Scope a lazy-list scroll to its subtree instead of rebuilding the whole scene - #504
Conversation
Local gates green
On the evidence, restated plainlyThe mechanism is proven by the new unit test: the renderer now receives a scoped update carrying dirty node ids, where it previously received a full rebuild, held across three steady-state scroll steps. That is the part that matters architecturally — the work went from O(whole app) to O(subtree). The timing number ( Verifying against cranscan is the obvious next step and needs a Not addressed here (both traced in #500)
One measurement caveat for anyone using the desktop harness on this: |
…le scene A scrolling LazyColumn rebuilt the render graph from the composition root on every frame. Two gaps put it there, and both had to close: The list invalidates through `schedule_measure_repass` — its item sizes are what change, so placement-only dirtiness is not enough — but the layout phase only ever collected node ids from *layout* repasses. The measure ids were dropped, so the scene phase saw the scene marked dirty with an empty dirty set, read that as "everything changed", and took `rebuild_scene_from_applier`. Closing that alone was not enough: a frame runs the layout phase twice, once initially and again after post-layout recomposition, and the phase *assigned* the scoped ids rather than accumulating them. Whichever pass ran last won, so a second pass with nothing pending wiped what the first recorded — the ids survived or vanished depending on which pass the scroll landed in. The phase now accumulates across passes and only an app-wide relayout, where scoping means nothing, clears them; the scene phase already takes them once per frame. Measured on the demo lazy list, the scene stage was 6.4-7.4 ms of a 16.7 ms frame on a Kirin 980 — the largest CPU stage of a scrolled frame. The regression test asserts what the sibling graphics-layer repass tests already assert and the lazy path had lost: zero rebuilds, one scoped update carrying dirty ids, held across three steady-state scroll steps. The pre-existing lazy test accepted either path, so it could not catch this; it now exercises the scoped path it was written for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0796c56 to
0b02a2b
Compare
On-device check on cranscan, and a false alarm worth recordingcranscan's Library page is a This looked like a regression and is not one. Raw
The patched minimum exceeds the unpatched maximum — clean separation, and It is not. Controlling with
The ratio is flat across every run. The absolute values drift together because the Kirin 980 clocks down between measurement sessions — the unpatched runs happened to land in a fast state and most patched runs in a slow one. Comparing runs at matched Method note for anyone measuring this device: a single stage compared across sessions is not interpretable. Carry an untouched stage as a within-run control, or the CPU governor will hand you whatever result the thermal state happens to favour. Two arms in my own session also disagreed on No fps change, as expected — this scene's frame is bound by The correctness claim of this PR — zero full rebuilds during a steady-state scroll — is asserted by 🤖 Generated with Claude Code |
Part of #500 (cause 1 of 3). Independent of #497 — different subsystem, no shared commits.
Problem
A scrolling
LazyColumnrebuilt the render graph from the composition root on every frame —rebuild_scene_from_applierrather than the scopedupdate_scene_from_applierthe path was designed to take. The work is O(whole app) where it should be O(visible rows).Two independent gaps stacked, and both had to close:
The measure-repass node ids were dropped. The list invalidates through
schedule_measure_repass— its item sizes are what change, so placement-only dirtiness is not enough — butrun_layout_phase_in_contextonly ever collected ids from layout repasses. The scene phase then sawscene_dirty = truewith an empty dirty set, which is indistinguishable from "everything changed", and fell through to the full rebuild.The scoped ids were assigned, not accumulated. A frame runs the layout phase twice — once initially, once after post-layout recomposition — and the second pass, with nothing pending, cleared what the first had recorded. Whichever pass ran last won, so scoping survived or vanished depending on which pass the scroll landed in. (Closing gap 1 alone fixed the first scroll step and not the second; that asymmetry is what exposed this.)
The phase now accumulates across passes and only an app-wide relayout — where scoping means nothing — clears them. The scene phase already takes them once per frame via
std::mem::take.Also corrects the doc on
LazyListState::dispatch_scroll_delta, which claimedschedule_layout_repassand "O(subtree) performance instead of O(entire app)". The code had drifted from its own stated contract; that stale comment is part of why this went unnoticed.Why no test caught it
lazy_column_scroll_repass_uses_scoped_renderer_update_without_stale_rowsassertsupdates + rebuilds == 1— it accepts either path. The siblinggraphics_layerrepass tests in the same file assertrebuilds == 0. The lazy path is the one that lost that bound, and it is the path every scrolling screen in a real app takes.New test
lazy_column_scroll_never_rebuilds_the_whole_sceneasserts zero rebuilds and exactly one scoped update carrying dirty ids, held across three steady-state scroll steps so it cannot pass on a first-frame special case. It fails on the parent commit (rebuilds: 1) and passes here.Measurements (Huawei EVR-AL00, Kirin 980)
Same scene, same gesture, same branch, A/B against the parent commit — demo Lazy List tab,
debug.cranpose.frame_stage_ms:scene_mstypicalscene_msworstPlease read that as a lower bound on the win, not the headline: the demo's whole composition tree is small, and the bug's cost is proportional to total app size, so a real app pays more than the demo does. I have not yet measured a real app against this build — that verification is still outstanding.
Explicitly not fixed by this change: the demo's Liquid tab does not take this path and is unchanged (its heavy frames still show
scene_ms6-13 ms, because the scoped update itself rebuilds the whole dirty subtree with no offset-only fast path). Causes 2 and 3 from #500 — render-pass count and backdrop cache thrash — are untouched here; both are traced to exact mechanisms in the issue thread.Testing
cargo testgreen for the changed crates (cranpose-app-shell,cranpose-ui,cranpose-foundation— 1400+ tests).just fmtandclippyclean on the changed crates. The fulljust testworkspace gate was still running when this PR was opened; I will report the result on the PR.🤖 Generated with Claude Code