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:
1 parent
9bfda08f85
commit
1ffe6ea067
2 files changed
+1267
-2
No files matched your search
@@ -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
Reference in new issue
Block a user