iris: on-demand message-list/image benchmarks, and two O(N) findings
IRIS_TODO.md's "Benchmarks" item: a message list of N wrapped-text rows (first-frame cost), scrolling it, and growing an input box above which the list must move rather than re-layout -- all as a plain, harness=false `cargo bench` binary (iris/benches/message_list.rs) since UiRenderState touches no GPU or window, chosen over criterion because every scenario here reduces to a count take_counters already answers exactly, and a new dependency wasn't worth it. Scroll (200 ticks) and the input-grow case (40 lines) are flat across N=100/1,000/10,000: LAYOUT.md's O(1) move chain holds. The many-images case (d) needs a real wgpu device, so it's a headless example (iris/examples/bench_images.rs) plus a new GpuTextures/UiRenderNode counter, take_image_bind_group_creates, mirroring take_counters. It found two real non-O(1) costs, recorded as new Fix items rather than redesigned: bind-group creation takes two frames to settle after a cold load instead of one, and appending a single image to an already-loaded 1,000-image list rebuilds all 1,000 existing bind groups (masks/move_offsets buffer growth triggers rebuild_image_bind_groups unconditionally). run-bench.sh wraps both. Numbers and commands are in IRIS_TODO.md. cargo fmt --all -- --check, cargo clippy --all-targets, and cargo test --workspace (19 passed) all clean; benches are not run by cargo test. Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
This commit is contained in:
1 parent
fba572427d
commit
288853c094
7 files changed
+531
-11
No files matched your search
+114
-10
@@ -26,18 +26,122 @@ order and what "done" looks like. Tick and date them in place.
|
|||||||
still reaches the button — confirmed to fail on the pre-fix code and
|
still reaches the button — confirmed to fail on the pre-fix code and
|
||||||
pass after.
|
pass after.
|
||||||
|
|
||||||
|
- [ ] **Appending one image to an already-loaded list rebuilds every other
|
||||||
|
image's bind group (2026-09-05).** Found by the benchmark below, not
|
||||||
|
designed against: `GpuTextures::update` (`core/src/render/texture.rs`)
|
||||||
|
triggers `rebuild_image_bind_groups` — a loop over *every live
|
||||||
|
standalone image*, rebuilding its `BindGroup` — whenever the shared
|
||||||
|
`masks` or `move_offsets` GPU buffer is resized (`masks_resized ||
|
||||||
|
moves_resized` in `UiRenderNode::update`, `core/src/render/mod.rs`), and
|
||||||
|
a widget getting its *first* move-offset slot (LAYOUT.md section 2 —
|
||||||
|
every widget gets one on first draw) can be exactly what grows that
|
||||||
|
buffer. So one new message with one new image, appended to a transcript
|
||||||
|
that already has N images loaded, does not cost O(1): it costs one
|
||||||
|
`create_image` for the new image plus one `make_image_bind_group` per
|
||||||
|
*existing* image, because the new widget's own move slot pushed the
|
||||||
|
arena past its capacity. Measured directly in
|
||||||
|
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
|
||||||
|
already-settled ones reports **1,001** bind-group creates for that one
|
||||||
|
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
|
||||||
|
This is the same class of cost LAYOUT.md's move chain exists to avoid
|
||||||
|
elsewhere in the codebase, just not yet closed off here — the fix is
|
||||||
|
presumably to size `masks`/`move_offsets` with headroom (the array
|
||||||
|
texture already grows by doubling, `grow_array`, for the same reason) so
|
||||||
|
an ordinary append does not cross a capacity boundary, or to stop tying
|
||||||
|
the *image* bind group's contents to a buffer that changes on every new
|
||||||
|
widget in the whole tree, image or not. Not designed further here per
|
||||||
|
the "do not redesign, record it" instruction this benchmark was built
|
||||||
|
under.
|
||||||
|
- [ ] **Bind-group creation takes two frames to reach the steady state, not
|
||||||
|
one (2026-09-05).** Same benchmark: loading 1,000 images cold reports
|
||||||
|
1,000 creates on frame 1 (expected — this is `create_image`, one per
|
||||||
|
new image) *and again* 1,000 on frame 2, with nothing between the two
|
||||||
|
frames marked dirty, before settling to 0 from frame 3. The second
|
||||||
|
frame's 1,000 is `rebuild_image_bind_groups` again, for the same
|
||||||
|
masks/move-offsets buffer-growth reason as the item above — the arena
|
||||||
|
apparently does not finish growing to its steady size within the first
|
||||||
|
frame the tree is drawn. Not chased further; recorded so whoever fixes
|
||||||
|
the item above checks whether the fix also closes this one, since they
|
||||||
|
look like the same root cause measured two different ways.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
- [ ] **Benchmarks**, not unit tests, run on demand (a `benches/` or a
|
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
||||||
script under `iris/`, never in `cargo test`). The scenario that matters
|
`benches/` or a script under `iris/`, never in `cargo test`). The
|
||||||
most is a **message list** — chat apps and this app's transcript alike —
|
scenario that matters most is a **message list** — chat apps and this
|
||||||
stressed with many messages and many images. One case in particular:
|
app's transcript alike — stressed with many messages and many images.
|
||||||
**resizing an input box** (typing enough text to grow it) that pushes a
|
One case in particular: **resizing an input box** (typing enough text to
|
||||||
long list of messages above it must stay very fast and recalculate
|
grow it) that pushes a long list of messages above it must stay very
|
||||||
almost nothing — a move of everything above, not a re-layout. That is
|
fast and recalculate almost nothing — a move of everything above, not a
|
||||||
exactly the O(1) move chain in LAYOUT.md; the benchmark is what proves
|
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
|
||||||
it. Done when the numbers are in this file with the command, and the
|
benchmark is what proves it. Done when the numbers are in this file with
|
||||||
input-box case reports draws re-run, not just frame time.
|
the command, and the input-box case reports draws re-run, not just frame
|
||||||
|
time.
|
||||||
|
|
||||||
|
**Built as two rigs**, chosen per scenario by whether a real `wgpu`
|
||||||
|
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
|
||||||
|
most of this runs as an ordinary binary — the same property
|
||||||
|
`layout_tests.rs` relies on):
|
||||||
|
|
||||||
|
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
|
||||||
|
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
|
||||||
|
the file's own header for why (short version — every scenario here
|
||||||
|
reduces to a *count* `UiRenderState::take_counters` already produces,
|
||||||
|
which criterion's statistical machinery adds nothing to and which a
|
||||||
|
new dependency is not worth pulling in for). Covers (a) first-frame
|
||||||
|
cost of a message list of N wrapped-text rows (one in 20 also carrying
|
||||||
|
a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost
|
||||||
|
of scrolling that list, 200 ticks; (c) the input-box case — a
|
||||||
|
fixed-height field at the bottom of the screen growing by a line 40
|
||||||
|
times, with the message list above it filling the rest of the screen.
|
||||||
|
Run: `cd iris && cargo bench --bench message_list` (always release —
|
||||||
|
`cargo bench` builds the `bench` profile, which is optimized).
|
||||||
|
- `iris/examples/bench_images.rs` — needs a real device, so it runs
|
||||||
|
through `iris/run-headless.sh bench_images`, printing
|
||||||
|
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
|
||||||
|
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
|
||||||
|
mirroring `UiRenderState::take_counters`) each frame. Covers (d): 1,000
|
||||||
|
image rows, checked both cold (does bind-group creation reach zero
|
||||||
|
once loaded) and after appending one more image once settled (does
|
||||||
|
*that* stay cheap) — the second question is what actually matters for
|
||||||
|
a live transcript and is what turned up the two Fix items above.
|
||||||
|
- `iris/run-bench.sh [list|images]` runs either or both and is what to
|
||||||
|
run before/after touching `Scroll`, `Span`, `Sized`, the move-offset
|
||||||
|
chain, or `GpuTextures`.
|
||||||
|
|
||||||
|
**Numbers (2026-09-05, release, `cargo bench`/`run-headless.sh`, this
|
||||||
|
VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):**
|
||||||
|
|
||||||
|
cd iris && cargo bench --bench message_list
|
||||||
|
(a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0
|
||||||
|
(a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0
|
||||||
|
(a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0
|
||||||
|
(b) scroll, N=100/1000/10000, 200 ticks each:
|
||||||
|
draws=200 rewrites=0 moves=200 (identical at every N)
|
||||||
|
per-tick average: 0.0002ms (identical at every N)
|
||||||
|
(c) input grows 40 lines, N=100/1000/10000 rows above it:
|
||||||
|
draws=320 rewrites=40 moves=160 (identical at every N)
|
||||||
|
per-line average: 0.0012-0.0013ms (identical at every N)
|
||||||
|
|
||||||
|
cd iris && ./run-bench.sh images
|
||||||
|
frame=1 bind_group_creates=1000 (cold load)
|
||||||
|
frame=2 bind_group_creates=1000 (see Fix item above)
|
||||||
|
frame=3 bind_group_creates=0
|
||||||
|
frame=4 bind_group_creates=0
|
||||||
|
(append one image here)
|
||||||
|
frame=5 bind_group_creates=1001 (see Fix item above)
|
||||||
|
frame=6 bind_group_creates=0
|
||||||
|
|
||||||
|
**Reading it**: (a) is real, necessary work — shaping and laying out N
|
||||||
|
never-before-seen text rows — and scales with N as it must, ~10x cost
|
||||||
|
per 10x N. (b) and (c) are the pass conditions that matter: both are
|
||||||
|
**exactly flat across N = 100 to 10,000**, confirming LAYOUT.md's O(1)
|
||||||
|
move chain holds for both scrolling and for a growing input box pushing
|
||||||
|
the message list — draws/moves per tick or per line do not grow with
|
||||||
|
list size, and the per-operation cost (a fraction of a microsecond) is
|
||||||
|
nowhere near a frame budget. (d)'s cold-load and steady-state halves
|
||||||
|
behave as designed; its *append* half did not, which is the two Fix
|
||||||
|
items above.
|
||||||
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
||||||
by something *and also* applies mask B — a mask can reference a parent
|
by something *and also* applies mask B — a mask can reference a parent
|
||||||
mask, the way the move chain references a parent offset. Today masks
|
mask, the way the move chain references a parent offset. Today masks
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
||||||
|
|
||||||
|
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
|
||||||
|
# header for why. `harness = false` opts out of the unstable `#[bench]`
|
||||||
|
# test-crate harness cargo would otherwise want, in favour of an ordinary
|
||||||
|
# `fn main()`.
|
||||||
|
[[bench]]
|
||||||
|
name = "message_list"
|
||||||
|
harness = false
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["core", "macro"]
|
members = ["core", "macro"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
//! 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`.
|
||||||
|
//!
|
||||||
|
//! **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.
|
||||||
|
//!
|
||||||
|
//! Scenarios (LAYOUT.md's O(1) move chain, 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.
|
||||||
|
//! - (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), 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.
|
||||||
|
|
||||||
|
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 `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(
|
||||||
|
rsc: &mut BenchRsc,
|
||||||
|
n: usize,
|
||||||
|
image_every: usize,
|
||||||
|
) -> (WeakWidget<Scroll>, StrongWidget) {
|
||||||
|
let mut span = Span::empty(Dir::DOWN);
|
||||||
|
for i in 0..n {
|
||||||
|
span.push(build_row(rsc, i, image_every));
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (_scroll, root) = build_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) = 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 (as `layout_tests.rs`'s scrolling test documents: `Scroll`
|
||||||
|
/// needs one no-op tick before a real scroll 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 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) = 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 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.
|
||||||
|
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_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) = 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -441,4 +441,11 @@ impl UiRenderNode {
|
|||||||
pub fn view_count(&self) -> usize {
|
pub fn view_count(&self) -> usize {
|
||||||
self.textures.view_count()
|
self.textures.view_count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Standalone-image bind groups built since the last call -- see
|
||||||
|
/// `GpuTextures::take_bind_group_creates`. Call once per frame before
|
||||||
|
/// `update()` to measure exactly that frame.
|
||||||
|
pub fn take_image_bind_group_creates(&mut self) -> u64 {
|
||||||
|
self.textures.take_bind_group_creates()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +61,14 @@ pub struct GpuTextures {
|
|||||||
/// nothing of its own to put there: rects and glyphs never sample it,
|
/// nothing of its own to put there: rects and glyphs never sample it,
|
||||||
/// but the layout requires something bound regardless.
|
/// but the layout requires something bound regardless.
|
||||||
null_view: TextureView,
|
null_view: TextureView,
|
||||||
|
|
||||||
|
/// Standalone-image bind groups actually built (`create_image`'s own
|
||||||
|
/// build, or one per slot touched by `rebuild_image_bind_groups`) since
|
||||||
|
/// the last `take_bind_group_creates`. IRIS_TODO.md's "many images"
|
||||||
|
/// benchmark reads this to prove the steady-state cost of an
|
||||||
|
/// unchanging image list is zero, the same way `UiRenderState`'s
|
||||||
|
/// `draw_count`/`region_mut_count` prove the layout side.
|
||||||
|
bind_group_creates: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GpuTextures {
|
impl GpuTextures {
|
||||||
@@ -297,12 +305,13 @@ impl GpuTextures {
|
|||||||
masks,
|
masks,
|
||||||
move_offsets,
|
move_offsets,
|
||||||
);
|
);
|
||||||
|
self.bind_group_creates += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_image(
|
fn create_image(
|
||||||
&self,
|
&mut self,
|
||||||
image: &DynamicImage,
|
image: &DynamicImage,
|
||||||
rsc_layout: &BindGroupLayout,
|
rsc_layout: &BindGroupLayout,
|
||||||
masks: &ArrBuf<Mask>,
|
masks: &ArrBuf<Mask>,
|
||||||
@@ -339,6 +348,7 @@ impl GpuTextures {
|
|||||||
masks,
|
masks,
|
||||||
move_offsets,
|
move_offsets,
|
||||||
);
|
);
|
||||||
|
self.bind_group_creates += 1;
|
||||||
ImageGpu {
|
ImageGpu {
|
||||||
texture,
|
texture,
|
||||||
view,
|
view,
|
||||||
@@ -424,9 +434,17 @@ impl GpuTextures {
|
|||||||
page_count: 0,
|
page_count: 0,
|
||||||
sampler,
|
sampler,
|
||||||
null_view,
|
null_view,
|
||||||
|
bind_group_creates: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads and zeroes the standalone-image bind-group creation counter --
|
||||||
|
/// call once per frame before `update()`, mirroring
|
||||||
|
/// `UiRenderState::take_counters`.
|
||||||
|
pub fn take_bind_group_creates(&mut self) -> u64 {
|
||||||
|
std::mem::take(&mut self.bind_group_creates)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn array_view(&self) -> &TextureView {
|
pub fn array_view(&self) -> &TextureView {
|
||||||
&self.array_view
|
&self.array_view
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
//! (d) of IRIS_TODO.md's "Benchmarks" item: 1,000 image rows, checking that
|
||||||
|
//! standalone-image bind-group *creation* -- a real `wgpu` resource, unlike
|
||||||
|
//! the counters in `benches/message_list.rs` -- goes to zero once every
|
||||||
|
//! image has loaded. This needs an actual `wgpu` device (`GpuTextures`,
|
||||||
|
//! `UiRenderNode`), so unlike the rest of the suite it cannot run as a
|
||||||
|
//! plain binary; run it through `iris/run-headless.sh bench_images`, which
|
||||||
|
//! gives it a real (headless, GPU-accelerated) compositor and surface. See
|
||||||
|
//! `run-bench.sh` for the wrapper that greps its output into one line.
|
||||||
|
//!
|
||||||
|
//! Each `RedrawRequested` prints the frame number and
|
||||||
|
//! `UiRenderNode::take_image_bind_group_creates()` for that frame, then
|
||||||
|
//! requests another redraw (nothing else marks the scene dirty, so without
|
||||||
|
//! this the app would only ever draw once). The first frame is expected to
|
||||||
|
//! report 1,000 (one create per image, on first load); the steady state
|
||||||
|
//! IRIS_TODO.md asks this scenario to prove is every frame after settling
|
||||||
|
//! down to 0.
|
||||||
|
//!
|
||||||
|
//! After `SETTLE_FRAMES` it appends one *new* image row (a transcript
|
||||||
|
//! receiving one more message) and keeps counting -- a chat transcript's
|
||||||
|
//! real access pattern is "one more image arrives," not "reload the whole
|
||||||
|
//! list," so the steady-state question that actually matters is the
|
||||||
|
//! *incremental* cost of that one append, not just whether an untouched
|
||||||
|
//! scene costs zero. It exits after `FRAMES`.
|
||||||
|
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
const ROWS: usize = 1000;
|
||||||
|
const SETTLE_FRAMES: usize = 4;
|
||||||
|
const FRAMES: usize = 6;
|
||||||
|
|
||||||
|
#[derive(DefaultUiState)]
|
||||||
|
struct State {
|
||||||
|
ui_state: DefaultUiState,
|
||||||
|
span: WeakWidget<Span>,
|
||||||
|
frame: usize,
|
||||||
|
appended: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DefaultAppState for State {
|
||||||
|
fn new(
|
||||||
|
mut ui_state: DefaultUiState,
|
||||||
|
rsc: &mut DefaultRsc<Self>,
|
||||||
|
_: Proxy<Self::Event>,
|
||||||
|
) -> Self {
|
||||||
|
let mut span = Span::empty(Dir::DOWN);
|
||||||
|
for _ in 0..ROWS {
|
||||||
|
let img = image::DynamicImage::new_rgba8(32, 32);
|
||||||
|
let widget = image::<DefaultRsc<Self>>(img)(rsc);
|
||||||
|
let widget = rsc.ui.widgets.add_strong(widget);
|
||||||
|
span.push(widget.any());
|
||||||
|
}
|
||||||
|
let span = rsc.ui.widgets.add_strong(span);
|
||||||
|
let span_weak = span.weak();
|
||||||
|
let root = rsc.ui.widgets.add_strong(Scroll::new(span.any(), Axis::Y));
|
||||||
|
ui_state.set_root(root.any());
|
||||||
|
Self {
|
||||||
|
ui_state,
|
||||||
|
span: span_weak,
|
||||||
|
frame: 0,
|
||||||
|
appended: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_event(
|
||||||
|
&mut self,
|
||||||
|
event: winit::event::WindowEvent,
|
||||||
|
rsc: &mut DefaultRsc<Self>,
|
||||||
|
_render: &mut UiRenderState,
|
||||||
|
) {
|
||||||
|
if !matches!(event, winit::event::WindowEvent::RedrawRequested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.frame += 1;
|
||||||
|
let creates = self.ui_state.renderer.ui.take_image_bind_group_creates();
|
||||||
|
println!(
|
||||||
|
"BENCH_IMAGES frame={} bind_group_creates={creates}",
|
||||||
|
self.frame
|
||||||
|
);
|
||||||
|
if self.frame == SETTLE_FRAMES && !self.appended {
|
||||||
|
self.appended = true;
|
||||||
|
let img = image::DynamicImage::new_rgba8(32, 32);
|
||||||
|
let widget = image::<DefaultRsc<Self>>(img)(rsc);
|
||||||
|
let widget = rsc.ui.widgets.add_strong(widget);
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&self.span)
|
||||||
|
.unwrap()
|
||||||
|
.push(widget.any());
|
||||||
|
println!("BENCH_IMAGES appended one image after settling");
|
||||||
|
}
|
||||||
|
if self.frame < FRAMES {
|
||||||
|
self.ui_state.window.request_redraw();
|
||||||
|
} else {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
DefaultApp::<State>::run();
|
||||||
|
}
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs iris's on-demand benchmark suite (IRIS_TODO.md's "Benchmarks" item).
|
||||||
|
# Never run by `cargo test`; run this by hand or before/after a layout
|
||||||
|
# change. Always release -- see AGENTS.md's own rule against reading a
|
||||||
|
# frame time from a debug build.
|
||||||
|
#
|
||||||
|
# ./run-bench.sh # everything
|
||||||
|
# ./run-bench.sh list # just the CPU-only message-list scenarios
|
||||||
|
# ./run-bench.sh images # just the GPU bind-group-creation scenario
|
||||||
|
set -eu
|
||||||
|
here=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
cd "$here"
|
||||||
|
|
||||||
|
what="${1:-all}"
|
||||||
|
|
||||||
|
if [ "$what" = "all" ] || [ "$what" = "list" ]; then
|
||||||
|
echo "=== message_list (CPU-only, no window) ==="
|
||||||
|
cargo bench --bench message_list
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$what" = "all" ] || [ "$what" = "images" ]; then
|
||||||
|
echo "=== bench_images (real wgpu device, via run-headless.sh) ==="
|
||||||
|
timeout 60 ./run-headless.sh bench_images --seconds 4 2>&1 | grep "^BENCH_IMAGES"
|
||||||
|
fi
|
||||||
Reference in new issue
Block a user