Compare the retained layout with the old code on one tree

The random generator grew during the work, so the seed that names a
1061-widget tree in the pre-#16 rows names a 144-widget one now. Every
"still N times the old resize" here was measured across that change and
says nothing; a hand-written fixture that compiles on both revisions
says the old code is 2.2x cheaper and puts a paragraph outside the
output, for two reasons it records as deliberate.

Record what a resize frame is actually spent on -- 63% text, 0.9%
draw_inner -- and what removing the single-entry shaping and placement
caches measures at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-14 18:10:14 -04:00
1 parent ecce4448f0
commit 2acfcf51aa
1 file changed
+96 -27
+96 -27
View File
@@ -148,23 +148,45 @@ Those draw counts explain mechanism, not total layout cost. Before #16,
measurement was a separate operation: the cold and resize rows each made 839 measurement was a separate operation: the cold and resize rows each made 839
draws plus 489 size queries, 314 of which hit the size cache. Current sizing is draws plus 489 size queries, 314 of which hit the size cache. Current sizing is
a draw, so its draw count includes the provisional work and cannot be compared a draw, so its draw count includes the provisional work and cannot be compared
directly with the old draw count. Fresh release wall-time measurements put the directly with the old draw count.
complete CPU layout cost in perspective:
| per frame | before #16 | `84f589e` | ### Against the old code, on a tree both revisions build
| --- | --- | --- |
| cold layout | 18.9 ms | 20.522.0 ms |
| repaint one leaf | below timer resolution | below timer resolution |
| scroll one scroller | below timer resolution | 0.002 ms |
| resize the output | 3.14 ms median | 12.313.4 ms median |
Wall time moves with CPU frequency, so retained benchmark counters should **The random generator is not a fixed load, so it cannot carry an old-versus-new
report CPU cycles or instructions as the deciding total, then separate widget comparison.** Padding, scrolling and alignment grew into it during this work, so
draws, size queries/probes, text shapes and primitive writes to explain it. A `seed 1, depth 7` is 144 widgets today and was the 1061-widget tree the rows
whole-rig `perf stat` run, dominated by 100 resize frames, measured 13.76B above were measured on. Earlier statements here that the retained layout was
instructions current versus 3.56B before #16 (3.87x), consistent with the "about 1.6x the old 3.14 ms resize" compared a 260-widget depth-8 tree against
resize wall-time gap. A follow-up rig should select one phase per invocation so that 1061-widget depth-7 one, and are withdrawn.
the hardware counters are phase-specific rather than inferred from that total.
`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 |
**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:
- `SizeCtx::len_inner` keys its memo on the widget id alone. It stores the
constraint box the answer was computed under and never compares it, so the
first asker's box decides the answer for every later one -- 314 of those 489
hits per frame. The frame-local answer cache rejected on seed 98 at least
compared the box; this does not.
- `Span::len_sum` measures every child against the container's whole axis
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.
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.
`480f0bc` retains that instrumentation behind the `layout-diagnostics` Cargo `480f0bc` retains that instrumentation behind the `layout-diagnostics` Cargo
feature; none of it is compiled into a normal Iris build. The ignored feature; none of it is compiled into a normal Iris build. The ignored
@@ -215,10 +237,9 @@ 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 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, 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% 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, 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 The remaining multiplier is within one constraint traversal. Re-sending the
is within one constraint traversal. Re-sending the existing output size also existing output size also no longer starts a resize.
no longer starts a resize.
`b1b3eca` records pixel reads and retained size inputs per axis. `Text` says it `b1b3eca` records pixel reads and retained size inputs per axis. `Text` says it
reads only its offered width, while `Scroll` says it reads only its scrolling reads only its offered width, while `Scroll` says it reads only its scrolling
@@ -228,8 +249,7 @@ when neither exists. On the same depth-8 load, resize now averages 449 widget
draws, 296 placement calls, 485 drawn-size reads, 12,164 primitive writes, and draws, 296 placement calls, 485 drawn-size reads, 12,164 primitive writes, and
107 text shapes. Cached uninstrumented 1,000-frame runs measured 4.955.24 ms 107 text shapes. Cached uninstrumented 1,000-frame runs measured 4.955.24 ms
medians, 55.4B instructions total (55.4M/frame), and 21.521.6B cycles: about medians, 55.4B instructions total (55.4M/frame), and 21.521.6B cycles: about
9% fewer instructions than `82fa6c1`, but still about 1.6x the old 3.14 ms 9% fewer instructions than `82fa6c1`.
median.
Pixel comparisons now use a 0.05 physical-pixel tolerance. The comparison is Pixel comparisons now use a 0.05 physical-pixel tolerance. The comparison is
against the last actual layout, not the preceding resize event or retained against the last actual layout, not the preceding resize event or retained
@@ -288,16 +308,62 @@ usable in its final box; measure-only mode would discard that useful output
and force another real traversal. The owner confirmed on 2026-09-14 that and force another real traversal. The owner confirmed on 2026-09-14 that
direct placement is the intended path. direct placement is the intended path.
The remaining costs are: ### What a resize frame is actually spent on
`perf record` on #18's head, resize phase, seed 1 depth 8: about 63% of the
frame is text -- shaping in parley, harfrust and icu\_segmenter, then
`TextData::place` at 10.8% on its own -- and `draw_inner` is 0.9%. **The
retained-layout machinery is not what the frame costs.** A container that
measures by drawing makes a text draw 32 times at about a dozen distinct
widths; every one of those re-runs the shaper, because the caches in front of
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:
| | 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 |
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.
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.
The remaining costs after those 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
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
pre-#16 code uploaded 106.
- **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. This is now the largest CPU layout cost. compound those redraws. The general form of the text caches is a widget
- **Redundant draws rewrite primitives.** A cold frame writes 48,050 answering from a box it has already drawn in, which -- unlike a size answer --
instances for a tree that holds 10,872. What reaches the GPU is 10,872, is realized by definition; the cost is retaining more than one drawing's
since a layer uploads whole, so this is CPU cost only -- but a one-leaf primitives, which is why text, where only the shaped layout has to be kept,
repaint still uploads 5,863 instances where the pre-#16 code uploaded 106. is where it is worth doing first.
## The random trees ## The random trees
@@ -574,6 +640,9 @@ git worktree add -b split/19-name /home/bob/repos/iris-pr19 upstream/main
`/home/bob/repos/iris-pr18` is the live one. Every other `iris-pr*` worktree `/home/bob/repos/iris-pr18` is the live one. Every other `iris-pr*` worktree
holds a merged branch; they are readable references, not places to build. holds a merged branch; they are readable references, not places to build.
`/home/bob/repos/iris-perf2` is detached at `b1b3eca` and holds the text-cache
prototype and `tests/fair.rs`; `iris-size-old` is `43ce8c7` with the old
patched counters, and is how the pre-#16 column above was measured.
## Cautions ## Cautions