Record why an arbitrary settle order returns a stale size

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-14 19:57:26 -04:00
1 parent d0c80a2037
commit ba97265191
1 file changed
+37 -32
+37 -32
View File
@@ -373,41 +373,46 @@ holds a *drawing* keyed on what produced it, and the widget still draws. The
five reference renders and the resize render are byte-identical, and the
sweep passes.
**Picking any dirty widget instead of the deepest does not work, and the
generated cases say so.** Tried on 2026-09-14: take whatever the dirty set
yields first, and let `redraw`'s existing climb to the highest size reader do
the ordering -- then also climbing to an ancestor that is itself dirty, since
drawing that redraws this one anyway. Both fail
`a_changed_size_lands_where_growing_it_that_way_would` at seed 1, 24 widgets
wrong, a subtree keeping a 317 px width where a cold tree has 147.
**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
the size it last reported. It does not ask whether a dirty widget sits under it
through the size dependencies -- which is the check `retained_size` makes, on
the path that does not draw, for exactly this reason. **The settle order is
what covers that gap**, and it was written down nowhere: taking the deepest
first means that by the time a reader draws, what it reads has drawn and
propagated.
What the trace says, and where it stops: with the deepest first, seed 1 settles
in 50 draws and the same `SetSize` is drawn three times as its box is decided
and redecided above it. Picking any settles in 12, and two of the three edited
`SetSize` widgets are never popped at all -- they are drawn inside a `Span`'s
pass, which consumes their marks. Nothing in the climb misfires; no escalation
happens in this scenario at all, so the difference is only which widgets are
drawn as the root of a pass and which inside someone else's. **Why that changes
the answer is not yet pinned down**, and it is the thing to find out before
changing the order again.
The trace that shows it, on seed 2 of `tests/generated.rs`: with the deepest
first and with an arbitrary pick, the two are identical event for event until a
`Span` reports its size -- 147 px one way and 317 px the other, from the same
child sizes. It had reused a subtree exactly, and inside that subtree sat a
`SetSize` whose declared width had changed and which had not been drawn yet.
24 widgets end up wrong.
So the order is load-bearing and the cost is the scan rather than the order.
It splits: of the 24.5%, about 15 points are the depth walk (removing it
entirely takes 16.5B instructions to 14.0B) and about 9 are iterating the
`HashSet` itself, which walks by capacity rather than by length. Three ways
out, none of them tried yet:
So the order is not about cost, and this is the thing to fix before changing
it. Adding the missing check to `try_reuse` does make any order correct -- all
six generated cases and the hundred-seed sweep pass with the dirty set taken in
whatever order it yields. But `dirty_size_under` walks the size-dependency
subtree on every reuse that hands a size back, and that costs about what the
sort saved: at 130 of 260 widgets dirty, 7.83M instructions per frame against
8.23M; at 32 dirty, 3.32M against 2.84M, so *worse* where the dirty set is
small. Two other shapes measured and rejected on the way: putting the check at
the top of `try_reuse` rather than on the two paths that return a size (7% dearer
again), and phrasing it as `size_is_invalid`, which also lets a resize mark
through and takes the resize phase from 7M to 106M instructions per frame.
- **Keep the depth on `ActiveData`.** `draw_inner` knows the parent, whose
depth is in its own entry, so this is O(1) to read and the order is
unchanged. The risk is a second source of truth: a widget whose ancestor is
re-placed under a different parent without the widget itself being redrawn
would hold a stale depth.
- **Bucket the dirty set by depth.** Exact, and the depth is only needed when
an id is inserted -- which inside the loop is always the parent of a widget
whose depth is already known, so no walk is needed there.
- **Process in rounds**: sort once, drain, re-sort what is left. Cheapest to
write and *not* the same order -- a widget marked during a round can be
deeper than what remains of it.
**What would actually pay is a maintained count** rather than a walk: each
widget holding how many dirty widgets sit under it through size-dependency
edges, incremented up the reader chain when a mark is added and decremented
when one is consumed. That is O(depth) at a mark and O(1) at the check, where
today it is O(dirty x depth) per widget settled. It wants agreeing first: every
place that inserts into or removes from `needs_redraw` has to pair with it, and
a count that is too low is a stale size rather than a slow frame.
Of the sort's own 24.5%, about 15 points are the depth walk and about 9 are
iterating the `HashSet`, which walks by capacity rather than by length. Keeping
the depth on `ActiveData` would remove the first without changing the order at
all, at the price of a second source of truth for it.
**A frame that dirties many widgets at once was not being checked, and it is
where the settle order shows.** `77bb75e` adds two generated cases -- every