Slint-on-Mali was conclusively janky at its base level (sltest: 0.33-0.83%
slot drops on a scene with no app logic); egui on the same phone measured
3-7x cleaner (egtest: 0.12%, same referee/scene).
Architecture (see HANDOFF §8.11-8.12):
- winit 0.30 ApplicationHandler on android-activity 0.6, raw EGL (ES3),
egui_glow::EguiGlow. winit 0.30 never delivers RedrawRequested on
Android → the frame driver is app-owned: World::tick runs every loop
iteration from about_to_wait (liveness), render when dirty or
animating, park ~8 ms (one vblank) at rest.
- Virtualized grid painter (3 cols, visible rows ±4), GPU-resident
textures, turbojpeg worker pool decoding off-thread.
- CRITICAL pixel-pipeline fix: RGB8→Vec<Color32> conversion happens on
the WORKER (egui 0.31's from_rgba_unmultiplied is O(n) per-pixel,
8-60 ms/2 MB on the UI thread → 9.57% drops + phone OOM crash).
UI thread only MOVES the vector into ColorImage.
- Fling: per-event EMA velocity (frame-based dies at slow touch sampling),
clamp 14000 px/s; decay exp(-7.42 dt).
- Painter::image uv is NORMALIZED (0..1) — a screen-space uv made every
cell a solid rectangle (the emulator's solid-color test images hid it).
- Memory caps for a personal phone: full cache 6 entries / 16 MP.
Verified on emulator: grid, scroll, fling, viewer, back-key. Phone
deploy: thumbnails render, 8580 files. Pending: user finger test +
fr_capture phone run (per-pixel fix expected to cut render cost
18.8 ms → <8.3 ms).
Deletes: vendor/ (instrumented Slint tree), shim/, build.rs, gputex,
vkshim, baseline60, main.slint — the Slint zero-jank campaign is
concluded in docs/BUGS/.
shim: 512 for aarch64-linux-android
Using package `gallery` in `/mnt/Backup/prog/android/gallery/Cargo.toml`
Compiling apk resources...
Linking apk resources...
Optimizing apk resources...
Compiling Java sources...
Creating classes.jar from compiled Java sources...
'classes.dex'...
'lib/arm64-v8a/libgallery.so'...
Verifying alignment of /mnt/Backup/prog/android/gallery/target/release/apk/gallery.apk (4)...
49 AndroidManifest.xml (OK - compressed)
1072 resources.arsc (OK)
1153 classes.dex (OK - compressed)
1635 lib/arm64-v8a/libgallery.so (OK - compressed)
Verification succesful
Signing `/mnt/Backup/prog/android/gallery/target/release/apk/gallery.apk` with keystore `/home/gmp/.android/debug.keystore`
APK: target/release/apk/gallery.apk (512) strips the [patch.crates-io] section so
the whole Slint stack (renderer, backend, core, wgpu-hal) resolves to
the stock 1.18.1 sources: no render-ahead, no vsync-locked render, no
app-owned frame driver, no wake-fd trampoline, no vkshim, no perf
instrumentation. All vendor-internal references in the app are now
#[cfg(feature = "perf")]-gated so the stock build is genuinely stock.
Purpose: the CONTROL experiment for the 120Hz zero-jank campaign.
If the grid still janks on the phone at 60 Hz with this build, the
jank lives in the app / GPU pipeline (drain tick, texture uploads,
model updates), not in the vendored fix stack.
60 Hz lock: NOT via JNI — the public API turned out to be
unavailable (Display.setRefreshRate is hidden-API-blocked;
WindowManagerImpl lacks setPreferredDisplayRefreshRate on both
tested devices). Instead the display is locked with the device's
own user setting:
(restored after the test). The baseline60 feature is a pure
marker; the build is otherwise identical to the app code.
Verified on emulator (x86_64, 60 Hz, SwiftShader): launch, grid
render, thumbnail fill, scroll + prefetch all work.
The slow pop-in (warm and cold) had two root causes, both in the
drain tick / scan pipeline:
1. Paint-cap drop bug: finished decodes beyond MAX_PAINT_CELLS_PER_TICK
were silently dropped (out of inflight, not in painted) so FILL
re-submitted them — the same cell re-decoded 3-6x per viewport and
a new viewport took 500-850ms to fill after a flick. Now excess
results hold in app.ready (bounded READY_CAP=48) and paint on a
later tick at zero re-decode cost; FILL skips ready_paths. The cap
is adaptive: 2 during grid motion (frame budget), 4 when settled
(fast pop-in).
2. Cold-start scan storm: every 512-entry scan batch re-sorted the
WHOLE list and set_list did a full model reset + generation bump,
orphaning every queued decode (arrived marked stale, dropped,
re-submitted) — a 17x re-decode storm (max observed dups = 18 =
batch count) plus full row churn per batch. Streaming now appends
(each 512 group mtime-sorted, groups concatenated = stable prefix),
and set_list detects appends (prefix match) to extend the model
in place (extend_from_slice) with NO reset/generation-bump. The
final global sort is ONE reset at scan end; viewport re-fills from
the disk cache (~2ms/cell). Measured: scan 7637ms -> ~4.3-4.9s,
decode dups 18 -> 2-3.
Window black after pause (regression found while testing): the OS can
invalidate our surface (system dialog, app switch) with NO window
event and no pending model change -> the window returned black and
stayed black (nothing ever set pending_redraw). Fixes:
- request_redraw() unconditionally in RenderingSetup
- drain-tick watchdog: window visible but no frame completed for
>2.5s -> one forced redraw (LAST_PRESENT_US, updated in
AfterRendering; now_us/NOW_T0 no longer perf-gated)
Also: debuggable=false — the system 16KB-compat dialog (debuggable
apps only) is its own activity, it paused us on every cold launch,
and its inconsistent lifecycle (TermWin or not) was the main black-
window trigger in testing.
Phone-verified (cold, 8580 files, fresh DB): clean launch, grid
streams in with thumbnails, flicks render 120Hz (median 8.3-8.5ms,
0-2 late frames), viewport fully painted after flick, grid intact
after an app switch (no black window).
Three root causes, all fixed and verified on the Pixel 9 Pro:
1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the
render to the vsync wake; RENDER_AHEAD presents the previous frame at
the start of render() (phase = wake + ~1.1 ms) and holds the new
frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never
merely dropped, before the next acquire (AlreadyAcquired panic at
launch otherwise); discard_pending_frame presents too.
2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled
through a ~20-cell viewport forever (137 decodes/20 s) and each
eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes,
~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone
cooldown on evicted paths (idle prefetch skips, urgent tap bypasses)
+ off-thread reaper for the freed blobs (insert: 21 ms -> 4 us).
3. (earlier) Skia final flush + Mailbox present mode (vendor, M3).
Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK
lines for cross-thread closure timing and push-site tagging.
Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact
120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero
>33 ms); PRES phase median ~994 us, zero late frames. Docs updated
(docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference).
Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch),
vkshim profiling tooling, backend made a non-optional dep, debuggable
flag for simpleperf (REMOVE before shipping).
Residual scroll jank at 120 Hz (suspected Slint event-loop bug) needs
per-frame + per-stage timing the logcat ring buffer can't hold. New
cargo feature `perf` (default OFF — all sites are cfg-gated, zero
overhead when off):
- FR: per-frame app-side render cost + frame gap, from the rendering
notifier (BeforeRendering/AfterRendering).
- STG: event-loop stage costs (wake/timers/events/poll/lastrender) from
timestamps instrumented into the pinned vendor copy of
i-slint-backend-android-activity (stg.rs; Cargo.toml now depends on
it directly + optional under `perf`).
- DECODE: per-thumbnail-job cost on the workers.
- All lines mirrored to /sdcard/Download/.gallery_perf.log (logcat
rotates too fast at ~120 lines/s).
Format + reading guide: docs/BUGS/scroll-jank-120hz.md.
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).
Root cause of scroll jank: first-present of each Image uploaded pixels
inside the render frame (~11-30 ms per 400px texture, 1.3 visible
hitches/s at 120 Hz; grey-image A/B: 20 -> 1). Slint has no render
thread and no pre-upload API.
Fix: decode worker creates the Rgba8Unorm texture on Slint's own wgpu
device (captured from GraphicsAPI::WGPU30 at RenderingSetup) and submits
the async upload; UI wraps it in Image::try_from(tex). Zero pixel work
in the render frame. CPU from_rgba8 fallback retained.
Measured (Pixel 9 Pro 120 Hz, 400 px, per-frame FR hook):
before: >10ms renders 20 in 15s (max 30 ms), 3 borderline 8-9 ms
after: 0 mid-scroll renders > 4.4 ms (median 1.9, p95 2.9);
only post-idle first-frames expensive (invisible)
Also: FR per-frame logging via set_rendering_notifier; APLY timestamp.
Slint's android-activity backend wakes the main loop from a dedicated
"slint vsync" Choreographer thread while any animation is active
(i-slint-backend-android-activity/vsync.rs). Android schedules that
thread at nice +10 (lowest on device), while our 4 turbojpeg workers
inherited the UI thread's nice -10. Under CPU bursts the +10 waker
starves and the main loop's next wakeup lands 3-4 frames late: 25-33 ms
of pure wake latency at 120 Hz, with zero cost in our tick (d=7-34 us) —
exactly the phone stall signature (measured: 28-39 ms gaps, catch-up
events).
- prio module: setpriority FFI (self + by-tid), raise_slint_vsync()
- thumb workers + dispatcher: nice +2 at spawn (ahead of background
apps, behind the UI's -10)
- android_main: prio-fix thread raises "slint vsync" to nice 0 with a
readback log
- verified on emulator: thumb-* hold nice 2, slint vsync holds 0 while
foreground (Android re-nices backgrounded processes — expected)
- emulator scroll cadence unchanged (llvmpipe rasterization ceiling,
not a priority issue) — phone is the acceptance target, A/B test
planned for when it is back online
Also: drop unused android_logger dependency (nblog replaced it).
- scan.rs: recursive walk of Pictures/DCIM (GALLERY_SCAN_DIRS override),
image-ext detection, mtime/size capture, no symlink-dir following
- db.rs: rusqlite(bundled) store; incremental apply (insert/update/delete)
keyed on path with mtime/size change detection; derived columns reset on
change; Android DB path in Android/data/<pkg>/files (EXTERNAL_DATA_DIR is
unset in this NativeActivity setup)
- lib.rs: start_scan() background thread -> invoke_from_event_loop ->
VecModel + status line; called from desktop and Android entry points
- main.slint: ListView of paths (M1), status line with scan stats
- main.rs: fix desktop bin for 1.18.1 (no slint::Result; ComponentHandle)
- build.sh: export NDK clang CC_/CXX_/AR_ per target (bundled sqlite)
- tests: 5k-file scan < 2s, incremental apply, roundtrip
Verified on emulator: 5002 files in 153-226 ms; relaunch 0+ 0~ 0-;
touch -> 1~, delete -> 1-.