Move LazySpan rows through one retained offset

This commit is contained in:
iris committed 2026-09-09 16:15:09 -04:00
1 parent 0aa03cf621
commit fffed42f9e
13 files changed
+503 -104

No files matched your search

-29
View File
@@ -12,35 +12,6 @@ and six phone-report sections went on 2026-09-08 for that reason.
## Fix
- [ ] **A row moving because the list grew should be one `move_offsets`
write, and today it is a redraw.** Found 2026-09-09 by
`scripts/rigs/ui-profile`'s `arena_churn` and left for whoever picks
this up next; the upload half of it is done and this is the layout
half.
The measurement. Over the bench fixture's 401 streamed deltas, the
instance arena uploads **71.9%** of itself per frame against a
**71.8%** floor -- those entries genuinely differ, so no amount of
better dirty-tracking touches it. The control that says it is wrong is
the fling phase on the same screen and the same content: it moves the
same primitives every frame and uploads **3.3%**, because a scroll
reaches `UiRenderState::mov` and writes one `move_offsets` delta for
the subtree (LAYOUT.md section 2) instead of rewriting every
primitive's absolute region.
The list is pinned to the newest end, so a growing reply pushes every
earlier row up. `draw_inner` now treats sub-pixel size differences as a
move and the span refactor removed nearly all repeated draws, but the
instance floor remains 71.8%. The remaining question is why those
translations still rewrite primitive regions instead of stopping at
the rows' move slots.
Done looks like: `arena_churn`'s `what_a_streamed_reply_uploads` shows
stream instances in the same range as the fling's, and its `whole`
column stops being the interesting one. The rig prints floor,
uploaded and whole per array precisely so this is checkable rather
than argued.
- [ ] **Where the scroll *pin* lives.** The rest of "scrolling moves out
of the list" landed on 2026-09-08 -- `List` is `LazySpan`, the physics
and the gesture live in one `ScrollController`, `.scrollable()` is the
+15
View File
@@ -94,6 +94,15 @@ resolved in the vertex shader.**
- `mov(id, delta)` becomes: look up `id`'s slot, write
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
recursion, since descendants already reference this slot transitively.
- A container may also retain one optional **child-coordinate slot** between
its own slot and every direct child's slot. `Painter::set_child_offset`
creates that boundary before the first child is drawn and can update it
after measuring a child on later redraws. The container's own primitives,
hit region and mask stay fixed; its whole child subtree moves through one
write and every existing GPU, hit-test and accessibility chain sees the
same result. `LazySpan` uses this while still walking visible rows for
virtualisation: row boxes stay in stable local coordinates and the shared
boundary carries the changing screen translation.
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
pixels (after `:106`, before the clip-space divide at `:113`), walks
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
@@ -120,6 +129,12 @@ for a resize that changes a region's `rel` component (a genuine reflow,
§3) and for a size-independent widget's resize (§3), where the content's
shape doesn't change and one field write already suffices.
Provisional layout can still write an instance at an intermediate position
and restore it before upload. `Primitives::set_instance` remembers the value
at the first write in a frame and clears the dirty bit when the final bytes
match it. The GPU therefore observes final layout state, not CPU-only
measurement work.
### 2b. Two more readers of "where is this widget," and masks
Moving the offset into the vertex shader means `ActiveData.region` is no
+14 -8
View File
@@ -670,8 +670,10 @@ The trap that only the rig could have caught: writing an entry is not the
same as changing it. Recycling rewrote every glyph of every moved row with
identical bytes, marking 73% of the glyph array against 0.6% genuinely
changed. `PrimitiveVec::set` and `Primitives::set_instance` compare before
marking, and `arena_churn` prints both numbers so the gap cannot reopen
unnoticed.
marking. Layout can also write a provisional instance and restore it within
one frame; `Primitives` remembers the pre-frame bytes and cancels that dirty
bit when the GPU-visible result is unchanged. `arena_churn` prints both
numbers so either gap cannot reopen unnoticed.
**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
@@ -685,12 +687,16 @@ Measured over the fixture's 401 streamed events: the busiest frame makes
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 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
redrawing instead.
Pinned growth now uses the same subtree translation as scrolling. A
container can retain a child-coordinate move slot through
`Painter::set_child_offset`; `LazySpan` keeps retained rows in stable local
boxes and changes that one slot when its anchor moves. It still walks the
visible run to virtualise it, but unchanged rows no longer acquire new
absolute primitive regions. Over the fixture's 401 streamed events, instance
upload is **2.9% against a 2.9% floor**, from 71.9% against 71.8%; median
instance bytes per frame are **1,728**, from 176,496. This is framework
layout/rendering behaviour and the transcript screen contains no special
case for it.
### The Android release profile is `opt-level = 3`, not `"s"` (2026-09-09)
+12 -9
View File
@@ -272,18 +272,21 @@ cannot pan; there is a `debug_assert` in `drag` naming that.
## Measurements worth not re-taking
- A settled scroll tick of a `LazySpan` with 31 rows on screen:
**1 real draw and 31 move-slot writes**, no primitive rewrites and no
text reshaped. An idle frame is `(0, 0, 0, 0)``draw_inner` does not
even enter the widget. This is the number any "store the edges and only
recompute what changed" optimisation would have to beat, and it is why
the walk was left alone.
- A settled scroll tick of a `LazySpan`, for 20, 200 or 2,000 total rows:
**1 real draw and 1 child-coordinate move-slot write**, no primitive
rewrites and no text reshaped. The visible-row walk remains: it is what
admits and retires rows at the viewport boundary, and a newly admitted row
has real initial-placement work of its own. An idle frame is `(0, 0, 0,
0)``draw_inner` does not even enter the widget.
- A fully hinted `Span` draws each child once. Unknown fixed children draw
provisionally and move; region-dependent children redraw if their final
box has a different size.
- The one design that would collapse those 31 moves into a single delta
write is moving the content as a unit, which needs a content length —
which a lazy layout cannot supply.
- Moving the currently retained run as a unit does **not** require the lazy
span's unknowable total content length. Its anchor supplies the relation
between stable local row boxes and their desired screen boxes; one retained
child-coordinate slot carries that translation. The offset is occasionally
rebased after 65,536 pixels to preserve `f32` precision, a rare O(visible)
move-slot pass rather than steady-state work.
## Tests that pin the behaviour