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).
5.3 KiB
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:
- clear pass (wgpu encoder) + submit
callback(...)→ Skia draws the frame (commands pending in Skia)flush_and_submit(gr_context)→gr_context.submit(None)— submits all of Skia's pending commands to the same Vulkan queuequeue.present(frame)→vkQueuePresentKHRon 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):
accessibilityfeature perf impact on big listviews — checked and ruled out for this app (accessibility off).