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:
1 parent
2acfcf51aa
commit
d50e791130
2 files changed
+88
-68
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user