Skip to content

Scroll on 2018-class Android is scene-update and render-pass bound; needs retained translated-surface reuse and pass coalescing #500

Description

@samoylenkodmitry

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, 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.

cranscan Library page (receipt cards + liquid-glass headers), quiet device, clean idle_iters=0 window: 28.2 fps

  • period p50 = 34.9 ms (~2 vsyncs)
  • present p50 = 13.5 ms — swapchain blocking on the GPU (filed as 29–33 ms under OCR load)
  • render (CPU encode) p50 = 8.1 ms (filed as 12–13 ms)
  • update p50 = 4.7–5.3 ms; a11y sync p50 = 5.5–5.6 ms (gone after Gate the Android accessibility publish on assistive-tech state and batch it at Compose parity #497)
  • 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.

Demo Liquid tab (framework build incl. #497): 35–37 fps

  • period p50 ≈ 26 ms, cpu p50 ≈ 25 ms
  • update p50 = 10.4 ms, of which frame-stage-telemetry shows scene_ms 6.4–7.4 and layout 2.5–3.5
  • render p50 ≈ 6 ms encoding 30–39 passes/frame, present p50 ≈ 7.5 ms
  • shadow cache and glyph atlas: 100 % hits (not the problem)

What #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 sync p50 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:

baseline rep1  fps 41.60   sync p50 5.12
baseline rep2  fps 40.34   sync p50 5.16
patched        fps 40.55   sync p50 0.00

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

  1. 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.)
  2. 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.
  3. 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_quadscissor) 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.

🤖 Generated with Claude Code

Metadata

Metadata

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions