Document the causes and correction of layout invalidation cascades

This commit is contained in:
iris-ai committed 2026-09-17 17:27:27 -04:00
1 parent 32151f7f9f
commit ccc42f34f8
2 files changed
+148 -192

No files matched your search

+135 -184
View File
@@ -16,6 +16,12 @@ size rules, fixed-point layout, a box in pixels threaded down the draw, and
a report read as a fraction of the containing widget. No PR review was
present when checked on 2026-09-15.
The separate frame/extent experiment now addresses the dominant size/resize
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.
**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,
and nothing in the routine verification reached them: the fast oracle takes
@@ -423,7 +429,7 @@ The original proposal was implemented by Claude as `5fcace1` on
`wip/region-and-placement` in `/home/bob/repos/iris-pr18`. It kept the fraction
reference stable but failed five layout cases and three draw-count cases.
The first correction is `efb416b`, exact recomposition is `2ed5503`, and the
current performance continuation is **`c44bd19`**, pushed to origin's
current performance continuation is **`0e107f0`**, pushed to origin's
`wip/region-and-placement`, in the isolated checkout
`/home/bob/repos/iris-layout-experiment`; **it is not on PR #18**. The original
checkout is unchanged. Keep the experiment's build directory separate: Cargo
@@ -506,179 +512,131 @@ red slot. Red is centered in that slot, 5 px right of the visible black gap's
center. Do not describe those two centers as the same thing or compensate with
an arbitrary offset. The parked padding API change is still separate.
Ordinary verification passes: formatting, workspace clippy with
`layout-diagnostics`, workspace tests (101 suite tests and 21 core tests), and
the fast generated cases. The corrected `tabs` resize from 1920x1200 to
900x1200 matches a cold image with zero differing pixels. The release oracle
passes 2000 seeds at depth 4 and 1000 at depth 6; the shrinker passes 400 at
depth 5, and the debug oracle passes 120 at depth 4 with assertions enabled.
Every scan runs all fifteen scenarios with exact equality. The measurement
regression fails with four draws instead of three when the optimization is
disabled.
**The major update gap was over-invalidation, not a required correctness cost.**
Two different dependency graphs had been accumulated into one range. A child's
size answer constrains its parent only when the parent reads that answer;
the child's drawing constrains the parent's retained drawing whether its size
was read or not. A stack sized by one child must not remeasure because an
unmeasured overlay wrapped at another width during provisional layout.
**Still not ready to replace PR #18.** The prototype does substantially more
nested layout work than `e44dea3`, even after retaining extent-relative child
frames (seed 1, depth 8):
`Painter` now collects these contracts separately. `DrawResult::size()` and
`measure_len` contribute answer dependencies; every child draw contributes
drawing dependencies. Direct numeric and raw-placement reads conservatively
constrain both. The original offer retains the answer contract, and final
placement retains the drawing contract. Previous attempts separated storage
without separating which child dependencies entered each contract, so they
continued to invalidate measurements for unrelated drawing work.
| phase | widget draws at e44dea3 | current | primitive writes at e44dea3 | current |
**A valid answer does not certify a valid drawing.** Root resize checks both.
Local updates propagate an invalidated drawing contract as well as an invalidated
answer; otherwise an ancestor can retain a broad resize range after a descendant
begins reading its pixel width. Focused regressions cover both paths and fail when
those checks are removed. No geometry check or equality requirement was relaxed.
**A wider validity range does not invalidate an existing guarantee.** When a
local redraw returns the same size under a contract covering the old one, the
old answer contract remains sufficient. Keep it, and likewise keep a still-valid
old drawing contract. Adopting the wider range without telling the parent would
lose which guarantee the parent relies on; keeping it prevents both widening
and narrowing back from causing layout. Scroll alternates between an exact
width and a wider interval as it enters/leaves end anchoring. Comparing whole
contracts by equality caused four draws and doubled scroll cycles after the
initial dependency split (`f860f71`). Retaining the valid guarantee reduces
scroll to one draw, below the previous baseline's two. It introduces no retained
state or separate propagation subsystem. A focused test widens/narrows several
times without redrawing the parent, then resizes and requires the leaf to redraw.
**Declared-size changes use ordinary bottom-up propagation.** The changed child
and its direct parent remain marked until the parent resolves the new rule.
Further propagation depends on the parent's new answer and drawing contract.
The blanket ancestor walk, `answer_invalid` set and frame-global
`replace_answers` flag are gone. They predated strict bottom-up settlement and
original-offer replay; a parent's independent answer no longer invalidates the
whole tree merely because a descendant declared a new width. A regression
checks the changed geometry and that propagation stops at an independent parent.
**The `c44bd19` extent-child extension was removed.** It changed
`widget_within` to accept `DrawRegion::Extent` and enlarged retained child records,
but saved only 1.8% resize cycles and no primitive writes. The dependency fixes
address the major cost with the original `widget_within(UiRegion)` API and
widget-id-only inherited-child list. Pad and Stack again read raw placement;
those conservative reads are not the cause of the pathological update counts.
Instrumented work, seed 1/depth 8, 50 frames per phase (cold once):
| phase | draws at c44bd19 | current draws | primitive writes at c44bd19 | current writes |
| --- | ---: | ---: | ---: | ---: |
| cold | 369 | 463 | 9635 | 9179 |
| cold | 463 | 484 | 9179 | 9179 |
| repaint | 1 | 1 | 1 | 1 |
| many | 157 | 263.1 | 4721 | 5873 |
| size | 16 | 240 | 106 | 5200 |
| scroll | 2 | 2 | 0 | 0 |
| resize | 13 | 278 | 0 | 5730 |
| many | 263.1 | 274 | 5873 | 5873 |
| size | 240 | 3 | 5200 | 105 |
| scroll | 2 | 1 | 0 | 0 |
| resize | 278 | 27 | 5730 | 0 |
These are instrumented work counts, not speedups (50 frames per phase except
cold, which is one; 578 generated widgets, 191 finally active). The
measurement-only change at `7601aa2` removed 7 widget draws and 525
primitive writes per size frame, and 8 draws and 525 writes per resize frame,
relative to `2ed5503`. The extent-relative continuation below skips additional
container evaluations but does not reduce primitive writes.
Resize performs no text renders. Size updates render one text. Removing the
extent-child extension increases cold/many container evaluations again. Do not
hide those costs behind the large size/resize gains. The original `e44dea3`
measured 16 draws/106 writes for size and 13/0 for resize, but does not implement
the corrected frame/extent semantics, so it is context rather than a correctness
baseline.
**Measure cycles as well as instructions** (Bryan, 2026-09-17). The following
are whole-process medians from seven alternating before/after runs of direct,
uninstrumented release test executables under `perf stat -e
cycles:u,instructions:u`. The baseline is `39f7b08`: `2ed5503` with the same
centering fix, so both sides draw the corrected layout. CPU-heavy scans were stopped
during measurement. Counts include fixture setup; size, resize and many run
5000 frames, repaint and scroll run 1,000,000. These are VM CPU measurements,
not phone frame times.
Nine alternating pairs of direct, uninstrumented release executables compare
`c44bd19` with `0e107f0` under `perf stat -e cycles:u,instructions:u`. All samples
were retained, with no scans or builds running during measurement. Counts include
fixture setup; these are VM CPU measurements, not phone frame times.
| phase | cycles before → after (billions) | instructions before → after (billions) |
| phase (frames) | median cycles before → after (billions) | median instructions before → after (billions) |
| --- | ---: | ---: |
| size | 12.8008 → 11.0344 (-13.8%) | 34.3660 → 29.9450 (-12.9%) |
| resize | 14.9368 → 13.0376 (-12.7%) | 39.8487 → 35.0032 (-12.2%) |
| many | 14.8875 → 12.9579 (-13.0%) | 39.6021 → 34.7499 (-12.3%) |
| repaint | 2.7416 → 2.6626 (-2.9%) | 6.0266 → 6.0197 (-0.1%) |
| scroll | 9.7848 → 8.4829 (-13.3%) | 21.7203 → 20.9527 (-3.5%) |
| size (2,000) | 4.0774 → 0.2021 (-95.0%) | 10.4017 → 0.4993 (-95.2%) |
| resize (2,000) | 4.7070 → 0.4930 (-89.5%) | 11.9383 → 1.3009 (-89.1%) |
| many (2,000) | 4.7718 → 5.0276 (+5.4%) | 12.0567 → 12.4463 (+3.2%) |
| repaint (1,000,000) | 2.6803 → 2.9086 (+8.5%) | 6.0224 → 6.4382 (+6.9%) |
| scroll (300,000) | 2.6252 → 1.3443 (-48.8%) | 6.5802 → 3.0649 (-53.4%) |
The size/resize/many cycle samples have nonoverlapping before/after ranges,
with each range less than 1.3% of its median. Additional depth-6 fixtures,
10,000 frames and seven alternating pairs each: seed 3 improves cycles by
7.0% for size and 8.4% for resize; seed 13 improves resize by 9.2%, while its
size samples overlap and establish no cycle improvement.
All five before/after cycle ranges are disjoint. The main gains coexist with
5.4% more cycles for `many` and 8.5% more for repaint. No claim is made that the
prototype is now uniformly faster.
**Glyph emission now composes its origin once per text draw.** Glyph offsets
and bitmap sizes are pixel lengths, so composition adds them unchanged. The
fresh draw resolves the shared frame/extent origin once, then adds each glyph's
offset and size. Retained local geometry is unchanged; a regression compares
fresh drawing with retained recomposition for fractional origins, negative
pixel offsets, changing frames and extents, and region nodes. This adds no
retained state and changes no layout decisions.
Additional depth-6 fixtures, nine alternating pairs and 4000 frames: seed 3
size updates use 20.2% fewer cycles and 23.0% fewer instructions, while its resize
case costs 5.4% more cycles and 3.5% more instructions. Seed 13 resize uses 6.6%
fewer cycles and 7.7% fewer instructions. Those cycle ranges are disjoint too.
Seed 13's size case performs no widget draws and its cycle ranges overlap.
Against `7601aa2`, nine alternating pairs of direct, uninstrumented release
executables under `perf stat -e cycles:u,instructions:u`, seed 1, depth 8,
2000 frames per process, including setup:
Verification: workspace formatting, clippy with `layout-diagnostics`, and tests
(106 suite tests, 21 core tests, fast generated cases) pass. Exact release scans
pass 2000 seeds/depth 4 and 1000/depth 6; the shrinker passes 400 trees/depth 5,
and the debug scan passes 120 seeds/depth 4 with assertions enabled. Every scan
runs all fifteen scenarios without a position tolerance. Each of the five new
regressions fails when its corresponding behavior is deliberately broken.
All five reference renders (`view`, `minimal`, `random`, `tabs`, `text`) match
`a7307d9` pixel for pixel. Resizing `tabs` from 1920x1200 to 900x1200 matches a
cold render there, also with zero differing pixels. The GPU probe reports Venus
on the RX 7900 XT.
| phase | cycles before → after (billions) | instructions before → after (billions) |
| --- | ---: | ---: |
| size | 4.5164 → 4.0909 (-9.4%) | 12.1709 → 10.3993 (-14.6%) |
| resize | 5.3025 → 4.8044 (-9.4%) | 14.2150 → 12.1999 (-14.2%) |
| many | 5.2743 → 4.8071 (-8.9%) | 14.0989 → 12.0868 (-14.3%) |
**Remaining work is narrower, but not proved unavoidable.** Cold layout and
widespread content updates still run descendants while evaluating provisional
sizes, including children whose answers will never be read. A disposable trial
made child requests lazy during measurement and skipped primitive emission, but
reused the same active records for measurement and painting. It failed nine
oracle scenarios and made dirty updates much worse. Measurement cannot erase
or replace the final drawing's dependency/lifetime state. The trial was removed;
there is no second widget layout body or measurement phase in this change.
All nine samples per side were retained; before/after cycle ranges do not
overlap. No scans or builds ran during measurement. All instrumented work
counters match `7601aa2` on all six phases (50 frames, cold once), including
the primitive counts in the table above: this reduces the cost of
emitting the repeated glyphs, not the repeated layout evaluations themselves.
The hot wrapping text really does return different heights at 68 and 89.53613 px,
so some width-dependent evaluation is necessary. That does not justify repeating
it after an unchanged size answer. The resize trace and reduced stack/overlay
case distinguished those two situations and led to this fix. Future work on
cold/many should preserve that distinction rather than weakening raw-placement
checks or memoizing arbitrary width results without a validity model.
Additional depth-6 fixtures, nine alternating pairs and 4000 frames each:
seed 3's size updates improve cycles by 5.0% and instructions by 8.0%; seed
13's resizes improve cycles by 8.5% and instructions by 13.6%, both with
nonoverlapping cycle ranges. Seed 3's resize and seed 13's size ranges overlap.
On seed 1/depth 8, cold (one frame/process) and scroll (300,000 frames) also
have overlapping cycle ranges. Repaint (1,000,000 frames) measures 1.1% more
cycles, with nonoverlapping ranges and just 0.02% more instructions: this slice
has a small measured repaint cost alongside the text-heavy gains. All nine
samples per side were retained in these checks too.
The verification runs above pass again at this head. All five reference
renders (`view`, `minimal`,
`random`, `tabs`, `text`) match `7601aa2` pixel for pixel at 1920x1200; `tabs`
resized to 900x1200 matches a cold render there. The GPU probe reported Venus
on the RX 7900 XT in this session.
Two trials were removed: combining frame recomposition with extent movement
saved under 0.8% of instructions and no cycles; special-casing equal endpoint
fractions in `UiSpan::within` cost 11% cycles and 7% instructions despite still
inlining. Keep that hot general composition small.
**Child frames can now follow the extent without evaluating their container.**
`widget_within` accepts the same `DrawRegion::{Frame, Extent}` reference as
primitives and masks. `Pad` retains its pixel inset in extent coordinates;
`Stack` inherits placement for its sizing child and retains extent-relative
frames for its other children. Moving the extent replays those requests through
`ask_box`, preserving child alignment and region nodes. This extends the existing
inherited-child walk rather than adding a separate layout body or a second cache.
It stores a region-bearing relation per extent-following child, so retained
child records are larger than the old widget-id-only list.
Two dependencies stay conservative. Fractional endpoints compose before pixel
evaluation, so their bounds cannot in general be pulled back through the extent
length alone; those frames still depend on raw placement. Reading a child's
fractional report also depends on placement because composing that report into
the parent's frame can change its value without changing the child's answer.
Pixel-inset frame bounds use the existing exact `Holds::through`.
**Keep the measured slot when repositioning.** A first trial recomputed it from
`active.size`, which is the final draw's report. Text measured narrow and placed
wider may report a different height at the latter width; the slot is still the
one chosen from measurement. Seed 20/depth 4 caught this. The retained slot is
now preserved on axes not explicitly assigned by the parent. A small synthetic
width-dependent widget pins the same failure without fonts.
Against `a7307d9`, seed 1/depth 8 skips 21 draws cold, 10.9 per `many` frame,
7 per size frame and 14 per resize frame. Repaint, scroll, and all primitive-write
counts are unchanged. Nine alternating pairs of direct uninstrumented release
executables under `perf stat -e cycles:u,instructions:u`, including setup:
| phase | median cycles before → after (billions) | median instructions before → after (billions) |
| --- | ---: | ---: |
| size (2000 frames) | 4.0819 → 4.0714 | 10.4107 → 10.4017 |
| resize (2000 frames) | 4.8108 → 4.7237 | 12.1854 → 11.9252 |
| many (2000 frames) | 4.7918 → 4.7787 | 12.0925 → 12.0567 |
| repaint (1,000,000 frames) | 2.6701 → 2.6851 | 6.0175 → 6.0195 |
| scroll (300,000 frames) | 2.6296 → 2.6273 | 6.5025 → 6.5817 |
Only resize establishes a cycle improvement: **1.8%**, with nonoverlapping sample
ranges, and 2.1% fewer instructions. Every other phase's cycle ranges overlap;
scroll uses 1.2% more instructions. All samples were retained and no scans or
builds ran during measurement. This is a small reduction in work, not closure of
the main gap.
Verification: workspace formatting, clippy with `layout-diagnostics`, tests
(104 suite tests, 21 core tests), exact release scans of 2000 seeds/depth 4 and
1000/depth 6, the shrinker over 400 trees/depth 5, and a debug scan of 120
seeds/depth 4 with assertions enabled.
The new no-redraw test checks nested padding/stacks, fixed child
alignment, primitives, masks, and region nodes; restoring the old container
calls makes it fail with three draws instead of two. Additional tests cover
fractional reports and numeric dependencies. All five reference renders match
`a7307d9` pixel for pixel at 1920x1200, and the resized 900x1200 `tabs` matches a
cold render there. The GPU probe reports Venus on the RX 7900 XT.
**The remaining gap has not been established as an unavoidable correctness
cost.** Before the extent-relative change, a temporary failure trace of one
frame on seed 1, depth 8 counted 39/108 failed reuse attempts in size, 65/151 in resize, and 45/109 in many
where only the raw placement snapshot differed: both numeric frame and extent
ranges still held. Containers read that snapshot to construct child positions,
so discarding the check would be wrong. Padding and stacks now retain their
extent-relative child frames, but `Span` still constructs its mixed-reference
slots from a raw placement read: along the row, child fractions refer to the
frame while cursor positions and leftover room refer to the extent. Retaining
those expressions without changing their fixed-point evaluation order is the
next protocol question. There is also necessary width-dependent work: the hot text at widget 199 visits 68 px and 89.53613 px
and reports different heights (316.80078 and 228.80078). It is evaluated
repeatedly at those widths, so that fact alone does not justify all the repeats.
Keeping measurement validity separate from final drawing validity and deferring
to an already dirty parent were previously tried without improving draw counts.
Measuring first in local redraw and then restoring its slot saved only one draw
in `many` and added another request on simple repaints; that trial was removed.
Retaining local coordinates still costs memory per primitive. Optimize under
the exact oracle before adopting the prototype; the `Inset`/`Outset` changes
remain parked.
The earlier glyph-origin composition optimization (`a7307d9`) remains; it changes
no layout decisions or retained state. Two removed arithmetic trials remain
unpromising: combining frame recomposition with extent movement saved under 0.8%
instructions and no cycles; special-casing equal endpoint fractions in
`UiSpan::within` cost 11% cycles and 7% instructions. Retained local coordinates
still cost memory per primitive. The prototype remains separate from PR #18;
the `Inset`/`Outset` changes remain parked.
## How layout is decided
@@ -774,7 +732,8 @@ is a useful regression target for the experimental recomposition.
- `Holds` is the interval of box lengths for which a widget's drawing and
reported size stay valid. Reading `Painter::px_len` or `px_size` narrows
it to the length read; `Painter::holds` widens it. Parent validity is the
intersection of what its children induce. The contract is trusted: a
intersection of what its children induce: measured children for an answer,
all painted children for a drawing. The contract is trusted: a
widget declaring a wrong range is a defective widget, and Iris adds no
defensive work to recover from one.
- A retained drawing can be reused only when its `Holds` contains the new
@@ -791,26 +750,18 @@ is a useful regression target for the experimental recomposition.
long as the offer; anything else is its parent's question, with the mark
left on. Lengths and not whole boxes: what a drawing depends on is its
lengths, so the same lengths elsewhere is the same question.
- An answer is reusable only where both its measurement and the drawing in
its final placed box remain valid; the drawing's `Holds` is translated
back through `placed_lens` and intersected with the answer's.
- `Painter::widget_decided(child, region, [bool; 2])` says the parent chose
this box from the child's own answer along those axes, so the answer is
not placed inside it again. A report of "half of what you give me" has no
fixed point but zero, so the framework asks exactly twice: at the offer,
and in the box chosen from the answer, final on the decided axes. `Span`
decides the row axis, `Scroll` both, `Stack` both for its sizing child.
`Pad` overrides nothing: its inset is exactly the inner where the box is
its answer, and the slack is the inner's to sit in otherwise.
- **Placement cannot be applied after the fact.** Three attempts at "draw
the widget, then move its drawing to where its alignment says" failed,
because the move is a change of frame and no split of the stored state
carries it: moving `ActiveData::region` with the drawing made a later
local redraw ask a differently rounded question, and leaving it made
`placed` accumulate without bound because `try_reuse` returns a clean
subtree's size without walking into it. Alignment is applied where the
size is known -- `declared_box` for a rule, the placing second ask
otherwise.
- An answer is reusable where its measurement contract holds. Its final
drawing is checked independently and may need redrawing even while the answer
stands. Drawing validity is translated back through the chosen placement for
the parent's drawing contract, not intersected into the retained answer.
- `Painter::widget_at(child, frame, placement)` supplies an optional chosen
extent on each axis. Unchosen axes place the measured answer by the child's
alignment. `measure_len` leaves a fresh measurement at the offer until the
parent assigns a slot; it does not first place an intermediate answer.
- Placement must not replace the fraction reference. The failed earlier
attempts moved `ActiveData::region` with the drawing and made local redraws
ask differently rounded questions. The experiment retains `placement`
separately and replays original local frames when recomposing geometry.
- A widget that clips to its box reports its box: `Scroll` and `Masked`
report `LEFTOVER` on both axes, and a `debug_assert` holds any widget
that set a mask this draw to it. Overflowing is otherwise ordinary, which
@@ -858,9 +809,9 @@ is a useful regression target for the experimental recomposition.
parent (seed 10). `Painter` records size-dependency edges only when a
parent reads a child's size or hint; an undrawn measured child stays
recorded so a later change reaches whoever decided not to draw it.
- Dirty widgets settle deepest-first, and that ordering is what makes an
answer trustworthy; `dirty_size_under` is to be deleted once a resize goes
through the same walk (see "A frame settles strictly bottom-up").
- Dirty widgets settle deepest-first, including during resize. A deferred
child leaves its parent marked, so no clean answer can hide an unsettled
size dependency. `dirty_size_under` has been deleted.
- Declared non-`leftover` lengths are resolved by the parent where the
widget is drawn, so a declared-length change redraws the parent. A rule
wins on the axis it names and the widget under it never learns of it.
+13 -8
View File
@@ -879,14 +879,19 @@ prototype has significant container-update costs and is not yet adopted into
the pinned framework. A container's length query can evaluate the offered extent
without first placing the child's reported size; the parent's subsequent draw
assigns the final slot using the same `Widget::draw` body. A parent-selected slot
still honors a fixed child's alignment within that slot. Child frames can also
retain an extent-relative region through `widget_within(DrawRegion::Extent)`;
padding and stacks use this instead of baking the current placement into child
coordinates. Pixel insets carry numeric validity through the extent. General
fractional endpoints and fractional reports retain conservative placement
dependencies; fixed-point composition must not be reassociated to remove them.
Retained movement preserves the slot chosen from measurement, even if the final
draw reports another size.
still honors a fixed child's alignment within that slot. Answer dependencies
are collected only from child sizes actually read; drawing dependencies
include all painted children. Root resize and local propagation check both
contracts. An unchanged answer with a wider validity range retains its old
contract: the parent's guarantee is still valid, and narrowing back must not
trigger layout. The same rule applies to retained drawing validity. A changed
declared size defers to its direct parent, then ordinary bottom-up propagation
stops when both contracts remain unchanged. Blanket ancestor invalidation is
unnecessary. The extent-relative child-frame API trial was removed: the major
size/resize cost came from conflating these dependencies, not from Pad and
Stack's conservative raw-placement reads. Cold layout and widespread content
changes still perform excess provisional work; that residual has not been
established as necessary for correctness.
**A `Span` leaves its children in the offered orthogonal region unless
explicitly compacted** (2026-09-12). It still reports the widest orthogonal