From 2acfcf51aac7ad93353168b549d77edf6d9e7d3c Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Mon, 14 Sep 2026 18:10:14 -0400 Subject: [PATCH] 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 --- docs/IRIS_EXTRACTION_HANDOFF.md | 123 +++++++++++++++++++++++++------- 1 file changed, 96 insertions(+), 27 deletions(-) diff --git a/docs/IRIS_EXTRACTION_HANDOFF.md b/docs/IRIS_EXTRACTION_HANDOFF.md index fbc185b..9b5faf1 100644 --- a/docs/IRIS_EXTRACTION_HANDOFF.md +++ b/docs/IRIS_EXTRACTION_HANDOFF.md @@ -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