diff --git a/IRIS_TODO.md b/IRIS_TODO.md index bb2e411..32ec671 100644 --- a/IRIS_TODO.md +++ b/IRIS_TODO.md @@ -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 diff --git a/iris/Cargo.toml b/iris/Cargo.toml index 045fd80..e4c4a8e 100644 --- a/iris/Cargo.toml +++ b/iris/Cargo.toml @@ -20,6 +20,14 @@ tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] } [dev-dependencies] 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] members = ["core", "macro"] diff --git a/iris/benches/message_list.rs b/iris/benches/message_list.rs new file mode 100644 index 0000000..7dc6cf8 --- /dev/null +++ b/iris/benches/message_list.rs @@ -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::(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, 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); + } +} diff --git a/iris/core/src/render/mod.rs b/iris/core/src/render/mod.rs index 8a89c1a..a342a6c 100644 --- a/iris/core/src/render/mod.rs +++ b/iris/core/src/render/mod.rs @@ -441,4 +441,11 @@ impl UiRenderNode { pub fn view_count(&self) -> usize { 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() + } } diff --git a/iris/core/src/render/texture.rs b/iris/core/src/render/texture.rs index 83ee913..f420a09 100644 --- a/iris/core/src/render/texture.rs +++ b/iris/core/src/render/texture.rs @@ -61,6 +61,14 @@ pub struct GpuTextures { /// nothing of its own to put there: rects and glyphs never sample it, /// but the layout requires something bound regardless. 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 { @@ -297,12 +305,13 @@ impl GpuTextures { masks, move_offsets, ); + self.bind_group_creates += 1; } } } fn create_image( - &self, + &mut self, image: &DynamicImage, rsc_layout: &BindGroupLayout, masks: &ArrBuf, @@ -339,6 +348,7 @@ impl GpuTextures { masks, move_offsets, ); + self.bind_group_creates += 1; ImageGpu { texture, view, @@ -424,9 +434,17 @@ impl GpuTextures { page_count: 0, sampler, 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 { &self.array_view } diff --git a/iris/examples/bench_images.rs b/iris/examples/bench_images.rs new file mode 100644 index 0000000..a0e016e --- /dev/null +++ b/iris/examples/bench_images.rs @@ -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, + frame: usize, + appended: bool, +} + +impl DefaultAppState for State { + fn new( + mut ui_state: DefaultUiState, + rsc: &mut DefaultRsc, + _: Proxy, + ) -> Self { + let mut span = Span::empty(Dir::DOWN); + for _ in 0..ROWS { + let img = image::DynamicImage::new_rgba8(32, 32); + let widget = image::>(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, + _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::>(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::::run(); +} diff --git a/iris/run-bench.sh b/iris/run-bench.sh new file mode 100755 index 0000000..21a4a1e --- /dev/null +++ b/iris/run-bench.sh @@ -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