Record both halves of the text fix, and what dirtying many widgets shows

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-14 19:22:14 -04:00
1 parent 60bf2568f8
commit 592cc808ca
1 file changed
+58 -37
+58 -37
View File
@@ -9,10 +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 `e5f8b6b`, twenty commits, worktree `/home/bob/repos/iris-pr18`, head `77bb75e`, twenty-two commits,
workspace tests passing, fmt and clippy clean. The last two are the text workspace tests passing, fmt and clippy clean. The last four are the text
shaping store and three retained-layout details that claimed something they shaping work, three retained-layout details that claimed something they did
did not do. It is LAYOUT.md §2's position chain, generalised not do, and the many-widgets-dirty cases. 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
@@ -320,49 +320,70 @@ 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.
`e5f8b6b` keeps more than one. A bounded store of shapings on `TextData`, `e5f8b6b` and `2525637` are the two halves of the answer.
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.
On `tests/revision_cost.rs`, 500 resize frames, every paragraph its own random **A new width is a line break, not a shaping.** Only the breaking depends on
words: the width -- the font selection, the unicode analysis and harfrust under it are
a function of the text and the attrs, and parley re-breaks them in place, which
is what its own editor does on every resize. On the depth-8 tree that is 107
breaks at 0.119 ms where the shapings they replace were 4.0 ms. This is the
half that holds however far the width moves.
| | instructions/frame | median | p99 | max | the paragraph's right edge | **A bounded store on `TextData` holds what re-breaking still cannot avoid:**
| --- | --- | --- | --- | --- | --- | the glyphs, placed per width, keyed by the text, the attrs and the width, with
| before #16 (`43ce8c7`) | 56.1M | 4.99 ms | 5.71 ms | 6.47 ms | **929 px, past the 900 px output** | `TextBuffer` holding the pair it is drawn as -- which is where `TextView::tex`
| #18 head (`b1b3eca`) | 124.2M | 11.76 ms | 13.78 ms | 14.56 ms | 877 px | was, so there is one place to invalidate rather than two. Bounding the store
| `e5f8b6b` | 17.7M | 1.76 ms | 2.23 ms | 5.39 ms | 877 px | rather than each buffer is the 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.
**Two widths in turn flatters anything that remembers an answer**, so Instructions per frame over 500 resize frames of `tests/revision_cost.rs`, for
`SWEEP=1` never repeats a width -- a drag rather than a toggle, and the case widths that alternate -- the friendly case for anything that remembers an
nothing can hit across frames. There the same three are 56.2M, 123.6M and answer -- and widths that never repeat, which is a drag:
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.
Memory, same rig at 2,000 rows: 108.3 MB on #18's head, 112.7 MB with the | | alternating | never repeating |
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 | before #16 (`43ce8c7`) | 56.1M | 56.2M |
`TextData`; 32 is not enough under `SWEEP=1` and 64 is. | #18 head (`b1b3eca`) | 124.2M | 123.6M |
| the store alone | 17.7M | 45.9M |
| re-breaking alone | 32.9M | 32.8M |
| both | 20.6M | 24.2M |
Widget draws do not move at all -- 449 either way -- so this removes work per Neither alone is good at both, which is the reason for having both. The frame
draw and leaves every retained-layout rule alone. That matters for more than times that go with the last row are 1.88 ms median and 2.54 ms worst
purity: **text is the load that makes a redundant draw expensive, and so the alternating, 2.46 and 3.24 never repeating, against 4.99 and 6.47 before #16 --
thing that shows when the layout is drawing more than it needs to.** The so the worst frame more than halves, and the two gestures are within a
counters keep saying so -- a text render is counted per ask rather than per millisecond of each other rather than a factor of two apart.
shaping, and glyph placements are counted separately -- so the store cannot
hide the amplification it is paying for. Memory on the same rig at 2,000 rows: 108.6 MB on #18's head, 111.6 MB with
both, and **132.0 MB before #16**, which grows 23 MB over the resizes where
neither of the others grows at all.
Widget draws do not move at all -- 449 either way -- so none of this touches a
retained-layout rule. 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 breaks and glyph
placements are counted separately -- so neither half can hide the amplification
it is paying for.
This is not the frame-local answer cache seed 98 rejected. That one let a 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 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 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 five reference renders and the resize render are byte-identical, and the
100-seed sweep passes. sweep passes.
**A frame that dirties many widgets at once was not being checked, and it is
where the settle order shows.** `77bb75e` adds two generated cases -- every
declared size changing together, and a spread of widgets marked for redraw
together, where nothing changes and so no box may move -- and `IRIS_PHASE=many`
to the rig, with `IRIS_DIRTY` widgets marked per frame. At 130 of 260 widgets,
**choosing which dirty widget to settle next is 24.5% of the frame**: the dirty
set is scanned once per widget settled, and a `HashSet` is walked by capacity
rather than by length. Memoizing the depth walk within one scan does not pay
(it trades parent lookups for memo lookups, 4% more instructions); the fix is
to stop rescanning, which changes the order widgets settle in and is hers to
agree first.
The remaining costs after this are: The remaining costs after this are: