iris: a measurement is a mode on the painter, not a discarded draw

Painter::draw_twice(child, first, |used| second) becomes Painter::measure
plus an ordinary draw. Iris's objection was the shape it forced on the
caller rather than the two draws themselves: 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. LazySpan::place was the only
caller, and it now reads as the three statements it is.

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. Only the returned Size survives, and the widget is left
exactly as it was, so the real draw that follows is an ordinary first
draw or redraw. That last part is load-bearing: a measurement that left
an ActiveData behind would let the following draw hit draw_inner's
"already at this region" fast path and return having drawn nothing.

A measurement also does not consume a redraw mark, since it is not the
redraw the mark asked for, and it takes none of the fast paths, since
"already drawn here" cannot report a size.

Every Painter method that writes now returns early on the mode -- a
widget's own draw never checks, which is the point. A debug_assert at the
end of draw_inner catches one that forgot, because the failure otherwise
is a single leaked primitive per measured widget per frame, which a
screen redrawn every frame turns into an arena that grows without bound.

What this is worth, and what it is not. The amplification it applies to,
measured on a streamed frame: 1,083 Widget::draw calls over 113 distinct
widgets, with the worst drawn 11 times at nesting depth 7-8 -- it is not
two draws but two to the power of how many measuring ancestors a widget
has. Only the writes go away; the walk and the region arithmetic still
happen 11 times, and removing those needs a size answerable without a
draw, which LAYOUT.md section 5 rules out. Streamed frame p50 1.39ms ->
1.22ms, p99 4.75ms -> 3.58ms. The upload numbers do not move, because
slot recycling had already made the discarded writes free in arena terms.

Also extracts move_slot_for from draw_inner, since measuring must not
allocate one and the reuse-in-place rule wanted saying once.

Verified: run-tests.sh, iris's suite, clippy and rustfmt clean, and the
headless phone render is byte-identical to the previous commit's on the
real GPU (Venus, RX 7900 XT -- checked, not llvmpipe).
This commit is contained in:
iris committed 2026-09-09 11:51:33 -04:00
1 parent a428cba41a
commit 18c5f9aaac
5 files changed
+232 -75

No files matched your search

+28
View File
@@ -674,6 +674,34 @@ 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.
`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.
**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
the newest end, so a growing reply moves every row, and a row's instances