# Scroll Jank Investigation & Fix (120Hz Android / Mali G715) ## Overview During M3/M4 development, profiling on physical 120 Hz hardware (Pixel 9 Pro, Mali G715, Android 17, 1280×2856, `falsePrerotation=1`) revealed periodic mid-scroll frame gaps (17–55 ms against the 8.33 ms budget). Stage-timestamp instrumentation (`STG`/`IT`/`FR`/`VK`/`PRES`/ `SCN`/`OTHR`/`FLCK`, all behind `--features perf`) isolated **three** distinct root causes. All three are fixed; the app is verified on the phone. **Final scorecard (warm, measured 4.5 s mid-screen swipe, 8 580 files grid):** | metric | baseline (M3) | RENDER_AHEAD | + full-cache fix (final) | |-------------------------------|---------------|--------------|--------------------------| | frame cadence median | 8.67 ms (judder) | 8.34 ms | **8.33 ms (exact 120 Hz)** | | cadence > 16.7 ms (2+ slots) | ~6 % | ~6 % | **0.9 % (5/552)** | | worst gap in swipe | 55 ms | 292 ms¹ | **28.3 ms** | | PRES phase median (vs vsync) | 6 509 µs | 1 992 µs | **~994 µs** | | PRES > 8 330 µs (late) | 100 % | 0 % | **0 %** | | slow `Event::Other` spikes | — | ~40 / 20 s | **2 (launch only)** | ¹ single outlier = app background/foreground transition. --- ## 1. Root cause #1 — phase: present landed after the compositor deadline **Symptom:** every rendered frame was shown one vsync late (effective ~60 Hz + judder) even though each render only took 4–8 ms. **Cause:** the render was triggered by input events at an arbitrary phase inside the 8.33 ms slot; the present then landed past the compositor's ~5.5 ms deadline, so the frame could only be scanned out on the *next* slot. **Fix (two parts, both in the vendored backend/renderer):** 1. **VSYNC_LOCKED_RENDER** (`vendor/i-slint-backend-android-activity-1.18.1/lib.rs`): while animating (active animations *or* pointer active within 200 ms — Slint has no fling physics, so a direct drag sets no animation), input events only mark the window dirty; the render happens on the next vsync **wake**, giving a deterministic present phase. 2. **RENDER_AHEAD** (`vendor/i-slint-renderer-skia/wgpu_30_surface.rs`): present the frame prepared by the *previous* `render()` at the start of the current one (phase = wake + ~1.1 ms), then prepare the next frame without presenting. One slot of constant latency; the present timing is fully decoupled from the render duration. Activated per frame (`RENDER_AHEAD_ACTIVE`): only locked (animating) renders use the hold-without-present pipeline; idle/model-update renders present immediately. **Crash fixed along the way — wgpu `AlreadyAcquired`:** wgpu's `Presentation` tracks the acquired swapchain image independently of the Rust `Arc`. The legacy (unlocked) path used to *drop* a held frame without presenting it; the drop does not clear the Presentation, so the next `get_current_texture` panicked with "Surface image is already acquired" (killed the window at launch). **Rule: a held frame must be PRESENTED, never merely dropped, before the next acquire.** `discard_pending_frame()` (resize/reconfigure) therefore presents too — the compositor absorbs the one-frame size change. --- ## 2. Root cause #2 — Skia final flush (fixed earlier, M3) Slint's default Skia renderer issued a synchronous `GrDirectContext::flush(None)` at the end of every frame; on Mali G715 that blocked the CPU 19–24 ms. Removed in vendored `i-slint-renderer-skia/lib.rs` (see `docs/BUGS/slint-skia-final-flush-jank.md`). --- ## 3. Root cause #3 — full-res prefetch churn: freeing 50 MB on the UI thread **Symptom:** after #1 and #2, the gallery still showed ~40 CPU spikes of 9–43 ms per 20 s (≈7/s) — `SLOWEV NonMain` with `cpu ≈ wall`, i.e. pure CPU inside `window.process_event(Wake)`, `render=0`. Sltest (no gallery code) does not show these, so the cause was app-side. **Localization:** `OTHR` (timing of `Event::Other` closures drained in `process_event` — the `invoke_from_event_loop` queue) + `INVK_TAG` (push-site tags) showed **106 of 112 slow closures were the full-load-arrival closure** (median 21 ms CPU), plus 9 `set_status` mislabeled by off-by-one (all fullload on re-correlation). **Mechanism (two compounding bugs in the M4.1 viewer prefetch):** 1. **Churn loop.** `prefetch_full` (drain tick) idly decodes full-res images for visible cells, cached in an 8-slot / 64 Mpx LRU (`FullCache`). The viewport holds ~20 cells ⇒ every insert evicts, and the evicted path is visible again ⇒ immediately re-prefetched. Steady state: decode → evict → re-decode forever — **137 full decodes in 20 s** with the viewer closed. 2. **Eviction freed on the UI thread.** Each evicted entry is a 50 MB-class `Vec`. `FullCache::insert` evicted (and thus `free()`d) the old blob synchronously on the UI thread: **20–52 ms of CPU per eviction** (`FLCK ins_us`: median 21.4 ms when evicting, 4 µs when not — perfect correlation with the OTHR spikes). **Fix (`src/lib.rs`):** 1. **Tombstone cooldown** — `full_evicted_recent` (path → time). Idle prefetch skips paths evicted within `EVICT_COOLDOWN` (60 s); a tap (`urgent`) clears the tombstone and proceeds. Churn drops from 137 decodes/20 s to ~11 (initial cache fill only). 2. **Off-thread reaper** — `FullCache::insert` now *returns* the evicted `(path, rgba)` pairs; the UI thread moves the blobs (a pointer swap) into `REAPER_TX`; a `nice`-style low-priority `full-reaper` thread drops them at 500 ms cadence. Insert cost: **median 4 µs** (was 21 ms). 3. Tombstones cleared on list reset / world teardown with the other full-cache state. **Verification:** `FLCK ins_us` median 4 µs / max 17 µs; OTHR slow closures 122 → 2 (launch set_list warm-up only); SLOWEV > 8 ms: 40 → 3 in 20 s; swipe cadence as in the scorecard. --- ## 4. Residual (accepted / open) - **~0.9 % two-slot gaps (16.8–17.7 ms)** and a single 28.3 ms gap in a warm swipe. The 28.3 ms case is one 8.3 ms render with `SKD = 4.5 ms` (`VK SKD max=57 ms` over the run) — Mali GPU cache miss on freshly uploaded textures; renders otherwise 2–4 ms. - Mali G715 sits at 150–419 MHz during scroll (low duty cycle; no root to force the 940 MHz max). A faster clock would shrink SKD variance. - `dumpsys gfxinfo` is blind to Slint's NativeActivity window; Perfetto FrameTimeline cannot attribute app-side jank here — all numbers in this doc come from the app-side perf log. ## 5. Instrumentation reference (all `--features perf`) | line | source | meaning | |--------|--------|---------| | `IT` | backend event loop | per-iteration: poll/first-ev/timers/ev/render/anim + LV/Flickable/input/timeout/wake counters | | `STG` | backend event loop | iteration summary (wake, poll, events, lastrender) | | `FR` | window rendering notifier | per rendered frame: cumulative ms, draw µs, gap ms | | `VSYNC`| vsync callback | frame time + monotonic | | `PRES` | renderer surface | presented frame idx, monotonic, present µs (phase vs `VSYNC mon`) | | `VK` | renderer (vkprobe) | per-stage µs: ACQ/CLR/SKD/SKF/ORD/PRE | | `VKU` | `src/gputex.rs` | thumb texture upload: create µs + write µs | | `SCN` | renderer (scn) | scene op census per frame | | `SLOWEV` | backend | event > 5 ms with wall vs CPU time | | `OTHR` | backend `process_event` | slow `invoke_from_event_loop` closure (wall + CPU) | | `INVK` / `INVK_TAG` | backend / app | push site of every cross-thread closure | | `FLCK` | app full-load closure | full-cache insert: size, LRU before/after, insert µs |