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.
|
in.
|
||||||
|
|
||||||
**#18 `split/18-position-chain`** is open and finished apart from one decision:
|
**#18 `split/18-position-chain`** is open and finished apart from one decision:
|
||||||
worktree `/home/bob/repos/iris-pr18`, head `b1b3eca`, eighteen commits,
|
worktree `/home/bob/repos/iris-pr18`, head `e5f8b6b`, twenty commits,
|
||||||
workspace tests passing, fmt and clippy clean. It is LAYOUT.md §2's position chain, generalised
|
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.
|
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
|
**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
|
"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.
|
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:
|
`tests/revision_cost.rs` is the load that does carry it: one hand-written tree
|
||||||
one hand-written tree -- 40 rows of `Dir::RIGHT` span holding a 40px rect and a
|
-- 40 rows of `Dir::RIGHT` span holding a 40px rect and a `Dir::DOWN` span of
|
||||||
`Dir::DOWN` span of one wrapping and one non-wrapping text -- in source that
|
one wrapping and one short non-wrapping text, each its own random words -- in
|
||||||
compiles unchanged on `43ce8c7` and on #18. 500 resizes alternating 900 and 892
|
source that compiles unchanged on `43ce8c7` and on #18. Drop it into an old
|
||||||
wide at 1200 tall, `perf stat -e instructions:u` on the test binary directly:
|
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
|
||||||
| revision | instructions/frame | median frame | the first paragraph's right edge |
|
the paragraphs, which is the other half of the comparison, and `text_memory`
|
||||||
| --- | --- | --- | --- |
|
in the same file reports what the tree holds.
|
||||||
| 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 |
|
|
||||||
|
|
||||||
**The old code is faster because it skips the question, not because it answers
|
**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:
|
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
|
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,
|
deliberate choice ("tempting to subtract the abs & rel from the ctx outer,
|
||||||
but that would create inconsistent sizing"). In the fixture above that shapes
|
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
|
a paragraph at 900 rather than the 860 left beside the rect, and it draws
|
||||||
14 px past the edge of the output.
|
29 px past the edge of the output.
|
||||||
|
|
||||||
So the amplification #18 pays is the cost of asking in the right box. That does
|
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.
|
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
|
entry and a trial width alternating with a final width evicts the answer that
|
||||||
is about to be asked for again.
|
is about to be asked for again.
|
||||||
|
|
||||||
Two capacity changes, prototyped in `/home/bob/repos/iris-perf2` and measured
|
`e5f8b6b` keeps more than one. A bounded store of shapings on `TextData`,
|
||||||
on the fair fixture above:
|
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 |
|
On `tests/revision_cost.rs`, 500 resize frames, every paragraph its own random
|
||||||
| --- | --- | --- |
|
words:
|
||||||
| #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 |
|
|
||||||
|
|
||||||
That is **3.3x fewer instructions than the pre-#16 code**, on the tree where the
|
| | instructions/frame | median | p99 | max | the paragraph's right edge |
|
||||||
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
|
| before #16 (`43ce8c7`) | 56.1M | 4.99 ms | 5.71 ms | 6.47 ms | **929 px, past the 900 px output** |
|
||||||
frame (5.28 to 1.80 ms) and a cold frame from 13.4 to 11.5 ms. Widget draws do
|
| #18 head (`b1b3eca`) | 124.2M | 11.76 ms | 13.78 ms | 14.56 ms | 877 px |
|
||||||
not move at all -- 449 either way -- so this removes work per draw and leaves
|
| `e5f8b6b` | 17.7M | 1.76 ms | 2.23 ms | 5.39 ms | 877 px |
|
||||||
every retained-layout rule alone.
|
|
||||||
|
|
||||||
Neither is the frame-local answer cache seed 98 rejected. That one let a parent
|
**Two widths in turn flatters anything that remembers an answer**, so
|
||||||
lay out from a size the child's one drawing might not realize; these hold a
|
`SWEEP=1` never repeats a width -- a drag rather than a toggle, and the case
|
||||||
*drawing* keyed on the width that produced it, and the widget still draws. The
|
nothing can hit across frames. There the same three are 56.2M, 123.6M and
|
||||||
existing single-entry caches already make exactly this assumption; only their
|
45.9M instructions per frame: still 2.7x better than #18's head, and a little
|
||||||
capacity changes. Workspace tests and the ignored 100-seed sweep pass with both
|
better than the old code rather than three times better. Both numbers are the
|
||||||
in. Still to do before either lands: headless reference renders, an eviction
|
honest ones, for different gestures.
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
- **Writing a glyph instance per glyph per draw.** With shaping and placement
|
||||||
memoized, `Painter::glyphs`, `Layers::write` and `InstanceList::push` are
|
kept, `Painter::glyphs`, `Layers::write` and `InstanceList::push` are most of
|
||||||
most of what is left. A cold frame writes 48,050 instances for a tree that
|
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
|
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
|
`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
|
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.**
|
- **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
|
The remaining-region trial removes many mismatches, but an unknown child's
|
||||||
measured length can still make its final box differ, and nested containers
|
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 --
|
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
|
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,
|
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
|
`DrawResult::size()` records a retained size dependency; drawing the child
|
||||||
without reading that result does not make the parent's size depend on it.
|
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
|
## Design
|
||||||
|
|
||||||
### UI ownership and frame access
|
### UI ownership and frame access
|
||||||
@@ -28,17 +33,15 @@ draw hooks so dispatch never has to scan every active widget.
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub trait Widget: Any {
|
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 size_hint(&self, axis: Axis) -> Option<Len> { None }
|
||||||
|
|
||||||
fn is_size_independent(&self) -> bool {
|
fn on_resize(&self, axis: Axis) -> OnResize { OnResize::Redraw }
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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()`
|
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
|
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
|
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
|
O(subtree) work this design removes. Chain depth is bounded in both Rust and
|
||||||
WGSL.
|
WGSL.
|
||||||
|
|
||||||
`Painter::set_child_offset` inserts a retained coordinate slot between a
|
`Painter::place` draws a child whose box its parent decides and may decide
|
||||||
container and its direct children. `LazySpan` uses one so visible row boxes
|
again, and gives that child a slot of its own; `widget` and `widget_within` do
|
||||||
remain stable while scrolling changes a single shared translation. Ordinary
|
not, and share the nearest ancestor's. A slot carries a whole **box**, not a
|
||||||
window-relative positions remain `rel + abs`; move slots carry translation
|
translation: a pixel-space scale and offset would scale a child that has to
|
||||||
only, not general remapping.
|
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
|
`UiRenderState::resolved_region` performs the same chain walk on the CPU for
|
||||||
hit-testing, accessibility, and public window-coordinate queries. Masks store
|
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.
|
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
|
**(b) A widget's `available` (its parent's offered region) can change
|
||||||
without the widget's *content* changing — this is what
|
without the widget's *content* changing — this is what `Widget::on_resize`
|
||||||
`is_size_independent` (§1) answers.** When a container's own layout shifts
|
answers, per axis.** When a container's own layout shifts (a sibling grew or
|
||||||
(a sibling grew or shrank, changing this widget's offered box), a widget
|
shrank, changing this widget's offered box), a widget that says `Scale` on the
|
||||||
that returns `true` from `is_size_independent` is not redrawn: its
|
axes that changed is not redrawn: everything it drew is a fraction of its own
|
||||||
primitives are unaffected by size, only by placement, so the parent
|
slot's box, so writing that one box moves and stretches all of it. `Span`,
|
||||||
either (i) issues a move (§2) if only position changed, or (ii) rewrites
|
`Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say `Scale`;
|
||||||
the primitive's `region` fields directly via `region_mut` if the box
|
`Scroll` and `MaxSize` read their box in pixels and cannot. `Redraw`, the
|
||||||
changed shape too (still O(primitives owned directly by this widget, not
|
default, is correct always and free never. `Translate` — an unchanged drawing
|
||||||
its subtree, since a size-independent widget by definition has no
|
placed somewhere else in a bigger box — is reserved: nothing reads it until a
|
||||||
size-dependent descendants worth distinguishing — in practice this is
|
widget can say where in that box its drawing belongs, which is the alignment
|
||||||
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
|
work.
|
||||||
`false` (the default) is redrawn in full whenever `available` changes,
|
|
||||||
which is correct always, just not free.
|
|
||||||
|
|
||||||
**Size invalidation travels upward before drawing; drawing itself travels only
|
**Size invalidation travels upward before drawing; drawing itself travels only
|
||||||
downward.** Every active widget retains the direct children whose size it read
|
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
|
each retained child into its final box. A child is redrawn only when that
|
||||||
box changes the size it was drawn for.
|
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
|
its declared axis without inspecting its child, which covers the important
|
||||||
`.height(rest())` case. A debug assertion compares every hint with the
|
`.height(rest())` case. A debug assertion compares every hint with the
|
||||||
eventual `draw` result. Widgets whose answer depends on shaping or on a
|
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,
|
and it already operates at (id, region) granularity, which subsumes "(id,
|
||||||
available size)" once size *is* what a region change means.
|
available size)" once size *is* what a region change means.
|
||||||
|
|
||||||
`ActiveData::size` stores the value the widget recorded with
|
`ActiveData::size` stores the value the widget's `draw` returned.
|
||||||
`Painter::set_size`. This is what a parent placing the widget for a second
|
This is what a parent placing the widget for a second
|
||||||
frame without redrawing it reads instead of recomputing — it replaces
|
frame without redrawing it reads instead of recomputing — it replaces
|
||||||
`Cache.size`'s role of "answer a size question without a full draw" with
|
`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
|
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
|
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
|
dependency disappears as soon as the widget stops reading it. Both fields
|
||||||
|
|||||||
Reference in new issue
Block a user