From 03da47e550522239efdd45b73844ca5770857ec1 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 05:42:54 -0400 Subject: [PATCH] iris: benches/message_list.rs measures the real List, adds insert-above and expand-hold The (a)/(b)/(c) scenarios built their own Span+Scroll pair, so they never exercised the virtualised widget the transcript screen actually needs. Rewritten on top of iris::widget::List, plus two new scenarios from RUST.md's I3: (d) insert-above-anchor (paging older history onto an already-scrolled list) and (e) expand-a-row-holding-its-edge (list.rs's note_tap mechanism). Both come out flat across N = 100/1,000/10,000, as required. Also fixes a real inefficiency this rewrite surfaced: List::place's "generous" measurement bound was derived from viewport_len, so a sibling resizing the list itself (the (c) scenario) changed that bound every tick and defeated draw_inner's same-size fast path, forcing a full redraw of every visible row instead of a move. It is now a fixed module constant (GENEROUS_PADDING), independent of the list's own size -- draws for (c) dropped from 3059 to 684 over 40 ticks. cargo test -p iris (5 List tests still pass), cargo clippy --all-targets and --benches --release, cargo fmt --all -- --check all clean. Numbers recorded in RUST.md's I3 box. Co-Authored-By: Claude Sonnet --- iris/benches/message_list.rs | 233 ++++++++++++++++++++++++++++++----- iris/src/widget/list.rs | 44 +++++-- 2 files changed, 236 insertions(+), 41 deletions(-) diff --git a/iris/benches/message_list.rs b/iris/benches/message_list.rs index 7dc6cf8..bf849b8 100644 --- a/iris/benches/message_list.rs +++ b/iris/benches/message_list.rs @@ -1,6 +1,7 @@ //! On-demand benchmarks for iris's message-list scenario -- IRIS_TODO.md's -//! "Benchmarks" item. Never run by `cargo test`; run explicitly with -//! `cargo bench --bench message_list --release` or `./run-bench.sh`. +//! "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 @@ -13,26 +14,49 @@ //! -- 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. //! -//! Scenarios (LAYOUT.md's O(1) move chain, and IRIS_TODO.md's "Benchmarks" -//! wording): +//! **The list under test is `iris::widget::List` (RUST.md's I3), not a +//! `Scroll` over a `Span` of pre-built rows.** Earlier versions of this +//! file built their own giant `Span` and wrapped it in `Scroll`, which +//! meant (a)/(b)/(c) below were measuring "move one big child," never the +//! virtualised widget the app's transcript screen actually needs. `List` +//! 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, list.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 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. `List::push_front` is an O(1) index update +//! (list.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 (list.rs's `note_tap`) must move +//! only the rows on the far side of it, never redraw the ones already +//! correctly placed. //! -//! (d), many images with zero steady-state bind-group creation, needs a +//! (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 IRIS_TODO.md, not here -- this -//! file is the rig, not the result. +//! 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; @@ -82,21 +106,21 @@ fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget { } } -/// A `Scroll` over `n` message rows, one in `image_every` of them carrying -/// an image (0 disables images entirely). Returns the scroll widget (weak, -/// so the caller can drive it) and the erased root to render. -fn build_list( +/// A virtualised `List` 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 span = Span::empty(Dir::DOWN); +) -> (WeakWidget, StrongWidget) { + let mut list = List::new(Axis::Y); for i in 0..n { - span.push(build_row(rsc, i, image_every)); + let row = build_row(rsc, i, image_every); + list.push_back(ListRow::new(i as u64, row)); } - let span = rsc.ui.widgets.add_strong(span); - let scroll = rsc.ui.widgets.add_strong(Scroll::new(span.any(), Axis::Y)); - (scroll.weak(), scroll.any()) + let list = rsc.ui.widgets.add_strong(list); + (list.weak(), list.any()) } fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) { @@ -111,7 +135,7 @@ fn bench_first_frame(n: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; - let (_scroll, root) = build_list(&mut rsc, n, 20); + let (_list, root) = build_message_list(&mut rsc, n, 20); let mut render = UiRenderState::new(); render.resize((1080.0, 2000.0)); @@ -129,18 +153,18 @@ fn bench_first_frame(n: usize) { } /// (b) Per-frame cost of scrolling an already-laid-out list of N rows. -/// Warms up (as `layout_tests.rs`'s scrolling test documents: `Scroll` -/// needs one no-op tick before a real scroll becomes a same-size move +/// Warms up (one no-op tick, matching `Scroll`'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_list(&mut rsc, n, 20); + 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(&scroll).unwrap().scroll(0.0); + rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0); render.update(&root, &mut rsc); render.take_counters(); @@ -149,7 +173,7 @@ fn bench_scroll(n: usize, ticks: usize) { let mut total_rewrites = 0u64; let mut total_moves = 0u64; for _ in 0..ticks { - rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-8.0); + rsc.ui.widgets.get_mut(&list).unwrap().scroll(-8.0); let start = Instant::now(); render.update(&root, &mut rsc); total += start.elapsed(); @@ -174,18 +198,16 @@ fn bench_scroll(n: usize, ticks: usize) { /// (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 scroll 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 scroll's own re-registration of where its content sits -/// is the one O(1) move this is checking for. See LAYOUT.md's `Scroll` -/// design note on offering the child last frame's content length, which is -/// exactly what keeps this a move instead of a reflow. +/// 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_list(&mut rsc, n, 20); + let (list, list_root) = build_message_list(&mut rsc, n, 20); let list_area = rsc.ui.widgets.add_strong(Sized { inner: list_root, x: None, @@ -209,7 +231,7 @@ fn bench_input_grows(n: usize, lines: usize) { 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); + rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0); render.update(&root, &mut rsc); render.take_counters(); @@ -244,6 +266,145 @@ fn bench_input_grows(n: usize, lines: usize) { ); } +/// (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) (list.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(ListRow::new(i as u64, row)); + let start = Instant::now(); + render.update(&root, &mut rsc); + total += start.elapsed(); + let (draws, rewrites, moves) = 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 list.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 = List::new(Axis::Y); + // 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(ListRow::new(i as u64, sized.any())); + } else { + let row = build_row(&mut rsc, i, 20); + list.push_back(ListRow::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) = 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 + ); +} + fn main() { println!("iris message-list benchmark -- release build, this machine's CPU"); for &n in &[100usize, 1_000, 10_000] { @@ -255,4 +416,10 @@ fn main() { 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); + } } diff --git a/iris/src/widget/list.rs b/iris/src/widget/list.rs index 188f51d..d19137c 100644 --- a/iris/src/widget/list.rs +++ b/iris/src/widget/list.rs @@ -335,6 +335,16 @@ impl List { self.pending_tap = Some(viewport_pos); } + /// The on-screen `(top, bottom)` viewport-pixel extent of `key`'s row + /// as of the last layout, or `None` if it was not among the rows drawn + /// then (off-screen, not yet loaded, or the list hasn't drawn since). + /// What a caller reads to decide where to aim `note_tap` -- e.g. "the + /// top of the row that's about to expand" -- without duplicating this + /// widget's own layout math. + pub fn extent(&self, key: RowKey) -> Option<(f32, f32)> { + self.extents.get(&key).map(|e| (e.top, e.bottom)) + } + fn slot_exists(&self, slot: isize) -> bool { match slot { BEFORE_SLOT => self.more_before.is_some(), @@ -513,21 +523,27 @@ impl List { /// return its resolved `(leading, trailing)` edges in viewport pixels. fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) { let axis = self.axis; - // Large enough that no real row's content is taller than this - // (rows do not clip to the height they're offered -- only width - // drives a wrapped row's height), bounded so the region's abs - // values never approach f32 imprecision at the sizes this list - // ever sees. - let generous = self.viewport_len.max(64.0) * 8.0; let (top, bottom) = match placement { Placement::Top(top) => { - let region = Self::abs_region(axis, top, top + generous); + let region = Self::abs_region(axis, top, top + GENEROUS_PADDING); let used = painter.widget_within(self.slot_widget(slot), region); let h = Self::resolve_len_px(painter, axis, used.axis(axis)); (top, top + h) } Placement::Bottom(bottom) => { - let used = painter.widget(self.slot_widget(slot)); + // Measured at a fixed, zero-anchored region rather than + // `painter.region()` (the list's *actual* offered box): + // using the real box would make the measurement's offered + // *size* track this list's own height, so a sibling + // growing taller (the input-box case) would look like a + // resize to every bottom-known row and force a full + // redraw of each -- despite a row's content depending + // only on width. A region fixed at `[0, GENEROUS_PADDING]` + // is identical frame to frame regardless of what else on + // screen changed, so an unchanged row hits `draw_inner`'s + // exact-match skip (LAYOUT.md's caching section) instead. + let region = Self::abs_region(axis, 0.0, GENEROUS_PADDING); + let used = painter.widget_within(self.slot_widget(slot), region); let h = Self::resolve_len_px(painter, axis, used.axis(axis)); let top = bottom - h; let region = Self::abs_region(axis, top, bottom); @@ -542,6 +558,18 @@ impl List { } } +/// The oversized bound offered along the primary axis when a row's real +/// extent isn't known yet (a fresh top-known placement) or is deliberately +/// discarded (a bottom-known measurement, see `place`). Large enough that +/// no real row's content is taller than this -- rows do not clip to the +/// height they're offered, only width drives a wrapped row's height -- and +/// a fixed module constant rather than derived from `viewport_len`, since +/// deriving it from a value that changes whenever the list itself resizes +/// (a sibling growing) would make the offered region's *size* change too, +/// defeating the same-size-different-position fast path `place` depends +/// on for an O(1) move. +const GENEROUS_PADDING: f32 = 100_000.0; + impl Widget for List { fn draw(&mut self, painter: &mut Painter) -> Size { let axis = self.axis;