Record what the shaping store cost and what it bought

Both gestures, since two widths in turn flatters a store and a drag does
not, and the memory beside the time: the question that decided the shape
of it was whether it is a fixed cost or one per text widget.

Bring LAYOUT.md's §1 and §2 to what shipped. A widget returns its size
rather than recording it, a slot carries a box rather than a
translation, and the per-axis answer is `OnResize` rather than
`is_size_independent` -- and the constraint the position chain replaced
was an agent's, which the text now says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-14 18:51:34 -04:00
1 parent 2acfcf51aa
commit d50e791130
2 files changed
+88 -68

No files matched your search

+58 -41
View File
@@ -9,8 +9,10 @@ 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 `b1b3eca`, eighteen commits,
workspace tests passing, fmt and clippy clean. It is LAYOUT.md §2's position chain, generalised
worktree `/home/bob/repos/iris-pr18`, head `e5f8b6b`, twenty commits,
workspace tests passing, fmt and clippy clean. The last two are the text
shaping store and three retained-layout details that claimed something they
did not do. It is LAYOUT.md §2's position chain, generalised
to boxes. `/home/bob/repos/ai-app-2` is on `rustify`, worktree clean.
**The one thing waiting on the owner.** `tabs` at 1920x1200 is no longer
@@ -159,16 +161,14 @@ above were measured on. Earlier statements here that the retained layout was
"about 1.6x the old 3.14 ms resize" compared a 260-widget depth-8 tree against
that 1061-widget depth-7 one, and are withdrawn.
`tests/fair.rs` in `/home/bob/repos/iris-perf2` is the load that does carry it:
one hand-written tree -- 40 rows of `Dir::RIGHT` span holding a 40px rect and a
`Dir::DOWN` span of one wrapping and one non-wrapping text -- in source that
compiles unchanged on `43ce8c7` and on #18. 500 resizes alternating 900 and 892
wide at 1200 tall, `perf stat -e instructions:u` on the test binary directly:
| revision | instructions/frame | median frame | the first paragraph's right edge |
| --- | --- | --- | --- |
| before #16 (`43ce8c7`) | 60.4M | 5.49 ms | **914.2 px, past the 900 px output** |
| #18 head (`b1b3eca`) | 133.4M | 11.8 ms | 881.4 px |
`tests/revision_cost.rs` is the load that does carry it: one hand-written tree
-- 40 rows of `Dir::RIGHT` span holding a 40px rect and a `Dir::DOWN` span of
one wrapping and one short non-wrapping text, each its own random words -- in
source that compiles unchanged on `43ce8c7` and on #18. Drop it into an old
worktree and run it there; take the number from `perf stat -e instructions:u`
on the test binary rather than the clock. It also prints where the layout put
the paragraphs, which is the other half of the comparison, and `text_memory`
in the same file reports what the tree holds.
**The old code is faster because it skips the question, not because it answers
it more cheaply.** Two shortcuts pay for the 2.2x, and both are wrong:
@@ -182,8 +182,8 @@ it more cheaply.** Two shortcuts pay for the 2.2x, and both are wrong:
rather than the part left for it, which its own comment records as a
deliberate choice ("tempting to subtract the abs & rel from the ctx outer,
but that would create inconsistent sizing"). In the fixture above that shapes
the paragraph at 900 rather than the 860 left beside the rect, and it draws
14 px past the edge of the output.
a paragraph at 900 rather than the 860 left beside the rect, and it draws
29 px past the edge of the output.
So the amplification #18 pays is the cost of asking in the right box. That does
not excuse the size of it, and most of it turns out not to be layout at all.
@@ -320,38 +320,55 @@ it (`TextView::width`/`tex` and `TextBuffer::layout_key`) each hold exactly one
entry and a trial width alternating with a final width evicts the answer that
is about to be asked for again.
Two capacity changes, prototyped in `/home/bob/repos/iris-perf2` and measured
on the fair fixture above:
`e5f8b6b` keeps more than one. A bounded store of shapings on `TextData`,
keyed by the text, the attrs and the width, holds the parley layout and the
glyphs placed from it; `TextBuffer` holds the one it is drawn as, which is
where `TextView::tex` was, so there is one place to invalidate rather than
two. Bounding the store rather than each buffer is the whole point: a
per-buffer cache of eight cost +79% on a tree of 4,000 texts, and lazy
eviction cannot fix that, because the texts holding the memory are exactly the
ones a retained layout has stopped drawing.
| | instructions/frame | median frame |
| --- | --- | --- |
| #18 head | 133.4M | 11.8 ms |
| + `TextBuffer` keeps 8 shaped layouts | 30.8M | 3.09 ms |
| + `TextView` keeps 8 placed results | 18.2M | 1.85 ms |
On `tests/revision_cost.rs`, 500 resize frames, every paragraph its own random
words:
That is **3.3x fewer instructions than the pre-#16 code**, on the tree where the
pre-#16 code puts the paragraph 14 px outside the output. On the random tree at
depth 8 the same two changes take a resize from 55.1M to 18.7M instructions per
frame (5.28 to 1.80 ms) and a cold frame from 13.4 to 11.5 ms. Widget draws do
not move at all -- 449 either way -- so this removes work per draw and leaves
every retained-layout rule alone.
| | instructions/frame | median | p99 | max | the paragraph's right edge |
| --- | --- | --- | --- | --- | --- |
| before #16 (`43ce8c7`) | 56.1M | 4.99 ms | 5.71 ms | 6.47 ms | **929 px, past the 900 px output** |
| #18 head (`b1b3eca`) | 124.2M | 11.76 ms | 13.78 ms | 14.56 ms | 877 px |
| `e5f8b6b` | 17.7M | 1.76 ms | 2.23 ms | 5.39 ms | 877 px |
Neither is the frame-local answer cache seed 98 rejected. That one let a parent
lay out from a size the child's one drawing might not realize; these hold a
*drawing* keyed on the width that produced it, and the widget still draws. The
existing single-entry caches already make exactly this assumption; only their
capacity changes. Workspace tests and the ignored 100-seed sweep pass with both
in. Still to do before either lands: headless reference renders, an eviction
rule better than a FIFO cap (a transcript holds thousands of texts, and eight
layouts each is real memory -- entries older than the current frame are what
wants dropping), and a decision on whether this belongs in #18 or a slice of
its own.
**Two widths in turn flatters anything that remembers an answer**, so
`SWEEP=1` never repeats a width -- a drag rather than a toggle, and the case
nothing can hit across frames. There the same three are 56.2M, 123.6M and
45.9M instructions per frame: still 2.7x better than #18's head, and a little
better than the old code rather than three times better. Both numbers are the
honest ones, for different gestures.
The remaining costs after those are:
Memory, same rig at 2,000 rows: 108.3 MB on #18's head, 112.7 MB with the
store, and **132.0 MB before #16**, which grows 23 MB over the resizes where
neither of the others grows at all. The store is 128 entries for the whole
`TextData`; 32 is not enough under `SWEEP=1` and 64 is.
Widget draws do not move at all -- 449 either way -- so this removes work per
draw and leaves every retained-layout rule alone. That matters for more than
purity: **text is the load that makes a redundant draw expensive, and so the
thing that shows when the layout is drawing more than it needs to.** The
counters keep saying so -- a text render is counted per ask rather than per
shaping, and glyph placements are counted separately -- so the store cannot
hide the amplification it is paying for.
This is not the frame-local answer cache seed 98 rejected. That one let a
parent lay out from a size the child's one drawing might not realize; this
holds a *drawing* keyed on what produced it, and the widget still draws. The
five reference renders and the resize render are byte-identical, and the
100-seed sweep passes.
The remaining costs after this are:
- **Writing a glyph instance per glyph per draw.** With shaping and placement
memoized, `Painter::glyphs`, `Layers::write` and `InstanceList::push` are
most of what is left. A cold frame writes 48,050 instances for a tree that
kept, `Painter::glyphs`, `Layers::write` and `InstanceList::push` are most of
what is left. A cold frame writes 48,050 instances for a tree that
holds 10,872. What reaches the GPU is 10,872, since a layer uploads whole and
`set_instance` cancels a dirty mark when the bytes are unchanged -- so this is
CPU cost only, but a one-leaf repaint still uploads 5,863 instances where the
@@ -359,7 +376,7 @@ The remaining costs after those are:
- **A container measures a child by drawing it in a box it will not keep.**
The remaining-region trial removes many mismatches, but an unknown child's
measured length can still make its final box differ, and nested containers
compound those redraws. The general form of the text caches is a widget
compound those redraws. The general form of the shaping store is a widget
answering from a box it has already drawn in, which -- unlike a size answer --
is realized by definition; the cost is retaining more than one drawing's
primitives, which is why text, where only the shaped layout has to be kept,
+30 -27
View File
@@ -4,6 +4,11 @@ A widget draws once and records its size on the `Painter`. Reading a child
`DrawResult::size()` records a retained size dependency; drawing the child
without reading that result does not make the parent's size depend on it.
§1 and §2 have landed in Iris (#16 and #18) and the notes below have been
brought to what shipped rather than what was proposed; §3 to §6 describe the
same design as it stands. `docs/IRIS_EXTRACTION_HANDOFF.md` has the invariants
the code now rests on and what is still to do.
## Design
### UI ownership and frame access
@@ -28,17 +33,15 @@ draw hooks so dispatch never has to scan every active widget.
```rust
pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter);
fn draw(&mut self, painter: &mut Painter) -> Size;
fn size_hint(&self, axis: Axis) -> Option<Len> { None }
fn is_size_independent(&self) -> bool {
false
}
fn on_resize(&self, axis: Axis) -> OnResize { OnResize::Redraw }
}
```
Every implementation calls `painter.set_size(size)` exactly once. A child
A widget returns what it used of the box it was given. A child
draw returns a `DrawResult` that keeps the painter borrowed; calling `.size()`
on that result reads the child's retained size and records that the current
widget depends on it. Dropping the result without reading it draws the child
@@ -76,11 +79,13 @@ rewriting the row whenever an ancestor moved and would restore the very
O(subtree) work this design removes. Chain depth is bounded in both Rust and
WGSL.
`Painter::set_child_offset` inserts a retained coordinate slot between a
container and its direct children. `LazySpan` uses one so visible row boxes
remain stable while scrolling changes a single shared translation. Ordinary
window-relative positions remain `rel + abs`; move slots carry translation
only, not general remapping.
`Painter::place` draws a child whose box its parent decides and may decide
again, and gives that child a slot of its own; `widget` and `widget_within` do
not, and share the nearest ancestor's. A slot carries a whole **box**, not a
translation: a pixel-space scale and offset would scale a child that has to
keep its pixel length, and the `rel`/`abs` pair is what distinguishes the two.
(That slots carry translation only was an agent's choice on 2026-09-04, never
asked for, and #18 replaced it.)
`UiRenderState::resolved_region` performs the same chain walk on the CPU for
hit-testing, accessibility, and public window-coordinate queries. Masks store
@@ -120,19 +125,17 @@ 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
`is_size_independent` (§1) answers.** When a container's own layout shifts
(a sibling grew or shrank, changing this widget's offered box), a widget
that returns `true` from `is_size_independent` is not redrawn: its
primitives are unaffected by size, only by placement, so the parent
either (i) issues a move (§2) if only position changed, or (ii) rewrites
the primitive's `region` fields directly via `region_mut` if the box
changed shape too (still O(primitives owned directly by this widget, not
its subtree, since a size-independent widget by definition has no
size-dependent descendants worth distinguishing — in practice this is
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
`false` (the default) is redrawn in full whenever `available` changes,
which is correct always, just not free.
without the widget's *content* changing — this is what `Widget::on_resize`
answers, per axis.** When a container's own layout shifts (a sibling grew or
shrank, changing this widget's offered box), a widget that says `Scale` on the
axes that changed is not redrawn: everything it drew is a fraction of its own
slot's box, so writing that one box moves and stretches all of it. `Span`,
`Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say `Scale`;
`Scroll` and `MaxSize` read their box in pixels and cannot. `Redraw`, the
default, is correct always and free never. `Translate` — an unchanged drawing
placed somewhere else in a bigger box — is reserved: nothing reads it until a
widget can say where in that box its drawing belongs, which is the alignment
work.
**Size invalidation travels upward before drawing; drawing itself travels only
downward.** Every active widget retains the direct children whose size it read
@@ -180,7 +183,7 @@ length is known, flexible space is allocated and `Painter::place` moves
each retained child into its final box. A child is redrawn only when that
box changes the size it was drawn for.
Hints are optional and affect cost, never correctness. `Sized` can report
Hints are optional and affect cost, never correctness. `SetSize` can report
its declared axis without inspecting its child, which covers the important
`.height(rest())` case. A debug assertion compares every hint with the
eventual `draw` result. Widgets whose answer depends on shaping or on a
@@ -201,11 +204,11 @@ buffer. That check is kept exactly as it is; it is the caching mechanism,
and it already operates at (id, region) granularity, which subsumes "(id,
available size)" once size *is* what a region change means.
`ActiveData::size` stores the value the widget recorded with
`Painter::set_size`. This is what a parent placing the widget for a second
`ActiveData::size` stores the value the widget's `draw` returned.
This is what a parent placing the widget for a second
frame without redrawing it reads instead of recomputing — it replaces
`Cache.size`'s role of "answer a size question without a full draw" with
"read the size of the last actual draw." `ActiveData::size_dependencies`
"read the size of the last actual draw." `ActiveData::size_deps`
stores the direct children whose `DrawResult::size()` or known length the
widget observed during that same draw; the next draw replaces the list, so a
dependency disappears as soon as the widget stops reading it. Both fields