You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
With the accessibility bridge fixed (#497), scroll on a Huawei EVR-AL00 (Kirin 980, Mali-G76, 60 Hz, 1080×2244) is still far from 60 fps on glass-heavy pages. Measured with debug.cranpose.frame_telemetry, debug.cranpose.frame_stage_ms, and debug.cranpose.gpu_stats on device.
Numbers below were re-taken on a quiet device (2026-08-27). The original
filing measured cranscan while its on-device OCR (Qwen3-VL 8B) was still
running — the app settled at 442 % CPU before the scroll even started, so
the app's own inference was being charged to the framework.
Same page, same device, same build — only the background load differs:
as filed (OCR running)
quiet device
fps
15–17
28.2
present p50
29–33 ms
13.5 ms
render p50
12–13 ms
8.1 ms
Wait for the app's own CPU to settle, and print what it settled at, before
trusting any on-device frame number.
The ranked causes below were re-verified, not re-derived. Pass count,
isolated-layer count, and the ~35 % layer-cache hit rate reproduce exactly on
a quiet device. Two of them came in worse than originally filed. Nothing in
the diagnosis changed — only the headline numbers.
GPU stats per frame: 35 render passes, 9 isolated layers, 7 live blurs, layer cache at 34.6 % hits with 1.78 MP of blurred offscreen content re-rendered every scrolled frame
Note that passes (35) and isolated layers (9) exceed the originally filed ranges (23–31 and 6–8). That part of the problem was understated, not overstated.
That is one noise band. Stage sums explain why: ~22.4 ms → ~19.4 ms against a
~16.1 ms vsync budget. The cut is real, but the frame misses the same vsync
either way. You do not get paid until you cross 16 ms. #497 is worth landing as
a correctness and battery fix — publishing an accessibility tree every frame
when nothing is listening is pure waste — but the fps work is entirely below.
Ranked causes
Scene update re-emission (~7 ms CPU/frame). During a pure scroll the moving subtree should ride the retained rigid-translation path (motion_context_animated / translated_content_offset on LayerNode), but the partial scene update path re-emits per frame. (Scope a lazy-list scroll to its subtree instead of rebuilding the whole scene #504 scopes the lazy-list case; cranscan's Library is a LazyColumn, so it is exercised by this scene.)
Render pass count (35/frame on the cranscan page, 30–39 on the demo). On a tile-based mobile GPU every pass is a full tile store+reload. Isolated glass layers each cost capture + blur chain + composite passes; the DirectChunkRunCoalescer exists for exactly this reason but glass pages defeat it.
Layer captures invalidated by translation (65 % miss, 1.78 MP/frame). Scrolling translates layers and misses their cached captures. For backdrop-sampling glass this needs an architectural answer (e.g. blur the static background once per change into a shared texture and let moving cards sample it) rather than per-card re-blur per frame.
Cause 2 is addressed by #521, and my earlier reading of it here was wrong.
The constraint is real — DirectChunkRunCoalescer must flush the absorbed
direct run before a chunk that composites from cache (render_paths.rs:4338-4361)
or painter's order breaks — but I concluded from it that the pass count could
not be attacked without first reducing the layer count. That assumed the flush
had to be unconditional. It does not. #521's flush_pending_queues_for_backdrop_capture flushes only when a pending
composite's written region (dest_quad ∩ scissor) actually intersects the
capture's dependency rect, plus the pending-clear case, and pins the ordering
failure that narrowing could cause with a test where one glass depends on
another's composited output. Cause 2 is fixed on its own, not gated behind
cause 3.
The layers have been attributed, and it rules out the caching fix. Per-layer gpu_stats output over five scrolled frames splits them into 4 fixed chrome
layers (top bar, search field, a 139x126 element, one 44x44) and 1-4 transient
per-row 44x44 layers. By area the fixed chrome is 89 % of the isolated-layer
total — and the fixed layers are the ones whose backdrop is the scrolling
list, so their capture_rect is constant over genuinely different pixels every
frame. They cannot be cached by any strategy. The cacheable moving-glass
case is the remaining ~10 %.
So the critical path is making each per-frame blur cheaper — downsample
before blurring, fewer taps, smaller capture region, or reusing the previous
frame's blur under a small scroll delta — not a shared background texture.
Shared-background-blur remains correct for moving glass and is worth having,
but it is a tenth of this page. Cause 1 (#504) is real, separate, and gates
neither.
#521 attacks exactly this (cheaper per-pixel glass plus the pass batching
above). Unlike #497, it should move fps: it targets present 13.5 ms and render 8.1 ms directly, against the ~3.4 ms this page needs to shed. That
makes it worth measuring on device rather than assuming — in both
directions. Method that this issue's own history argues for: same package,
app settled at 0-3 % CPU, judge only idle_iters≈0 windows, and carry an
untouched stage as a within-run control against CPU clock drift.
Two traps recorded in the comments before anyone starts: the cache key's local_bounds_bits field is fed a screen-space rect for backdrop effects, so
making the key translation-invariant ships a stale backdrop; and the per-layer
diagnostic needed to redo this attribution already exists in gpu_stats
(gpu_stats.rs:333) — it does not need building.
Targets (same device, same scenes)
scene_ms ≈ 0 during pure scroll (rigid translation, no re-emit)
passes in the single digits per frame
layer-cache scroll hit rate ≈ 100 % on the cranscan-style page
both scenes at 60 fps with headroom
Notes
This is the "retained translated/offscreen surface reuse for moving scroll content" / surface-planner work already flagged in TIME_WASTERS.md and docs/render_arch.md — now with concrete on-device numbers.
Measurement traps. Wait for the app's own CPU to settle and print what it settled at before trusting any frame number; an app doing background inference, indexing, or sync will be measured as framework cost. Never A/B across two packages — install both arms over the same package, or flip arms inside one binary with a runtime override. Judge only idle_iters≈0 windows, and use single long input swipe drags. SurfaceFlinger shows steady 60 fps presents even while content is janky.
Related but separate observations from the same session: app-startup windows show 1.9–5.5 s stalls, and acquire p90 spikes to 7–9 ms on some windows.
Summary
With the accessibility bridge fixed (#497), scroll on a Huawei EVR-AL00 (Kirin 980, Mali-G76, 60 Hz, 1080×2244) is still far from 60 fps on glass-heavy pages. Measured with
debug.cranpose.frame_telemetry,debug.cranpose.frame_stage_ms, anddebug.cranpose.gpu_statson device.cranscan Library page (receipt cards + liquid-glass headers), quiet device, clean
idle_iters=0window: 28.2 fpsNote that passes (35) and isolated layers (9) exceed the originally filed ranges (23–31 and 6–8). That part of the problem was understated, not overstated.
Demo Liquid tab (framework build incl. #497): 35–37 fps
frame-stage-telemetryshows scene_ms 6.4–7.4 and layout 2.5–3.5What #497 does and does not buy
#497 removes the per-frame accessibility publish. On a same-package, same-data,
same-thermal-state A/B it takes
syncp50 from 5.12/5.16 ms to 0.00 ms.It does not improve frame rate, and should not be described as a perf fix:
That is one noise band. Stage sums explain why: ~22.4 ms → ~19.4 ms against a
~16.1 ms vsync budget. The cut is real, but the frame misses the same vsync
either way. You do not get paid until you cross 16 ms. #497 is worth landing as
a correctness and battery fix — publishing an accessibility tree every frame
when nothing is listening is pure waste — but the fps work is entirely below.
Ranked causes
motion_context_animated/translated_content_offsetonLayerNode), but the partial scene update path re-emits per frame. (Scope a lazy-list scroll to its subtree instead of rebuilding the whole scene #504 scopes the lazy-list case; cranscan's Library is aLazyColumn, so it is exercised by this scene.)DirectChunkRunCoalescerexists for exactly this reason but glass pages defeat it.Cause 2 is addressed by #521, and my earlier reading of it here was wrong.
The constraint is real —
DirectChunkRunCoalescermust flush the absorbeddirect run before a chunk that composites from cache (
render_paths.rs:4338-4361)or painter's order breaks — but I concluded from it that the pass count could
not be attacked without first reducing the layer count. That assumed the flush
had to be unconditional. It does not. #521's
flush_pending_queues_for_backdrop_captureflushes only when a pendingcomposite's written region (
dest_quad∩scissor) actually intersects thecapture's dependency rect, plus the pending-clear case, and pins the ordering
failure that narrowing could cause with a test where one glass depends on
another's composited output. Cause 2 is fixed on its own, not gated behind
cause 3.
The layers have been attributed, and it rules out the caching fix. Per-layer
gpu_statsoutput over five scrolled frames splits them into 4 fixed chromelayers (top bar, search field, a 139x126 element, one 44x44) and 1-4 transient
per-row 44x44 layers. By area the fixed chrome is 89 % of the isolated-layer
total — and the fixed layers are the ones whose backdrop is the scrolling
list, so their
capture_rectis constant over genuinely different pixels everyframe. They cannot be cached by any strategy. The cacheable moving-glass
case is the remaining ~10 %.
So the critical path is making each per-frame blur cheaper — downsample
before blurring, fewer taps, smaller capture region, or reusing the previous
frame's blur under a small scroll delta — not a shared background texture.
Shared-background-blur remains correct for moving glass and is worth having,
but it is a tenth of this page. Cause 1 (#504) is real, separate, and gates
neither.
#521 attacks exactly this (cheaper per-pixel glass plus the pass batching
above). Unlike #497, it should move fps: it targets
present13.5 ms andrender8.1 ms directly, against the ~3.4 ms this page needs to shed. Thatmakes it worth measuring on device rather than assuming — in both
directions. Method that this issue's own history argues for: same package,
app settled at 0-3 % CPU, judge only
idle_iters≈0windows, and carry anuntouched stage as a within-run control against CPU clock drift.
Two traps recorded in the comments before anyone starts: the cache key's
local_bounds_bitsfield is fed a screen-space rect for backdrop effects, somaking the key translation-invariant ships a stale backdrop; and the per-layer
diagnostic needed to redo this attribution already exists in
gpu_stats(
gpu_stats.rs:333) — it does not need building.Targets (same device, same scenes)
Notes
idle_iters≈0windows, and use single longinput swipedrags. SurfaceFlinger shows steady 60 fps presents even while content is janky.acquirep90 spikes to 7–9 ms on some windows.🤖 Generated with Claude Code