Steps 2 and 3 of the plan in docs/IRIS_TODO.md, together because
deleting the fling before `Scroll` could drive it would leave the app
unable to scroll at all. IRIS.md has the account and the measurements.
`LazySpan` loses its `Flinger`, its `density`, its
`Arc<dyn RequestRedraw>` -- which had no business existing in a
single-threaded frame loop -- its `tick`, and the whole
`fling`/`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity`
surface. `Scroll` was the only other `Flinger` user, so there is now one
implementation of the physics rather than two, and a transcript is
`list.scrollable_to_end()` like anything else.
Three new `Widget` methods carry the handoff:
fn scrolls_itself(&self) -> bool { false }
fn apply_scroll(&mut self, delta: &mut f32) {}
fn scroll_offset(&self) -> f32 { 0.0 }
`Scroll` asks the first, and a child that says yes is handed deltas
instead of being slid about as a lump -- which a lazy layout cannot be,
since which rows exist at all is a function of where it is scrolled to,
and it has no content length to be clamped against. `scrolls_itself` is
`&self` deliberately: `Widgets::get_dyn_mut` marks a widget dirty, so
asking through `apply_scroll` would dirty every ordinary child on every
tick and cost exactly the O(1) move the scheme exists for.
`Scroll::draw` is measure, apply, place -- the idiom it already used for
its own content length. The measuring draw is free in the common case
(unchanged region, nothing dirty, `draw_inner` returns immediately and
the child's stored walls are still correct) and really walks exactly
when the content changed. Nothing is marked by hand: reaching the child
to hand it the delta is what dirties it, which is why `draw_again` could
stay deleted.
`scroll_offset` was not in the plan and is needed. A lazy span usually
cannot say where its content ends until it has walked there, so it takes
a delta in full whenever the wall is not already in view and the walk
gives part of it back; the remainder is exact only when the wall was
already visible, and `Scroll` adding remainders up would over-count by
every overshoot and never correct. It reads the child's accumulated
movement after the placing draw instead, so `amt` equals what is on
screen. `amt_counts_only_what_the_child_could_take` is the test.
One convention for a scroll delta, the finger's. `Scroll::scroll(+)`
moved toward the start while `LazySpan::scroll(+)` moved toward the end,
with the latter's doc claiming to mirror the former -- so every call site
had to know which it was talking to. `LazySpan::scroll` is private now
and the single negation is inside its `apply_scroll`; call sites that
passed `-dy`/`-v` pass them through, and `phone_screen.rs`'s recorded
velocity flips sign with its magnitude unchanged.
`a_negative_delta_moves_toward_the_end` pins the sign across the whole
handoff, since nothing else can catch a list scrolling backwards.
The transcript builds its `Scroll` by hand rather than through
`.scrollable_to_end()`: that helper registers a finger drag, and
`Selection` is already the arbiter for those frames -- two `DragGesture`s
seeing one gesture is what its own doc rules out. Caught by
`a_long_press_and_drag_selects_text`, which failed when both were live.
Deferred, in DECISIONS.md and IRIS_TODO.md: the *pin* is still each
widget's own. Applying one happens when a row is appended, between
frames with no painter in hand, so moving it to `Scroll` needs a fourth
`Widget` method or a parameter on `apply_scroll`; nothing external edits
a pin today.
Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace green (21 suites), the arm64 release APK builds,
and the phone-shaped headless window replaying flick-120hz.touch scrolls
back through the transcript in the direction it did before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
434 lines
16 KiB
Rust
434 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::LazySpan` (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. `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.
|
|
//!
|
|
//! (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 `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<LazySpan>, WeakWidget<Scroll>, StrongWidget) {
|
|
let mut list = LazySpan::new(Dir::DOWN, true);
|
|
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);
|
|
let list_weak = list.weak();
|
|
// Scrolled through a `Scroll`, like every other scroll area in iris
|
|
// since the position moved out of the list: what this measures has to
|
|
// be the path the app actually takes.
|
|
let scroll = rsc
|
|
.ui
|
|
.widgets
|
|
.add_strong(Scroll::new(list.any(), Axis::Y, true));
|
|
(list_weak, scroll.weak(), scroll.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, _scroll, 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, 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 (_list, 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, _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(&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, true);
|
|
// 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
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|