Perf profiling and some low hanging perf improvements in text rendering

This commit is contained in:
Will Temple
2026-05-16 19:07:44 -04:00
parent 4347232b98
commit e5ae067bc2
13 changed files with 1010 additions and 117 deletions

View File

@@ -0,0 +1,8 @@
[package]
name = "ruin_perf_scenarios"
version = "0.1.0"
edition = "2024"
[dependencies]
ruin_perf = { path = "../../lib/perf" }
ruin_ui = { path = "../../lib/ui" }

View File

@@ -0,0 +1,366 @@
use std::env;
use std::io;
use std::path::PathBuf;
use std::time::Instant;
use ruin_perf::{PerfReportBuilder, RunMetadata};
use ruin_ui::{
AlignItems, Color, Edges, Element, FlexDirection, LayoutCache, LayoutFrameStats,
ScrollbarStyle, TextStyle, TextSystem, UiSize, layout_snapshot_with_cache_and_stats,
};
const DEFAULT_FRAMES: u64 = 300;
#[derive(Clone, Debug)]
struct Config {
scenario: Scenario,
frames: u64,
output: Option<PathBuf>,
text_kb: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Scenario {
Counter,
LargeScrollList,
Calculator,
ResizeConfigure,
LargeTextBody,
}
impl Scenario {
const ALL: &'static [Self] = &[
Self::Counter,
Self::LargeScrollList,
Self::Calculator,
Self::ResizeConfigure,
Self::LargeTextBody,
];
const fn name(self) -> &'static str {
match self {
Self::Counter => "counter",
Self::LargeScrollList => "large-scroll-list",
Self::Calculator => "calculator",
Self::ResizeConfigure => "resize-configure",
Self::LargeTextBody => "large-text-body",
}
}
fn parse(value: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|scenario| scenario.name() == value)
}
}
fn main() -> io::Result<()> {
let config = Config::parse(env::args().skip(1))?;
let report = run_scenario(&config);
let output = config
.output
.clone()
.unwrap_or_else(|| report.default_output_path());
report.write_json(&output)?;
report.print_summary();
println!();
println!("wrote {}", output.display());
Ok(())
}
impl Config {
fn parse(mut args: impl Iterator<Item = String>) -> io::Result<Self> {
let mut scenario = Scenario::LargeScrollList;
let mut frames = DEFAULT_FRAMES;
let mut output = None;
let mut text_kb = 256usize;
while let Some(arg) = args.next() {
match arg.as_str() {
"--scenario" => {
let value = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--scenario needs a value")
})?;
scenario = Scenario::parse(&value).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown scenario '{value}'"),
)
})?;
}
"--frames" => {
let value = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--frames needs a value")
})?;
frames = value.parse::<u64>().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid frame count '{value}': {error}"),
)
})?;
}
"--output" => {
let value = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--output needs a value")
})?;
output = Some(PathBuf::from(value));
}
"--text-kb" => {
let value = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--text-kb needs a value")
})?;
text_kb = value.parse::<usize>().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid text size '{value}': {error}"),
)
})?;
}
"--list" => {
for scenario in Scenario::ALL {
println!("{}", scenario.name());
}
std::process::exit(0);
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown argument '{other}'"),
));
}
}
}
Ok(Self {
scenario,
frames,
output,
text_kb,
})
}
}
fn print_help() {
println!(
"ruin_perf_scenarios --scenario <name> [--frames <n>] [--output <path>] [--text-kb <n>]"
);
println!();
println!("scenarios:");
for scenario in Scenario::ALL {
println!(" {}", scenario.name());
}
}
fn run_scenario(config: &Config) -> ruin_perf::PerfReport {
let mut report =
PerfReportBuilder::new(RunMetadata::new(config.scenario.name(), config.frames));
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
report.note("headless layout/app-build scenario; renderer/platform timings are not included");
if config.scenario == Scenario::LargeTextBody {
report.set_counter("text_body_kb", config.text_kb as f64);
}
for frame in 0..config.frames {
let build_started = Instant::now();
let (tree, viewport) = match config.scenario {
Scenario::Counter => counter_tree(frame),
Scenario::LargeScrollList => large_scroll_list_tree(frame),
Scenario::Calculator => calculator_tree(frame),
Scenario::ResizeConfigure => resize_configure_tree(frame),
Scenario::LargeTextBody => large_text_body_tree(frame, config.text_kb),
};
let build_ms = build_started.elapsed().as_secs_f64() * 1_000.0;
let (_snapshot, stats) = layout_snapshot_with_cache_and_stats(
frame + 1,
viewport,
&tree,
&mut text_system,
&mut layout_cache,
);
report.record_ms("app_build", build_ms);
record_layout_stats(&mut report, stats);
}
report.finish()
}
fn record_layout_stats(report: &mut PerfReportBuilder, stats: LayoutFrameStats) {
report.record_ms("layout_total", stats.total_ms);
report.record_ms("layout_intrinsic", stats.intrinsic_ms);
report.record_ms("text_prepare", stats.text_prepare_ms);
report.record_ms("text_cache_miss", stats.text_miss_ms);
report.record("layout_nodes", "count", stats.nodes as f64);
report.record("scene_items", "count", stats.scene_items as f64);
report.record("text_glyphs", "count", stats.text_output_glyphs as f64);
report.add_counter("layout_cache_hits", stats.layout_cache_hits as f64);
report.add_counter("layout_cache_misses", stats.layout_cache_misses as f64);
report.add_counter("intrinsic_cache_hits", stats.intrinsic_cache_hits as f64);
report.add_counter("viewport_culled", stats.viewport_culled as f64);
report.add_counter("text_cache_hits", stats.text_cache_hits as f64);
report.add_counter("text_cache_misses", stats.text_cache_misses as f64);
report.add_counter("text_requests", stats.text_requests as f64);
}
fn text_style(size: f32) -> TextStyle {
TextStyle::new(size, Color::rgb(0x20, 0x20, 0x20))
}
fn counter_tree(frame: u64) -> (Element, UiSize) {
let count = frame as i64;
let root = Element::column()
.gap(12.0)
.padding(Edges::all(24.0))
.align_items(AlignItems::Center)
.child(Element::text("Counter", text_style(28.0)))
.child(Element::text(format!("count = {count}"), text_style(18.0)))
.child(Element::text(
format!("double = {}", count * 2),
text_style(18.0),
))
.child(button_row(["-1", "Reset", "+1"]));
(root, UiSize::new(480.0, 320.0))
}
fn large_scroll_list_tree(frame: u64) -> (Element, UiSize) {
let item_count = 1_000usize;
let item_height = 28.0;
let viewport = UiSize::new(480.0, 640.0);
let max_offset = (item_count as f32 * item_height - viewport.height).max(0.0);
let offset = ((frame as f32 * 19.0) % max_offset.max(1.0)).round();
let mut list = Element::column().gap(0.0);
for index in 0..item_count {
list = list.child(Element::new().height(item_height).child(Element::text(
format!("list item {index:04}"),
text_style(14.0),
)));
}
let root = Element::new().child(
Element::scroll_box(offset)
.width(viewport.width)
.height(viewport.height)
.scrollbar_style(ScrollbarStyle::default())
.child(list),
);
(root, viewport)
}
fn calculator_tree(frame: u64) -> (Element, UiSize) {
let expression = match frame % 6 {
0 => "2",
1 => "2+",
2 => "2+2",
3 => "4",
4 => "4*8",
_ => "32",
};
let history_len = (frame as usize / 12).min(40);
let mut history = Element::column().gap(4.0);
for index in 0..history_len {
history = history.child(Element::text(
format!("{} + {} = {}", index, index + 1, index * 2 + 1),
text_style(12.0),
));
}
let root = Element::column()
.gap(10.0)
.padding(Edges::all(20.0))
.child(Element::text("Calculator", text_style(28.0)))
.child(display_panel(expression))
.child(button_grid())
.child(Element::scroll_box(10_000.0).height(180.0).child(history));
(root, UiSize::new(420.0, 720.0))
}
fn resize_configure_tree(frame: u64) -> (Element, UiSize) {
let width = 360.0 + (frame % 90) as f32 * 4.0;
let height = 280.0 + (frame % 60) as f32 * 3.0;
let mut content = Element::column().gap(8.0).padding(Edges::all(16.0));
for index in 0..40 {
content = content.child(Element::text(
format!("resize row {index:02}: {width:.0} x {height:.0}"),
text_style(14.0),
));
}
(content, UiSize::new(width, height))
}
fn large_text_body_tree(frame: u64, text_kb: usize) -> (Element, UiSize) {
let viewport = UiSize::new(720.0, 720.0);
let text_width = 680.0;
let line_height = 18.0;
let text = large_text_body(text_kb);
let estimated_line_count = (text.len() as f32 / 72.0).ceil();
let estimated_height = (estimated_line_count * line_height).max(viewport.height);
let max_offset = (estimated_height - viewport.height).max(0.0);
let offset = ((frame as f32 * 127.0) % max_offset.max(1.0)).round();
let style = text_style(14.0)
.with_line_height(line_height)
.with_bounds(UiSize::new(text_width, estimated_height));
let root = Element::new().child(
Element::scroll_box(offset)
.width(viewport.width)
.height(viewport.height)
.child(
Element::new()
.width(text_width)
.child(Element::paragraph(text, style)),
),
);
(root, viewport)
}
fn large_text_body(text_kb: usize) -> String {
let target_bytes = text_kb.saturating_mul(1024).max(1);
let paragraph = "RUIN large text profiling paragraph: the quick brown fox jumps over the lazy dog. This line is intentionally ordinary prose with enough words to exercise wrapping, glyph collection, hit-test geometry, and cache behavior.\n";
let mut text = String::with_capacity(target_bytes + paragraph.len());
while text.len() < target_bytes {
text.push_str(paragraph);
}
text.truncate(target_bytes);
text
}
fn display_panel(expression: &str) -> Element {
Element::new()
.padding(Edges::all(12.0))
.border(1.0, Color::rgb(0x80, 0x80, 0x80))
.child(Element::text(expression, text_style(24.0)))
}
fn button_grid() -> Element {
let rows = [
["7", "8", "9", "/"],
["4", "5", "6", "*"],
["1", "2", "3", "-"],
["0", ".", "=", "+"],
];
let mut grid = Element::column().gap(6.0);
for row in rows {
grid = grid.child(button_row(row));
}
grid
}
fn button_row<const N: usize>(labels: [&str; N]) -> Element {
let mut row = Element::new().direction(FlexDirection::Row).gap(6.0);
for label in labels {
row = row.child(
Element::new()
.width(76.0)
.height(36.0)
.border(1.0, Color::rgb(0x90, 0x90, 0x90))
.child(Element::text(label, text_style(16.0))),
);
}
row
}