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
|
||||
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
|
||||
|
||||
- [ ] **Benchmarks**, not unit tests, run on demand (a `benches/` or a
|
||||
script under `iris/`, never in `cargo test`). The scenario that matters
|
||||
most is a **message list** — chat apps and this app's transcript alike —
|
||||
stressed with many messages and many images. One case in particular:
|
||||
**resizing an input box** (typing enough text to grow it) that pushes a
|
||||
long list of messages above it must stay very fast and recalculate
|
||||
almost nothing — a move of everything above, not a re-layout. That is
|
||||
exactly the O(1) move chain in LAYOUT.md; the benchmark is what proves
|
||||
it. Done when the numbers are in this file with the command, and the
|
||||
input-box case reports draws re-run, not just frame time.
|
||||
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
||||
`benches/` or a script under `iris/`, never in `cargo test`). The
|
||||
scenario that matters most is a **message list** — chat apps and this
|
||||
app's transcript alike — stressed with many messages and many images.
|
||||
One case in particular: **resizing an input box** (typing enough text to
|
||||
grow it) that pushes a long list of messages above it must stay very
|
||||
fast and recalculate almost nothing — a move of everything above, not a
|
||||
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
|
||||
benchmark is what proves it. Done when the numbers are in this file with
|
||||
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
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user