Note the immediate-mode path Iris proposed, and what it would not catch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-15 01:02:06 -04:00
1 parent 9bfda08f85
commit 1ffe6ea067
2 files changed
+1267 -2

No files matched your search

+125 -2
View File
@@ -379,6 +379,40 @@ sweep passes.
### Settling a dirty set, and why the order is not free to change ### Settling a dirty set, and why the order is not free to change
**There are two ways to obtain a retained size and only one of them is
guarded.** `Painter::known_len` -> `RenderState::retained_size` is the path
that answers a child's length without drawing it, and it refuses on
`size_is_invalid(id) || dirty_size_under(id)` -- it asks the deep question.
`try_reuse` is the path that keeps a child's *drawing* for a new box, and it
asks only whether that widget is itself in `needs_redraw`; the size comes back
as a by-product of the reuse succeeding, and never got the guard the other path
has. So a reader is not reaching past anything: it asks a clean child, and the
child answers from a record that is stale only because something below it has
not settled. **The settle order is standing in for a missing check on one of
two implementations of the same concept**, which is why the order is
load-bearing for the answer and why that was easy to miss.
That also says why bolting `dirty_size_under` onto `try_reuse` costs about what
the sort saves. On the `many` phase at 130 of 260 dirty the guarded path runs
47 times a frame and `try_reuse` runs 569 -- so the O(subtree) walk moves from
a cold path to one twelve times hotter. **The owner rejected building a maintained count for this on
2026-09-14**, and the reasoning stands: the count is only clarity, it costs a
pairing obligation at every site that marks or consumes a mark plus a cost in
every shipped frame, and bottom-up is needed for its own sake regardless. A
count that drifts low is a silent stale size, which is worse than what it
replaces. Its one remaining argument would have been freedom to settle in any
order, and the rows below measure that as costing more than it saves.
What guards the invariant instead: `a_long_run_of_seeds_agrees` compares a warm
incremental frame against a cold rebuild of the same tree over a hundred seeds,
which is what caught the defect on seed 2 and is a stronger oracle than an
assertion. A `debug_assert!(!self.dirty_size_under(..))` on the two paths in
`try_reuse` that return a size would make the dependence on the order fail
loudly there rather than silently -- those are the exact and moved paths, 133
calls a frame on the `many` load rather than all 569 attempts -- and it
compiles out of release, matching what `depth` already does against
`walked_depth`.
**Why picking any dirty widget does not work, found 2026-09-14.** `try_reuse` **Why picking any dirty widget does not work, found 2026-09-14.** `try_reuse`
asks whether the widget in front of it is dirty and, if not, hands its parent asks whether the widget in front of it is dirty and, if not, hands its parent
the size it last reported. It does not ask whether a dirty widget sits under it the size it last reported. It does not ask whether a dirty widget sits under it
@@ -458,6 +492,60 @@ hits and only two lines differ between the builds. Nothing here rests on that
difference, but it means those two rows are worth re-measuring before anyone difference, but it means those two rows are worth re-measuring before anyone
builds on them. builds on them.
### An immediate-mode path, proposed by the owner
Iris raised this on 2026-09-15, as something to have rather than something to
do now: a way to render with nothing cached at all, redrawing everything cold.
Per widget would be nicer, but a switch on the ui, or a second entry point
alongside `update`, is the useful start. Two uses -- the performance floor a
retained layout is measured against, and a second opinion on correctness that
does not share any of the retained machinery.
Worth recording what it would and would not have caught. Not the `SetSize`
defect below: that one is a first frame laying out wrongly, with no retained
state involved, so an immediate path would have reproduced it faithfully. It
would catch anything where keeping a drawing is what goes wrong, which is what
`generated.rs` uses a cold `Harness` for today -- and a cold `Harness` is a
weaker instrument, because building a second tree is not the same code path as
refusing to reuse the first.
### A span's size across its own axis
`Span` reported `max(children abs)` across its axis, except that one child with
any `rel` or `rest` flipped the whole span to `Len::REST` -- a discontinuity
its own `TODO` admitted. So any disagreement about a child's reported size
moved the span between tight and filled rather than by a little, and every
child is placed `FULL` across the axis, so they all inherit the move together.
That is what the depth-5 seed-10 divergence looks like: three texts, identical
heights, all jumping from 874 px wide to 306.
`OrthoSize::{Fill, Children}` replaces it, chosen per span rather than inferred
from what the children happen to report. `Children` propagates the largest
child whole -- a child at `rel` 0.5 makes the span `rel` 0.5 -- and compares
candidates in pixels only when one has a share the other does not, since that
is the only case the components cannot be compared directly. Reading pixels
costs `OnResize::Scale` across the axis, which is why the choice is explicit.
**It is circular, and this is the open question.** `Children` reads the span's
own box to decide which child is longest, and what it reports decides that box:
offered 900 it may report 306, be given 306, and on the next draw compare
against 306 and pick a different child. `adding_and_removing_span_children`
seed 13 diverges in release with 112 widgets wrong, at `DEPTH = 4`. The
comparison wants a reference that does not depend on the answer -- the offered
box rather than the settled one -- which is stable until a `Children` span
nests inside another. This is the cross-axis twin of the along-axis
double-shaping in LAYOUT.md §4, not a separate defect.
**Depth 4 is not enough.** `tests/generated.rs` ran at `DEPTH = 4` and the
generator branches two to four ways per level, so depth is exponential in width
and a deep tree cannot be reached by raising it. `IRIS_GENERATED_DEPTH` and
`IRIS_GENERATED_SEEDS` now select the load. At depth 5 the first 300 seeds
already fail -- seed 10, `AddThree`, a wrapping text under a span whose axis is
**Y**, which the comment on `a_long_run_of_seeds_agrees` claims is the stable
case. That claim is wrong, and the "7 of these 90 / 30 with it" numbers beside
it do not match a sweep that passes clean at depth 4; re-measure before
trusting them.
### Dirtying many widgets at once ### Dirtying many widgets at once
**A frame that dirties many widgets at once was not being checked, and it is **A frame that dirties many widgets at once was not being checked, and it is
@@ -469,8 +557,43 @@ to the rig, with `IRIS_DIRTY` widgets marked per frame. At 130 of 260 widgets,
set is scanned once per widget settled, and a `HashSet` is walked by capacity set is scanned once per widget settled, and a `HashSet` is walked by capacity
rather than by length. Memoizing the depth walk within one scan does not pay rather than by length. Memoizing the depth walk within one scan does not pay
(it trades parent lookups for memo lookups, 4% more instructions); the fix is (it trades parent lookups for memo lookups, 4% more instructions); the fix is
to stop rescanning, which changes the order widgets settle in and is hers to to stop rescanning.
agree first.
**That fix does not have to change the order, and the cost is not the
choosing.** Measured 2026-09-14 on the `many` phase at 130 of 260 dirty, all
4.7% of it is one symbol: hashbrown's `RawIterRange::fold_impl`, the table walk
under `max_by_key`. Since `3f7cd82` the key is a field read, so what is left is
the cost of *finding* an element in a set walked by capacity, once per widget
settled -- not the cost of deciding between them. A depth-bucketed worklist in
`UiRenderState` keeps the order exactly and makes the pop O(1); the objection
that `needs_redraw` lives on `Widgets` and is inserted from places with no view
of the tree does not apply to it, because the buckets are a hint rather than a
second truth. A bucket entry whose mark has since been consumed is skipped on
pop for one hash lookup, so the obligation is only never to miss an insert,
never to stay in step.
**Going order-free is not the thing to buy.** `iter().next()` in place of the
deepest-first pick, with no check added, takes widget draws from 502 to 670 per
frame and instructions from 6.84M to 9.01M. The extra draws are not a parent
drawn twice on its own: the same widgets appear at the top of the per-widget
draw counts in both orders, at the same trial widths (a `Text` under
`SetSize < Scroll < Span` is drawn at 14 distinct widths either way), with
every count about 1.5x. The measure-by-drawing loop is not exploring more, it
is being re-entered. What drives the re-entry is that boxes change more often:
`try_reuse` refusals for a changed box go from 79 to 116 own-resize and 126 to
187 descendant-resize, because a reader settled before its children hands out
boxes from sizes that are about to move, and when they move every child in the
subtree refuses reuse. The `dirty_size_under` check is what buys that back,
which is why the order-free rows above come out 3.4% dearer at 130 dirty rather
than 32%. So the order is paying for itself in draws, and the count maintained
up the reader chain would be a way to afford dropping something worth keeping.
One counter does not fit that account and was not chased down: queue pops fall
from 87 to 80 and local redraws from 66 to 59, where re-marking a reader should
raise both. The likely reading is that a re-marked reader is consumed inside a
later pop's subtree draw rather than popped on its own, but it is inferred from
`redraw`'s escalation path rather than traced. `EagerReaderRedraws` is 4 and 3,
so the escalation itself is not where the draws come from.
**What a frame is made of now**, `perf record` on the `many` phase at 130 of **What a frame is made of now**, `perf record` on the `many` phase at 130 of
260 dirty, which is the heaviest thing the rig has: `Layers::write` 16%, 260 dirty, which is the heaviest thing the rig has: `Layers::write` 16%,
File diff suppressed because it is too large. Load diff