gallery/docs/BUGS/slint-skia-final-flush-jank.md
Greg Pomerantz 93b27d724b Root-cause and fix the Mali scroll jank; strip jank-hunt instrumentation
The 19-26 ms mid-scroll stalls (Mali-only, fixed content offsets) were
the Skia renderer's final synchronous GrDirectContext::flush() blocking
the UI thread on Mali driver back-pressure — proven by per-stage
timestamps in the vendored renderer (stall frames: flush 19-24 ms,
walk 1-3 ms, 1 binding re-eval, image imports 0.1 ms each).

- vendor/i-slint-renderer-skia (pinned 1.18.1, [patch.crates-io]):
  remove that one flush. Redundant on the wgpu backend: the surface
  already calls flush_and_submit before queue.present, same Vulkan
  queue, in-order. Phone: 0 stalls across 4 runs (max 3.6-7.4 ms vs
  the 8.3 ms budget), display verified clean.
- docs/BUGS/slint-skia-final-flush-jank.md: full evidence, prior art
  (slint-ui/slint#12030 closest), open question for a future
  upstream filing (not filed yet).
- Profiling also retired the O(n)-walk theory: Slint 1.18 prunes the
  item walk to ~133-152 items; the windowed Flickable built on that
  theory was already reverted (ListView is smooth with the fix).
- Strip all jank-hunt instrumentation: FR/SCR/TICK/APLYD/STG logging,
  stg markers, .gallery_* file knobs, jsamp thread sampler,
  DbgScroll programmatic scroll, nblog perf-file mirroring.
  Also fixes the 'launches into unstoppable fast scroll' bug: a
  leftover .gallery_cmd was re-read on every launch (file never
  deleted) and overrode user swipes — the hook is gone.
- Keep: nblog (logd backpressure is real), zero-dimension decode
  guards, keep-alive (default-off).
- HANDOFF: replace stale windowed-Flickable section with the actual
  root cause/fix; 15 unit tests pass.

Verified on phone (aarch64, 8580 photos) and emulator (x86_64).
2026-10-28 18:23:50 -04:00

5.3 KiB
Raw Permalink Blame History

Slint 1.18 Skia renderer: redundant per-frame final flush() causes scroll jank on Mali

Status: worked around locally (vendored one-line deletion, see below). Upstream issue: NOT filed as of 2026-10-28. Not found in upstream issues at research time (see "Prior art" below). Re-visit filing if the workaround must be maintained across Slint upgrades.

Symptom

Pixel 9 Pro (Tensor G4, Mali G715, 120 Hz), 8,580-item ListView grid of GPU-resident wgpu textures: 2–4 deterministic scroll stalls of 19–26 ms at fixed content offsets during scrolling. Same Slint code on an x86_64 emulator (SwiftShader Vulkan): zero stalls.

Root cause (proven by stage-timestamp profiling)

The Skia renderer's render_components_to_canvas ends with a CPU-synchronous

if let Some(ctx) = gr_context.as_mut() {
    ctx.flush(None);          // GrDirectContext::flush
}

(stage FL2 in the profiling below). Steady-state cost: 0.3–0.8 ms. When the Mali driver's queue is momentarily busy — new texture uploads landing (queue.write_texture copies), first-draw pipeline state, swapchain pressure — that call blocks the UI thread 19.2–20.4 ms, producing the visible stall. SwiftShader's flush() returns instantly, which is why the emulator never reproduced the bug and every CPU-side hypothesis (item walk, binding re-evaluation, image import, geometry, worker writes) failed.

Per-stage frame data (Mali G715, programmatic scroll)

Stages: ACQ = get_current_texture, MKS = swapchain surface create, DC = draw_contents, EV = render callback, RCI = item walk + draws, PD = partial-renderer dirty region, FL1 = pre-BeforeRendering flush (active only while our rendering notifier was set), FL2 = the final flush, SUB = flush_and_submit (Skia submit()), PRE = queue.present.

steady frames (y < -2000):  SR=4-8ms   DC=2-3ms  RCI=1-2ms  FL2=0.3-0.8ms  ev=1  IMG=0-3
stall frames:
  y=-574.5 :  SR=23.9  DC=22.1  RCI=1.0  FL2=20.4  IMG=0
  y=-3543  :  SR=23.4  DC=21.2  RCI=1.0  FL2=19.8  IMG=0
  y=-4347  :  SR=24.3  DC=21.9  RCI=2.0  FL2=19.2  IMG=3 (400x301, 301x400, 400x301)

Also ruled out by the same data (all small on stall frames): item walk (RCI 1–2 ms; Slint 1.18 prunes the walk to ~133–152 visited items, not O(rows) — an earlier O(n) walk diagnosis was wrong), binding re-evaluations (1/frame), new Skia image creation (0.0–0.1 ms each), partial renderer (0 ms), swapchain acquire (0.2 ms), submit (0.3 ms), present (0.6 ms).

A second, smaller stall class (22 ms between stage markers, once per run) was wgpu driver back-pressure on a vkQueueSubmit (the "Slint frame init" clear pass or the texture-transition submit); it disappeared with the fix and never reappeared in verification runs.

Why the flush is redundant (for the wgpu backend)

Frame order in i-slint-renderer-skia/wgpu_30_surface.rs::render:

  1. clear pass (wgpu encoder) + submit
  2. callback(...) → Skia draws the frame (commands pending in Skia)
  3. flush_and_submit(gr_context) → gr_context.submit(None) — submits all of Skia's pending commands to the same Vulkan queue
  4. queue.present(frame) → vkQueuePresentKHR on the same device/queue

Vulkan guarantees in-order execution on a single queue, so the present cannot run ahead of Skia's work. The final GrDirectContext::flush() therefore adds no ordering guarantees — only a CPU-side wait that costs 19–24 ms on Mali. (FL1, the pre-BeforeRendering flush, exists to make the window-background clear visible to BeforeRendering callbacks; it is only active while a rendering notifier is registered and was left in place.)

Local workaround

vendor/i-slint-renderer-skia (pinned 1.18.1, [patch.crates-io] in Cargo.toml) with that final flush removed (see the "jank fix" comment in lib.rs::render_components_to_canvas). The patch set is exactly this one hunk; the rest of vendor/i-slint-renderer-skia is byte-identical to the 1.18.1 registry source.

Verification on the Pixel 9 Pro (post-fix):

test before after
slow scroll 5 s (×3 runs) 2–4 stalls, 20–26 ms 0 stalls, max 3.6 / 5.8 / 3.9 ms
fast fling 8× velocity stalls 0 stalls, max 7.4 ms, p50 1.0 ms
display — clean (screenshot-verified, no artifacts)

Frame budget at 120 Hz is 8.3 ms.

Open question before filing upstream

We cannot rule out that the final flush exists to bound GPU-side resource lifetime or to serialize against the (non-wgpu) backends that share render_components_to_canvas (OpenGL/EGL, where the Skia context and the swapchain may be on different queues). If filing, propose making the flush backend-gated or replacing it with an async submit, and cite the data above.

Prior art (adjacent, not identical)

  • slint-ui/slint#12030 (open): rendering notifier + FemtoVG causes per-frame flush → pipeline cache cleared → pipeline recreation every frame. Same class of problem (per-frame synchronous flush interacting with GPU pipeline state), different backend and mechanism.
  • slint-ui/slint#11119 (open): request for a debugging tool to diagnose performance issues (this bug was found with ad-hoc stage timestamps in the vendored crates).
  • slint-ui/slint#3867 (open): accessibility feature perf impact on big listviews — checked and ruled out for this app (accessibility off).