//! On-demand benchmarks for iris's message-list scenario -- IRIS_TODO.md's //! "Benchmarks" item, and RUST.md's I3. Never run by `cargo test`; run //! explicitly with `cargo bench --bench message_list --release` or //! `./run-bench.sh`. //! //! **Why a plain `Instant`-timed binary, not criterion.** Every scenario //! here is really "how many `Widget::draw` calls and primitive rewrites did //! this frame cost," which `UiRenderState::take_counters` already answers //! exactly (see `iris/src/layout_tests.rs`, which this file's harness //! mirrors). A short loop that times itself and prints the counters //! alongside the wall time says everything criterion's warm-up/sampling/ //! outlier-removal machinery would add on top, for scenarios that are //! fundamentally about a *count*, not a noisy microbenchmark distribution //! -- and it avoids a new dependency this crate does not otherwise need. //! Per the code rules, the plain option is also the one shorter to explain. //! //! **The list under test is `iris::widget::LazySpan` (RUST.md's I3), not a //! `ScrollArea` over a `Span` of pre-built rows.** Earlier versions of this //! file built their own giant `Span` and wrapped it in `ScrollArea`, which //! meant (a)/(b)/(c) below were measuring "move one big child," never the //! virtualised widget the app's transcript screen actually needs. `LazySpan` //! still needs every row's *widget* built up front by the caller (its //! module doc explains why: it only ever sees `&dyn Widget` through //! `Painter`, so it cannot construct a row lazily on its own) -- what //! virtualisation buys is that only the rows currently on screen are ever //! *drawn*, which is what the draw/rewrite/move counters below are //! measuring, not construction time. //! //! Scenarios (LAYOUT.md's O(1) move chain, lazy_span.rs's module doc, and //! IRIS_TODO.md's "Benchmarks" wording): //! //! - (a) first-frame cost of a message list of N wrapped-text rows, some //! with an image, for N = 100 / 1,000 / 10,000. With a virtualised list //! this is expected to stop scaling with N once N exceeds a screenful -- //! the draw/rewrite counters below are the number that used to grow 10x //! per 10x N and should not any more. //! - (b) per-frame cost of scrolling that list -- must be O(1) moves, not //! re-layout. //! - (c) the input-box case: growing a fixed-height field at the bottom of //! the screen must move the message list above it, not re-lay its rows. //! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md //! section 8 defines. //! - (d) insert-above-anchor: paging older history onto the front of an //! already-scrolled list. `LazySpan::push_front` is an O(1) index update //! (lazy_span.rs's module doc); this measures that none of the rows already //! on screen are touched by it. //! - (e) expand-a-row-holding-its-edge: growing one row's height with a //! tap recorded near one of its edges (lazy_span.rs's `note_tap`) must move //! only the rows on the far side of it, never redraw the ones already //! correctly placed. //! //! - (g) redraw-one-big-text: a single text widget of N glyphs redrawn in //! place, which is what a tool card rebuilt on a tap costs. Every one of //! its primitives is freed and rewritten, and so renumbered in the //! layer's draw order -- the pass that used to be O(N^2) there //! (`UiRenderState::apply_free`, fixed 2026-09-08). The number to watch //! is per-glyph: it must stay flat as N grows, not grow with it. //! //! (f), many images with zero steady-state bind-group creation, needs a //! real `wgpu` device and lives in `iris/examples/bench_images.rs` instead, //! driven through `run-headless.sh` -- see that file's header. //! //! `UiRenderState`/`Widgets` touch no GPU or window (as `layout_tests.rs` //! notes), so everything here runs as an ordinary `--release` binary with //! no compositor. Numbers are recorded in RUST.md's I3 box, not here -- //! this file is the rig, not the result. use iris::prelude::*; use std::time::Instant; /// The minimal `UiRsc` a benchmark needs -- identical in shape to /// `layout_tests.rs`'s `TestRsc`. struct BenchRsc { ui: UiData, } impl UiRsc for BenchRsc { fn ui(&self) -> &UiData { &self.ui } fn ui_mut(&mut self) -> &mut UiData { &mut self.ui } } /// Long enough to force real wrapping at a phone-plausible column width, and /// varied enough (no two rows byte-identical) that nothing can special-case /// on repeated content. const BODY: &str = "The quick brown fox jumps over the lazy dog. Iris lays \ out wrapped text by shaping once per width and caching the result, so a \ row that is offered the same width twice does not reshape. This sentence \ exists only to give a row enough text to wrap across several lines at a \ typical phone column width."; /// One message row: a wrapped `Text`, and every `image_every`th row also an /// `Image` beneath it -- a small in-memory RGBA square rather than a file, /// so N=10,000 rows costs no disk I/O. fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget { let mut text = Text::new(format!("Message {i}: {BODY}")); text.wrap = true; let text = rsc.ui.widgets.add_strong(text).any(); if image_every > 0 && i.is_multiple_of(image_every) { let img = image::DynamicImage::new_rgba8(64, 64); let image_widget = image::(img)(rsc); let image_widget = rsc.ui.widgets.add_strong(image_widget).any(); let mut row = Span::empty(Dir::DOWN); row.push(text); row.push(image_widget); rsc.ui.widgets.add_strong(row).any() } else { text } } /// A virtualised `LazySpan` of `n` message rows, one in `image_every` of them /// carrying an image (0 disables images entirely). Returns the list widget /// (weak, so the caller can drive it) and the erased root to render. fn build_message_list( rsc: &mut BenchRsc, n: usize, image_every: usize, ) -> (WeakWidget, StrongWidget) { let mut list = LazySpan::new(Dir::DOWN, Pin::End); for i in 0..n { let row = build_row(rsc, i, image_every); list.push_back(LazyItem::new(i as u64, row)); } let list = rsc.ui.widgets.add_strong(list); // Driven through the span's own `ScrollController`, like every other // scroll area in iris: what this measures has to be the path the app // actually takes. (list.weak(), list.any()) } fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) { println!( "{label}: {:.2}ms draws={draws} rewrites={rewrites} moves={moves}", elapsed.as_secs_f64() * 1000.0 ); } /// (a) First-frame cost of a message list of N rows. fn bench_first_frame(n: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; let (_list, root) = build_message_list(&mut rsc, n, 20); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); let start = Instant::now(); render.update(&root, &mut rsc); let elapsed = start.elapsed(); let (draws, rewrites, moves, _shapes) = render.take_counters(); report( &format!("(a) first frame, N={n}"), elapsed, draws, rewrites, moves, ); } /// (b) Per-frame cost of scrolling an already-laid-out list of N rows. /// Warms up (one no-op tick, matching `ScrollArea`'s own need for it before an /// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move /// rather than a resize), then times a run of individual scroll ticks. fn bench_scroll(n: usize, ticks: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; let (scroll, root) = build_message_list(&mut rsc, n, 20); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); render.update(&root, &mut rsc); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); render.update(&root, &mut rsc); render.take_counters(); let mut total = std::time::Duration::ZERO; let mut total_draws = 0u64; let mut total_rewrites = 0u64; let mut total_moves = 0u64; for _ in 0..ticks { rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-8.0); let start = Instant::now(); render.update(&root, &mut rsc); total += start.elapsed(); let (draws, rewrites, moves, _shapes) = render.take_counters(); total_draws += draws; total_rewrites += rewrites; total_moves += moves; } report( &format!("(b) scroll, N={n}, {ticks} ticks (totals; expect draws/moves independent of N)"), total, total_draws, total_rewrites, total_moves, ); println!( " per-tick average: {:.4}ms", total.as_secs_f64() * 1000.0 / ticks as f64 ); } /// (c) The input-box case: a fixed-height field at the bottom of the screen /// growing by a line at a time, with a message list of N rows filling the /// rest of the screen above it. Growing the input shrinks the *offered* /// height of the list container (a single widget, from the outer `Span`'s /// point of view) without changing the width it offers its content -- so /// the rows underneath, which only care about width, must not redraw; the /// list's own re-registration of where its content sits is the one O(1) /// move this is checking for. fn bench_input_grows(n: usize, lines: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; let (scroll, list_root) = build_message_list(&mut rsc, n, 20); let list_area = rsc.ui.widgets.add_strong(Sized { inner: list_root, x: None, y: Some(rest(1.0)), }); let line_height = 24.0; let input_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let input_area = rsc.ui.widgets.add_strong(Sized { inner: input_rect.any(), x: None, y: Some(abs(line_height)), }); let input_area_weak = input_area.weak(); let mut root_span = Span::empty(Dir::DOWN); root_span.push(list_area.any()); root_span.push(input_area.any()); let root = rsc.ui.widgets.add_strong(root_span).any(); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); render.update(&root, &mut rsc); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); render.update(&root, &mut rsc); render.take_counters(); let mut total = std::time::Duration::ZERO; let mut total_draws = 0u64; let mut total_rewrites = 0u64; let mut total_moves = 0u64; for line in 1..=lines { rsc.ui.widgets.get_mut(&input_area_weak).unwrap().y = Some(abs(line_height * (line + 1) as f32)); let start = Instant::now(); render.update(&root, &mut rsc); total += start.elapsed(); let (draws, rewrites, moves, _shapes) = render.take_counters(); total_draws += draws; total_rewrites += rewrites; total_moves += moves; } report( &format!( "(c) input grows by {lines} lines above N={n} rows (totals; \ draws/rewrites must not scale with N)" ), total, total_draws, total_rewrites, total_moves, ); println!( " per-line average: {:.4}ms", total.as_secs_f64() * 1000.0 / lines as f64 ); } /// (d) Insert-above-anchor: the list is scrolled to its very first loaded /// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default /// bottom, so a row prepended above it is genuinely "inserted above the /// anchor" rather than merely far off-screen at the far end. Each /// `push_front` is O(1) (lazy_span.rs's module doc: the anchor's slot is an /// index, bumped by one) and, since the prepended rows never enter the /// viewport, none of them should cost a draw either. fn bench_insert_above_anchor(n: usize, inserts: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; let (list, root) = build_message_list(&mut rsc, n, 20); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); render.update(&root, &mut rsc); rsc.ui.widgets.get_mut(&list).unwrap().jump_to_start(); render.update(&root, &mut rsc); render.take_counters(); let mut total = std::time::Duration::ZERO; let mut total_draws = 0u64; let mut total_rewrites = 0u64; let mut total_moves = 0u64; for i in 0..inserts { // Older-history rows: distinct keys below every existing one, so a // real caller's paging code (prepending an older page) is exactly // what this loop does. let row = build_row(&mut rsc, usize::MAX - i, 20); rsc.ui .widgets .get_mut(&list) .unwrap() .push_front(LazyItem::new(i as u64, row)); let start = Instant::now(); render.update(&root, &mut rsc); total += start.elapsed(); let (draws, rewrites, moves, _shapes) = render.take_counters(); total_draws += draws; total_rewrites += rewrites; total_moves += moves; } report( &format!( "(d) insert-above-anchor, N={n}, {inserts} pushes (totals; \ must not scale with N)" ), total, total_draws, total_rewrites, total_moves, ); println!( " per-push average: {:.4}ms", total.as_secs_f64() * 1000.0 / inserts as f64 ); } /// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is /// directly controllable) is grown a little at a time, each time preceded /// by `note_tap` aimed at its own top edge -- the exact mechanism lazy_span.rs's /// module doc describes and its unit tests check for correctness. This /// measures its *cost*: only the rows on the far side of the grown one /// (below it, since the top edge is held) should ever move, and nothing /// should be redrawn purely because the list overall got taller. fn bench_expand_holds_edge(n: usize, growths: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); // Near the end (not the very last row) so it is already on screen // under the list's default bottom-anchored placement, for every N -- // no scrolling needed to bring it into view before measuring. let growable_index = n.saturating_sub(3); let mut growable = None; for i in 0..n { if i == growable_index { let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let sized = rsc.ui.widgets.add_strong(Sized { inner: rect.any(), x: None, y: Some(abs(40.0)), }); growable = Some(sized.weak()); list.push_back(LazyItem::new(i as u64, sized.any())); } else { let row = build_row(&mut rsc, i, 20); list.push_back(LazyItem::new(i as u64, row)); } } let list = rsc.ui.widgets.add_strong(list); let list_weak = list.weak(); let root = list.any(); let growable = growable.unwrap(); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); render.update(&root, &mut rsc); render.take_counters(); let mut total = std::time::Duration::ZERO; let mut total_draws = 0u64; let mut total_rewrites = 0u64; let mut total_moves = 0u64; let mut height = 40.0f32; let key = growable_index as u64; for _ in 0..growths { height += 10.0; if let Some((top, _bottom)) = rsc.ui.widgets.get(&list_weak).unwrap().extent(key) { rsc.ui .widgets .get_mut(&list_weak) .unwrap() .note_tap(top + 1.0); } rsc.ui.widgets.get_mut(&growable).unwrap().y = Some(abs(height)); let start = Instant::now(); render.update(&root, &mut rsc); total += start.elapsed(); let (draws, rewrites, moves, _shapes) = render.take_counters(); total_draws += draws; total_rewrites += rewrites; total_moves += moves; } report( &format!( "(e) expand-hold, N={n}, {growths} growths (totals; \ must not scale with N)" ), total, total_draws, total_rewrites, total_moves, ); println!( " per-growth average: {:.4}ms", total.as_secs_f64() * 1000.0 / growths as f64 ); } /// (g) One text widget of `chars` characters, redrawn in place `redraws` /// times -- an open tool card whose content is rebuilt, or any widget /// holding a lot of text that a tap changes. /// /// A redraw frees every primitive the widget owned and writes fresh ones, /// so every glyph is renumbered in its layer's draw order. Finding the /// handle to renumber used to be a scan of everything the same widget /// drew, which made one redraw quadratic in its own glyph count: 1.37s for /// 51,200 glyphs on this machine, against 20ms to shape and rasterise the /// same text. Print per-glyph rather than per-redraw, since flat is the /// pass condition and a total says nothing without dividing it. fn bench_redraw_big_text(chars: usize, redraws: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; // One character per glyph, and varied so nothing can collapse the // string into a repeat. let content: String = (0..chars) .map(|i| char::from(b'a' + (i % 26) as u8)) .collect(); let mut text = Text::new(content); text.wrap = true; let text = rsc.ui.widgets.add_strong(text); let handle = text.weak(); let root = text.any(); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); render.update(&root, &mut rsc); render.take_counters(); let mut total = std::time::Duration::ZERO; for _ in 0..redraws { // Asking for the widget mutably is what marks it for redraw -- // the same path a caller changing its content takes. rsc.ui.widgets.get_mut(&handle).unwrap(); let start = Instant::now(); render.update(&root, &mut rsc); total += start.elapsed(); } let (draws, rewrites, moves, _shapes) = render.take_counters(); report( &format!("(g) redraw one {chars}-glyph text, {redraws}x (totals)"), total, draws, rewrites, moves, ); println!( " per redraw: {:.3}ms, per glyph: {:.4}us", total.as_secs_f64() * 1000.0 / redraws as f64, total.as_secs_f64() * 1_000_000.0 / (redraws * chars) as f64, ); } fn main() { println!("iris message-list benchmark -- release build, this machine's CPU"); for &n in &[100usize, 1_000, 10_000] { bench_first_frame(n); } for &n in &[100usize, 1_000, 10_000] { bench_scroll(n, 200); } for &n in &[100usize, 1_000, 10_000] { bench_input_grows(n, 40); } for &n in &[100usize, 1_000, 10_000] { bench_insert_above_anchor(n, 200); } for &n in &[100usize, 1_000, 10_000] { bench_expand_holds_edge(n, 40); } for &chars in &[1_000usize, 10_000, 50_000] { bench_redraw_big_text(chars, 10); } }