Redesign span layout around retained placement

This commit is contained in:
iris committed 2026-09-09 15:11:57 -04:00
1 parent ae0af8f5e3
commit 0aa03cf621
30 files changed
+501 -1321

No files matched your search

+12 -29
View File
@@ -650,8 +650,7 @@ strategy alone. Three things, in the order they had to be fixed:
dirty. This alone took the glyph array from 95% re-uploaded to 3%.
2. **A redraw freed its primitives and pushed new ones.** Freed slots are
not reusable until the end of the frame (a layer's draw order still
names them), and `Painter::draw_twice` -- how a container learns a
child's size, and containers nest -- meant the arena's high-water was
names them), and nested provisional layout meant the arena's high-water was
the *transient* push count: 17 million pushes across 401 deltas, and
127,443 slots for 11,569 live primitives, growing linearly with the
transcript. A redraw now gets its old handles back as a recycle pool
@@ -674,36 +673,20 @@ changed. `PrimitiveVec::set` and `Primitives::set_instance` compare before
marking, and `arena_churn` prints both numbers so the gap cannot reopen
unnoticed.
**A measurement is a mode, not a discarded draw (added later the same
day).** `Painter::draw_twice(child, first, |used| second)` became
`Painter::measure` + an ordinary draw, at Iris's request: her objection
was the shape it forced on the caller, since the arithmetic that picks
the real region had to happen inside a closure and anything it wanted to
keep came back out through a captured `&mut`. Two statements now say it
in the order it happens.
**Layout has no measurement mode.** A widget is drawn provisionally only
when its size cannot be known yet, and that retained drawing is moved into
place. `Widget::size_hint(axis)` lets context-free wrappers such as `Sized`
report an exact `Len`; a debug assertion compares every hint with the real
draw result. If final allocation changes a child's size, `Painter::place`
redraws it in that box. Otherwise placement is one move-offset write.
`DrawMode::Measure` is that draw with everything it *writes* switched
off -- no arena slot, no mask, no move slot, nothing left in `active`,
nothing marked dirty -- so the real draw that follows is an ordinary one
and cannot be short-circuited by the measurement having "already drawn"
the widget at that region. A `debug_assert` at the end of `draw_inner`
catches a `Painter` method that forgets to check the mode, because the
failure would otherwise be one leaked primitive per measured widget per
frame.
The amplification this removes, measured: a streamed frame makes **1,083
`Widget::draw` calls over 113 distinct widgets**, and the worst widgets
are drawn **11 times** at nesting depth 7-8. It is not two draws, it is
two to the power of how many measuring ancestors a widget has. Only the
*writes* go away, not the traversals -- the walk and the region
arithmetic still happen 11 times, and removing those needs a size that
can be answered without drawing, which is what LAYOUT.md section 5 rules
out. Worth what it cost: the streamed frame went p50 1.39ms -> 1.22ms
and p99 4.75ms -> 3.58ms, and the upload numbers did not move, because
recycling had already made the discarded writes free in arena terms.
Measured over the fixture's 401 streamed events: the busiest frame makes
176 `Widget::draw` calls and the worst widget is called four times.
Streamed-frame CPU p50 is 0.35ms, from 1.18ms before this layout change.
Arena size and upload floors are unchanged.
**What is left, and it is a layout question rather than an upload one.**
Stream instances upload 72.7%, which *is* the floor: the list is pinned to
Stream instances upload 71.9%, against a 71.8% floor: the list is pinned to
the newest end, so a growing reply moves every row, and a row's instances
carry an absolute region. Moving a subtree is supposed to be one
`move_offsets` write (LAYOUT.md section 2); something on this path is