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 five reference renders and the resize render are byte-identical, and the
sweep passes. sweep passes.
**Picking any dirty widget instead of the deepest does not work, and the **Why picking any dirty widget does not work, found 2026-09-14.** `try_reuse`
generated cases say so.** Tried on 2026-09-14: take whatever the dirty set asks whether the widget in front of it is dirty and, if not, hands its parent
yields first, and let `redraw`'s existing climb to the highest size reader do the size it last reported. It does not ask whether a dirty widget sits under it
the ordering -- then also climbing to an ancestor that is itself dirty, since through the size dependencies -- which is the check `retained_size` makes, on
drawing that redraws this one anyway. Both fail the path that does not draw, for exactly this reason. **The settle order is
`a_changed_size_lands_where_growing_it_that_way_would` at seed 1, 24 widgets what covers that gap**, and it was written down nowhere: taking the deepest
wrong, a subtree keeping a 317 px width where a cold tree has 147. 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 The trace that shows it, on seed 2 of `tests/generated.rs`: with the deepest
in 50 draws and the same `SetSize` is drawn three times as its box is decided first and with an arbitrary pick, the two are identical event for event until a
and redecided above it. Picking any settles in 12, and two of the three edited `Span` reports its size -- 147 px one way and 317 px the other, from the same
`SetSize` widgets are never popped at all -- they are drawn inside a `Span`'s child sizes. It had reused a subtree exactly, and inside that subtree sat a
pass, which consumes their marks. Nothing in the climb misfires; no escalation `SetSize` whose declared width had changed and which had not been drawn yet.
happens in this scenario at all, so the difference is only which widgets are 24 widgets end up wrong.
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.
So the order is load-bearing and the cost is the scan rather than the order. So the order is not about cost, and this is the thing to fix before changing
It splits: of the 24.5%, about 15 points are the depth walk (removing it it. Adding the missing check to `try_reuse` does make any order correct -- all
entirely takes 16.5B instructions to 14.0B) and about 9 are iterating the six generated cases and the hundred-seed sweep pass with the dirty set taken in
`HashSet` itself, which walks by capacity rather than by length. Three ways whatever order it yields. But `dirty_size_under` walks the size-dependency
out, none of them tried yet: 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 **What would actually pay is a maintained count** rather than a walk: each
depth is in its own entry, so this is O(1) to read and the order is widget holding how many dirty widgets sit under it through size-dependency
unchanged. The risk is a second source of truth: a widget whose ancestor is edges, incremented up the reader chain when a mark is added and decremented
re-placed under a different parent without the widget itself being redrawn when one is consumed. That is O(depth) at a mark and O(1) at the check, where
would hold a stale depth. today it is O(dirty x depth) per widget settled. It wants agreeing first: every
- **Bucket the dirty set by depth.** Exact, and the depth is only needed when place that inserts into or removes from `needs_redraw` has to pair with it, and
an id is inserted -- which inside the loop is always the parent of a widget a count that is too low is a stale size rather than a slow frame.
whose depth is already known, so no walk is needed there.
- **Process in rounds**: sort once, drain, re-sort what is left. Cheapest to Of the sort's own 24.5%, about 15 points are the depth walk and about 9 are
write and *not* the same order -- a widget marked during a round can be iterating the `HashSet`, which walks by capacity rather than by length. Keeping
deeper than what remains of it. 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 **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 where the settle order shows.** `77bb75e` adds two generated cases -- every