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).
115 lines
5.3 KiB
Markdown
115 lines
5.3 KiB
Markdown
# 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**
|
||
|
||
```rust
|
||
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).
|