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:
1 parent
ecce4448f0
commit
2acfcf51aa
1 file changed
+96
-27
@@ -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
|
||||
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
|
||||
directly with the old draw count. Fresh release wall-time measurements put the
|
||||
complete CPU layout cost in perspective:
|
||||
directly with the old draw count.
|
||||
|
||||
| per frame | before #16 | `84f589e` |
|
||||
| --- | --- | --- |
|
||||
| cold layout | 18.9 ms | 20.5–22.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.3–13.4 ms median |
|
||||
### Against the old code, on a tree both revisions build
|
||||
|
||||
Wall time moves with CPU frequency, so retained benchmark counters should
|
||||
report CPU cycles or instructions as the deciding total, then separate widget
|
||||
draws, size queries/probes, text shapes and primitive writes to explain it. A
|
||||
whole-rig `perf stat` run, dominated by 100 resize frames, measured 13.76B
|
||||
instructions current versus 3.56B before #16 (3.87x), consistent with the
|
||||
resize wall-time gap. A follow-up rig should select one phase per invocation so
|
||||
the hardware counters are phase-specific rather than inferred from that total.
|
||||
**The random generator is not a fixed load, so it cannot carry an old-versus-new
|
||||
comparison.** Padding, scrolling and alignment grew into it during this work, so
|
||||
`seed 1, depth 7` is 144 widgets today and was the 1061-widget tree the rows
|
||||
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 |
|
||||
|
||||
**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
|
||||
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
|
||||
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%
|
||||
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
|
||||
is within one constraint traversal. Re-sending the existing output size also
|
||||
no longer starts a resize.
|
||||
fewer instructions than the roughly 138M/frame #18 path before coalescing.
|
||||
The remaining multiplier is within one constraint traversal. Re-sending the
|
||||
existing output size also no longer starts a resize.
|
||||
|
||||
`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
|
||||
@@ -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
|
||||
107 text shapes. Cached uninstrumented 1,000-frame runs measured 4.95–5.24 ms
|
||||
medians, 55.4B instructions total (55.4M/frame), and 21.5–21.6B cycles: about
|
||||
9% fewer instructions than `82fa6c1`, but still about 1.6x the old 3.14 ms
|
||||
median.
|
||||
9% fewer instructions than `82fa6c1`.
|
||||
|
||||
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
|
||||
@@ -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
|
||||
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.**
|
||||
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. This is now the largest CPU layout cost.
|
||||
- **Redundant draws rewrite primitives.** 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, so this is CPU cost only -- but a one-leaf
|
||||
repaint still uploads 5,863 instances where the pre-#16 code uploaded 106.
|
||||
compound those redraws. The general form of the text caches 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,
|
||||
is where it is worth doing first.
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
|
||||
Reference in new issue
Block a user