From 974d6c7a52c32196d897689f1c2f8e04ff09e46f Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Mon, 14 Sep 2026 17:06:41 -0400 Subject: [PATCH] Document coalesced resize layout --- docs/IRIS_EXTRACTION_HANDOFF.md | 64 +++++++++++++++++++++++---------- docs/LAYOUT.md | 34 +++++++----------- 2 files changed, 59 insertions(+), 39 deletions(-) diff --git a/docs/IRIS_EXTRACTION_HANDOFF.md b/docs/IRIS_EXTRACTION_HANDOFF.md index c2d5227..42a374d 100644 --- a/docs/IRIS_EXTRACTION_HANDOFF.md +++ b/docs/IRIS_EXTRACTION_HANDOFF.md @@ -9,7 +9,7 @@ Canonical `main` is **`ca2b4b2`** (#17, the headless rig). Sixteen slices are in. **#18 `split/18-position-chain`** is open and finished apart from one decision: -worktree `/home/bob/repos/iris-pr18`, head `480f0bc`, sixteen commits, +worktree `/home/bob/repos/iris-pr18`, head `82fa6c1`, seventeen commits, workspace tests passing, fmt and clippy clean. It is LAYOUT.md ยง2's position chain, generalised to boxes. `/home/bob/repos/ai-app-2` is on `rustify`, worktree clean. @@ -183,24 +183,51 @@ IRIS_PHASE=resize IRIS_DEPTH=8 IRIS_FRAMES=1000 \ --test layout_diagnostics -- --ignored --nocapture ``` -The first depth-8 resize run makes the amplification concrete. A 260-widget -tree has 215 active widgets, but a resize averages 1,395 widget draws, 913 +`82fa6c1` adds targeted tracing to the same feature. Call +`layout_diagnostics::trace_widget(id)` before a frame; `take().traces()` then +returns the selected widgets' ordered draw requests and pixel boxes, reuse +outcomes, placements, hints, size reads and reported sizes, and text widths. +The selection is a set, survives `take()`, and is removed with +`untrace_widget` or `clear_traced_widgets`. This replaces temporary +text-specific logging without imposing anything on normal builds. + +The first depth-8 resize run made the amplification concrete. A 260-widget +tree has 215 active widgets, but a resize averaged 1,395 widget draws, 913 placement calls, 1,313 reads of drawn sizes and 34,844 primitive writes. Only -12 distinct text widgets render, yet they render and reshape 282 times per -frame with no shape-cache hits. Text rendering accounts for 11.6 of 13.2 ms, -including 9.9 ms shaping and 1.7 ms placing glyphs. The hottest two text -widgets each draw 96 times per frame at 22 distinct widths across two resize -frames, below nested `Span`, `Aligned`, `Scroll`, `Pad`, and `SetSize` -ancestors. This identifies repeated constraint discovery, rather than resize -marking (0.001 ms), as the next subject; it does not yet choose between -per-axis retained size validity and coalescing the resize dependency frontier. +12 distinct text widgets rendered, yet they rendered and reshaped 282 times +per frame with no shape-cache hits. Text rendering accounted for 11.6 of 13.2 +ms, including 9.9 ms shaping and 1.7 ms placing glyphs. The hottest two text +widgets each drew 96 times below nested `Span`, `Aligned`, `Scroll`, `Pad`, and +`SetSize` ancestors. + +Tracing one of them showed that 96 was two multipliers, not 96 necessary +layout iterations. One traversal of the nested positioning chain shaped it 32 +times at 12 widths; three resize-dependent descendants then caused that whole +traversal to run three times through the same highest size reader. Resize +marking had queued each pixel reader independently, and each leaf discovered +and redrew the same reader path in turn. + +`82fa6c1` coalesces that frontier by marking every resize-dependent leaf and +its size-reader chain first, then settling the shallowest shared reader once. +Ordinary content and size changes remain deepest-first. On the same depth-8 +load, an actual resize now averages about 694 widget draws, 435 placement +calls, 649 drawn-size reads, 15,343 primitive writes, and 116 text shapes; the +hottest texts draw 32 times. Instrumented layout is about 5.5 ms instead of +13.2 ms. An uninstrumented 1,000-frame resize run measured a 5.61 ms median, +60.79B instructions total (60.8M/frame), and 23.85B cycles. This is about 58% +fewer instructions than the roughly 138M/frame #18 path before coalescing, +but still about 1.8x the old 3.14 ms resize median. The remaining multiplier +is within one constraint traversal, so per-axis retained size validity is now +the useful next question. Re-sending the existing output size also no longer +starts a resize. The generated tree now includes `Aligned` with every meaningful per-axis alignment. That exposed two retained-layout ordering bugs which `84f589e` -fixes. The regular cold-layout equivalence suite passes. The ignored 100-seed -sweep passed every transition through seed 59 and now gets past the former -scroll failure at seed 52; at seed 60 it reaches the already-documented -iterative wrapping-text defect, differing by 0.00003 px after `SwapForThree`. +fixes. The regular cold-layout equivalence suite and the ignored 100-seed +sweep pass. The latter previously stopped at seed 60 on `140.0` versus +`139.99996948242188`: exactly two `f32` ULPs from an equivalent composition +order, not a visible layout difference. The oracle now keeps draw presence +exact and allows at most four ULPs per pixel coordinate. The branches are invariants rather than widget exceptions: @@ -211,9 +238,10 @@ The branches are invariants rather than widget exceptions: condition the discarded `/tmp/escalate.patch` missed on resize. - Dirty widgets settle deepest-first. A changed size queues only its immediate reader; propagation stops as soon as a reader's own answer stays unchanged. - During an output resize, the resize condition stays live until these updates - finish, so an `Aligned` ancestor chooses the new child box before a - pixel-dependent descendant draws in it. + Output resize is the distinct invariant: all pixel-dependent leaves and + their reader chains are marked together, then shared reader roots settle + shallowest-first under the new output. This both chooses the parent box + before drawing its descendant and coalesces overlapping resize paths. - A span measures an unknown child in the part of its axis still available, rather than giving every child the whole container and immediately taking most of it away. This is the archive's faster shape, recreated on the current diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index 6e559a1..148760d 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -102,27 +102,19 @@ narrowings apply, and both are real, measured properties of the code as it stands rather than new machinery: **(a) A window resize does not, by itself, require touching most widgets.** -`shader.wgsl:105-106` recomputes every primitive's pixel position from -`window.dim` and the primitive's stored `rel`/`abs` pair *every frame, -already, on the GPU*. A widget laid out purely in `rel`/`abs` terms (no -call to `px_size()`, `output_size()`, or anything else that reads a -concrete pixel count) is therefore already correct after a resize with zero -CPU work โ€” the shader did it. `UiRenderState::needs_redraw_all` -(`render_state.rs:229-231`) currently ignores this and redraws the entire -tree on every `resized`, which was the safe default while sizing and -drawing were two passes; it should be narrowed to only the widgets that -*do* read a concrete pixel value. Track this the same way `needs_redraw` -already tracks per-widget dirtiness (`Widgets::needs_redraw`, -`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself -pixel-dependent by calling through `Painter` methods that read -`output_size`/`px_size` (both already funnel through `Painter`, so the -marking is one line at each), and `resize()` (`render_state.rs:32-35`) -walks only that set instead of unconditionally setting `resized = true` -for a full `redraw_all`. This turns "every resize redraws everything" into -"every resize redraws what depends on pixels" โ€” a real behavior change -beyond what was asked, so verify it against the I0b `pre_present_notify` -resize regression (that fix depended on `redraw_all`'s completeness) -before narrowing this. +`shader.wgsl` recomputes every primitive's pixel position from `window.dim` +and the primitive's stored `rel`/`abs` pair every frame, already, on the GPU. +A widget laid out purely in `rel`/`abs` terms is therefore already correct +after a resize with zero CPU work. Calls to `Painter::px_size` and +`Painter::output_size` mark the active widget as reading concrete pixels; +only those widgets become dirty when the output actually changes. + +All pixel-dependent leaves are marked before layout begins, along with every +chain of parents that read their sizes. Resize then settles the shallowest +shared readers first, under the new output, so overlapping dependency paths +are drawn once. Ordinary content changes use the opposite order: deepest +dirty widgets first, with a changed returned size propagated one reader edge +at a time. Re-reporting the current output size is a no-op. **(b) A widget's `available` (its parent's offered region) can change without the widget's *content* changing โ€” this is what