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

115 lines
5.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).