take_counters gains a fourth counter, text shapes, bumped in Painter::render_text -- which TextView::render only reaches on a cache miss, so it counts shapes and not requests. A draw counter cannot stand in for it in either direction: a widget can be redrawn without re-shaping (the layout is memoized by width) and re-shaped without any extra draw, and re-shaping is the whole thing the per-block transcript row exists to avoid. With it, a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one asserts the number docs/DECISIONS.md's 2026-09-06 entry actually claims: one delta into a 100-paragraph reply shapes exactly one text layout, the same as into a one-paragraph one. Before the split that was necessarily O(message), since the reply was one buffer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
426 lines
16 KiB
Rust
426 lines
16 KiB
Rust
//! 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::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 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.
|
|
//!
|
|
//! (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::<BenchRsc>(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 `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<List>, StrongWidget) {
|
|
let mut list = List::new(Axis::Y);
|
|
for i in 0..n {
|
|
let row = build_row(rsc, i, image_every);
|
|
list.push_back(ListRow::new(i as u64, row));
|
|
}
|
|
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) {
|
|
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 `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 (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().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(&list).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 (list, 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(&list).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) (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, _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 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, _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
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|