Document coalesced resize layout

This commit is contained in:
iris-ai committed 2026-09-14 17:06:41 -04:00
1 parent 986f98380d
commit 974d6c7a52
2 files changed
+59 -39

No files matched your search

+46 -18
View File
@@ -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
+13 -21
View File
@@ -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