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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Sonnet committed 2026-09-05 05:42:54 -04:00
1 parent a2cd119985
commit 03da47e550
2 files changed
+236 -41

No files matched your search

+200 -33
View File
@@ -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<Scroll>, StrongWidget) {
let mut span = Span::empty(Dir::DOWN);
) -> (WeakWidget<List>, 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);
}
}
+36 -8
View File
@@ -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;