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
|
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.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 |
|
|
||||||
|
|
||||||
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.95–5.24 ms
|
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
|
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
|
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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user