From 384b6a11502d26453b3dcff728b3fa14d6375b9b Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Thu, 17 Sep 2026 18:02:02 -0400 Subject: [PATCH] Price the frame/extent experiment against #18's head, and name the placement pin Co-Authored-By: Claude Opus 5 --- docs/HANDOFF.md | 95 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 20ed6bc..8a77a14 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -21,6 +21,10 @@ redraw cascade through dependency tracking and invalidation, not glyph emission. Its current head is `0e107f0` in `/home/bob/repos/iris-layout-experiment`. See **Frame and extent: retained prototype** for the mechanism, measured costs, and the remaining cold/many-update work. The app's framework pin is unchanged. +**It is not ready to replace #18 yet**: measured against `e44dea3` it wins on +`size` and `scroll` and loses badly on `many` -- 6x on seed 13 at depth 8 -- +and the cause is one conservative dependency rather than the semantics. See +**Measured against #18's head, and the placement pin behind the gap**. **Widen one fuzzer axis at a time, and record which.** Seeds **1121** and **1839** at **depth 4** failed on `ea6dbae` and on every commit before it, @@ -638,6 +642,97 @@ instructions and no cycles; special-casing equal endpoint fractions in still cost memory per primitive. The prototype remains separate from PR #18; the `Inset`/`Outset` changes remain parked. +### Measured against #18's head, and the placement pin behind the gap + +Checked on 2026-09-17 in `/home/bob/repos/iris-layout-baseline` (`e44dea3`, +its own `target-own` because its `target` is a symlink into +`/home/bob/repos/iris-pr18`) against `/home/bob/repos/iris-layout-experiment` +(`0e107f0`). **The five-phase cycles table above compares `c44bd19` with +`0e107f0` -- two commits inside the experiment -- so it says what the last two +fixes bought, not what the experiment costs against the branch it would +replace.** Against `e44dea3` the picture is different, and it decides whether +this can replace #18. + +Nine alternating pairs under `perf stat`, seed 1 at depth 8, uninstrumented +release executables: + +| phase | cycles `e44dea3` -> `0e107f0` | instructions | +| --- | ---: | ---: | +| size (2,000) | 0.2618 -> 0.2026 B (-22.6%) | -21.3% | +| scroll (300,000) | 2.5169 -> 1.2796 B (-49.2%) | -49.5% | +| repaint (300,000) | 0.8546 -> 0.9533 B (+11.5%) | +15.0% | +| resize (2,000) | 0.2697 -> 0.4813 B (+78.5%) | +99.0% | +| many (2,000) | 2.3885 -> 4.9504 B (+107.3%) | +98.4% | + +Each phase's before and after cycle ranges are disjoint. The work counters agree with the times: `size` +falls from 16 widget draws to 3 and `scroll` from 2 to 1, while `many` rises +from 157 to 274 and `resize` from 13 to 27. `many` is also the phase that +costs anything at all -- median frames at seed 1, depth 8 are 1 us for +repaint and scroll, 9 us for size, 48 us for resize and 667 us for `many`, +so a percentage on `many` is worth two orders of magnitude more than the same +percentage on repaint. + +**The `many` regression varies enormously with the tree**, so one fixture +cannot settle it. Median frame, 200 frames, `IRIS_DIRTY` at its default: + +| fixture | `e44dea3` | `0e107f0` | draws before -> after | +| --- | ---: | ---: | --- | +| seed 1, depth 8 | 0.318 ms | 0.667 ms | 157 -> 274 | +| seed 2, depth 8 | 0.218 ms | 0.700 ms | 111 -> 349 | +| seed 5, depth 8 | 0.759 ms | 0.702 ms | 337 -> 230 | +| seed 13, depth 8 | 1.088 ms | 6.351 ms | 524 -> 2380 | +| seed 3, depth 6 | 0.173 ms | 0.156 ms | 83 -> 51 | +| seed 13, depth 6 | 0.583 ms | 0.570 ms | 325 -> 268 | + +**The cause is the raw placement read, and it is nearly all of it.** +`LayoutHolds::placement` is `Some(..)` for any widget that called +`Painter::placement`, and that pins the exact box the drawing sits in: the +widget is redrawn whenever it moves at all, however wide its frame and extent +ranges are. `Pad` and `Stack` both read it -- `self.padding.region_of( +painter.placement())` and `let placement = painter.placement()` -- so every +`Pad` and `Stack` in a row redraws its whole subtree when an earlier sibling +changes length. A counter for reuse failures where the frame and extent +ranges still hold and only the placement moved says it is 45 of the 109 +failures a `many` frame at seed 1 has, 14 of 26 on `resize`, and 73 of 136 +on `cold`. + +Bounded by deleting the `reads_placement = true` line -- unsound, since +nothing then redraws a widget whose placement really did move, but it prices +the dependency: + +| fixture, `many` | `e44dea3` | `0e107f0` | no placement pin | +| --- | ---: | ---: | ---: | +| seed 1, depth 8 | 0.318 ms / 157 draws | 0.667 / 274 | 0.127 / 78 | +| seed 2, depth 8 | 0.218 / 111 | 0.700 / 349 | 0.079 / 46 | +| seed 13, depth 8 | 1.088 / 524 | 6.351 / 2380 | 0.147 / 84 | + +`cold` at seed 1 falls from 484 draws to 280 and 12.5 ms to 11.1 ms with it +gone, and `resize` from 48 us to 12 us. So the pin is not a cost the frame and +extent semantics require: it is `Pad` and `Stack` having no way to say "inside +my extent" other than reading the raw box. + +**That is what `c44bd19` was, and the 1.8% that got it reverted was measured +before the dependency split.** Extending `widget_within` to accept +`DrawRegion::Extent` was priced against a head where one accumulated range +still forced the redraws, so the saving had nowhere to show. Re-price it +against `0e107f0` before the experiment replaces #18; the enlarged child +record it costs is 16 bytes against a phase that is presently 6x #18's on +seed 13. + +**A smaller one, unrelated and free.** `redraw_updates` picks the deepest +dirty widget by scanning the whole `needs_redraw` set and calling `depth` on +every member, once per pop, and `depth` is a hash lookup. Depth reads per +`many` frame at seed 1, depth 8: 131 at `IRIS_DIRTY=8`, 1,314 at 33, and +14,611 at 145, for 21, 62 and 182 pops. It is about 3% of a 33-dirty frame +(`depth` plus the hashbrown fold in `perf report`) and grows as the square of +the update set. Ordering the pops rather than re-scanning needs no retained +state. + +Verified green at `0e107f0` on 2026-09-17 before any of the above: +`cargo fmt --all --check`, `cargo clippy --workspace --all-targets -D +warnings`, and `cargo test --workspace` (106 suite, 20 core, 11 generated, +4 harness) all clean. + ## How layout is decided ### Fixed point