From ed52109dba115942539fd9a93d1816c7f66be91d Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Wed, 28 Oct 2026 10:57:15 -0400 Subject: [PATCH] GPU-resident thumbnails: worker uploads to Slint's wgpu device (gputex) 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. --- Cargo.lock | 104 +++++++++++++++++++++-- Cargo.toml | 9 +- HANDOFF.md | 26 ++++++ scripts/build.sh | 2 +- scripts/jank_ab.sh | 27 +++--- src/gputex.rs | 97 +++++++++++++++++++++ src/images.rs | 21 ++++- src/lib.rs | 205 ++++++++++++++++++++++++++++++++++++++------- src/thumb.rs | 35 ++++++-- 9 files changed, 472 insertions(+), 54 deletions(-) create mode 100644 src/gputex.rs diff --git a/Cargo.lock b/Cargo.lock index fcffd35..26bd549 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -784,6 +784,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ + "serde", + "termcolor", "unicode-width", ] @@ -1102,6 +1104,21 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "drm" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a41816e58f47f49acfd956651055ddcf137c4882c2098c30c448817af21183a" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "bytemuck_derive", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 1.1.6", +] + [[package]] name = "drm-ffi" version = "0.9.1" @@ -1254,7 +1271,7 @@ dependencies = [ "bitflags 2.13.2", "bytemuck", "fnv", - "glow", + "glow 0.18.0", "imgref", "itertools 0.15.0", "log", @@ -1263,6 +1280,7 @@ dependencies = [ "swash", "wasm-bindgen", "web-sys", + "wgpu", ] [[package]] @@ -1544,7 +1562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" dependencies = [ "bitflags 2.13.2", - "drm", + "drm 0.14.1", "drm-fourcc", "gbm-sys", "libc", @@ -1625,6 +1643,18 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "glow" version = "0.18.0" @@ -1856,18 +1886,20 @@ dependencies = [ "bytemuck", "calloop 0.14.5", "cfg_aliases", - "drm", + "drm 0.14.1", "gbm", "glutin", "i-slint-common", "i-slint-core", "i-slint-renderer-femtovg", + "i-slint-renderer-skia", "i-slint-renderer-software", "input", "libseat", "memmap2", "nix", "raw-window-handle", + "wgpu", "xkbcommon", ] @@ -1887,6 +1919,7 @@ dependencies = [ "i-slint-core", "i-slint-core-macros", "i-slint-renderer-femtovg", + "i-slint-renderer-skia", ] [[package]] @@ -2074,7 +2107,7 @@ dependencies = [ "cfg-if", "const-field-offset", "femtovg", - "glow", + "glow 0.18.0", "i-slint-common", "i-slint-core", "i-slint-core-macros", @@ -2084,6 +2117,7 @@ dependencies = [ "rgb", "wasm-bindgen", "web-sys", + "wgpu", ] [[package]] @@ -2097,7 +2131,7 @@ dependencies = [ "cfg_aliases", "clru", "const-field-offset", - "glow", + "glow 0.18.0", "glutin", "i-slint-common", "i-slint-core", @@ -2576,6 +2610,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + [[package]] name = "khronos_api" version = "3.1.0" @@ -4267,6 +4312,7 @@ dependencies = [ "slint-macros", "unicode-segmentation", "vtable", + "wgpu", ] [[package]] @@ -4382,6 +4428,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "as-raw-xcb-connection", "bytemuck", + "drm 0.14.1", "fastrand", "js-sys", "memmap2", @@ -4586,6 +4633,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "text-size" version = "1.1.1" @@ -5277,12 +5333,18 @@ dependencies = [ "cfg_aliases", "document-features", "hashbrown 0.17.1", + "js-sys", "log", + "naga", + "parking_lot", "portable-atomic", "profiling", "raw-window-handle", "smallvec", "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "wgpu-core", "wgpu-hal", "wgpu-types", @@ -5315,6 +5377,8 @@ dependencies = [ "smallvec", "thiserror 2.0.21", "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -5330,6 +5394,24 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "30.0.1" @@ -5354,13 +5436,19 @@ dependencies = [ "bytemuck", "cfg-if", "cfg_aliases", + "drm 0.15.0", + "glow 0.17.0", + "glutin_wgl_sys", "gpu-allocator", "hashbrown 0.17.1", + "js-sys", + "khronos-egl", "libc", "libloading", "log", "naga", "naga-types", + "ndk-sys 0.6.0+11769913", "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", @@ -5380,10 +5468,14 @@ dependencies = [ "smallvec", "static_assertions", "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", "wgpu-naga-bridge", "wgpu-types", "windows", "windows-core", + "windows-result", ] [[package]] @@ -5404,10 +5496,12 @@ checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" dependencies = [ "bitflags 2.13.2", "bytemuck", + "js-sys", "log", "naga-types", "raw-window-handle", "static_assertions", + "web-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 03f05f9..7e9767c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,14 @@ edition = "2021" crate-type = ["cdylib", "lib"] [dependencies] -slint = { version = "1.18.1", features = ["backend-android-activity-06"] } +slint = { version = "1.18.1", features = [ + "backend-android-activity-06", + # Exposes `slint::wgpu_30` + `Image: TryFrom` + + # GraphicsAPI::WGPU30 — used by gputex to upload thumbnail textures + # off the UI thread (auto-enabled on Android already; explicit for + # host builds). + "unstable-wgpu-30", +] } log = "0.4" rusqlite = { version = "0.41.0", features = ["bundled"] } sha1 = "0.11.0" diff --git a/HANDOFF.md b/HANDOFF.md index 270342c..5ff1db1 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -606,3 +606,29 @@ phone (Pixel 9 Pro, 120 Hz). Emulator repro via programmatic scroll hook. - Emulator AVD: `pad_avd` (google_apis x86_64, API 35); start command in §3. - Gitea: push target `git+ssh://gitea@git.wow.st/gmp/gallery.git` (repo must exist on the server first, like the other gmp/* repos). + +### 8.7 GPU-resident thumbnails (jank fix, 2026-10-28) + +Root cause of the perceived scroll jank (measured with the per-frame +`set_rendering_notifier` hook, `FR` lines in `.gallery_perf.log`): every +**first present** of a new `Image` uploaded its pixels inside the render +frame — Slint's Skia backend has no RGB888 path (byte-by-byte conversion +on the UI thread) and no API to pre-upload; measured 11–30 ms per 400 px +texture = 1.3 visible hitches/s at 120 Hz. Grey-image A/B: 20 hitches/s → +1. Not a frame-rate, input, or layout issue; Fossify (RenderThread +pipelining) doesn't have this cost. + +Fix (no Slint patch, no re-architecture): the **decode worker creates the +texture on Slint's own wgpu device** (captured once from +`GraphicsAPI::WGPU30` at `RenderingSetup`, `src/gputex.rs`) and submits +the upload via `queue.write_texture` (async, no fence wait). UI thread +wraps it in `Image::try_from(texture)` (needs `Rgba8Unorm` + +`TEXTURE_BINDING|RENDER_ATTACHMENT`; `unstable-wgpu-30` slint feature; +wgpu/Vulkan `bytes_per_row` 256-multiple padding). Fallback to +`from_rgba8` CPU path when the device isn't captured yet. +Result (Pixel 9 Pro, 120 Hz, 400 px): **zero mid-scroll renders > 4.4 ms** +(median 1.9 ms, p95 2.9 ms); only post-idle first-frames are expensive +(invisible — screen static). + +Perf file: `/sdcard/Download/.gallery_perf.log` (SCR/TICK/APLY/FR). +Clear it by restarting the app (writer holds the fd). diff --git a/scripts/build.sh b/scripts/build.sh index 8921314..fa46996 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -65,7 +65,7 @@ esac profile_dir=release; [ -z "$profile" ] && profile_dir=debug # Optional: ADB_SERIAL picks the device when several are attached (see AGENTS.md). -adb=(); [ -n "$ADB_SERIAL" ] && adb=(adb -s "$ADB_SERIAL") +adb=(); [ -n "${ADB_SERIAL:-}" ] && adb=(adb -s "$ADB_SERIAL") cargo apk2 build $profile --target "$target" --lib apk=target/$profile_dir/apk/gallery.apk diff --git a/scripts/jank_ab.sh b/scripts/jank_ab.sh index fb0ffa2..fb93cec 100755 --- a/scripts/jank_ab.sh +++ b/scripts/jank_ab.sh @@ -22,32 +22,39 @@ APK=target/release/apk/gallery.apk PKG=wow.gmp.gallery CMD=/sdcard/Download/.gallery_cmd GREY=/sdcard/Download/.gallery_grey +PERF=/sdcard/Download/.gallery_perf.log [ -f "$APK" ] || { echo "build first: ./scripts/build.sh aarch64"; exit 1; } scroll_window() { # $1 = label, $2 = tag, $3 = seconds local out="/tmp/jank_$2.txt" - $E logcat -c + $E shell "echo -n '' > $PERF" 2>/dev/null || true echo "### $1 — scroll now (moderate drag, no fling)..." - $E logcat 2>/dev/null | grep -m 2000 "SCRDBG\|TICK \|APLY \|raised\|not found\|panic" > "$out" & - local LP=$! sleep "$3" - kill "$LP" 2>/dev/null + $E pull "$PERF" "$out" >/dev/null 2>&1 python3 - "$out" << 'EOF' import re, sys ev = [] for line in open(sys.argv[1], errors="ignore"): - m = re.search(r"SCRDBG Instant \{ tv_sec: (\d+), tv_nsec: (\d+) \}", line) - if m: ev.append(int(m.group(1))*1000 + int(m.group(2))/1e6) + m = re.match(r"SCR +([\d.]+)", line) + if m: ev.append(float(m.group(1))) if len(ev) < 10: print(f" {sys.argv[1]}: only {len(ev)} scroll events — not enough data"); sys.exit() gaps = [ev[i+1]-ev[i] for i in range(len(ev)-1)] -on_grid = sum(1 for g in gaps if 7.5 <= g <= 10.5) +# drop the first gap (window edge) and any multi-event same-ms catch-ups +# are kept — they ARE the jank signature +gaps = gaps[1:] +on_grid = sum(1 for g in gaps if 6.5 <= g <= 10.5) stalls = [g for g in gaps if g > 16] -print(f" events={len(ev)} on 8.3ms grid={100*on_grid/len(gaps):.0f}% " +import collections +buckets = collections.Counter() +for g in sorted(stalls): buckets['16-25' if g<25 else '25-40' if g<40 else '40-65' if g<65 else '>65'] += 1 +print(f" events={len(ev)} on ~8ms grid={100*on_grid/len(gaps):.0f}% " f"stalls(>16ms)={len(stalls)} p95={sorted(gaps)[int(len(gaps)*0.95)]:.1f}ms " - f"worst={max(gaps):.1f}ms") + f"worst={max(gaps):.1f}ms stall buckets: {dict(buckets)}") +# tick-stall context: how many TICK stalls, any APLY nearby EOF + echo " TICK-stall lines: $(grep -c '^TICK' $out 2>/dev/null) APLY lines: $(grep -c '^APLY' $out 2>/dev/null)" } check_prios() { @@ -58,7 +65,7 @@ check_prios() { install_and_start() { # $1 = "grey" to enable the grey knob before launch $E install -r "$APK" >/dev/null - if [ "$1" = grey ]; then $E shell "touch $GREY"; else $E shell "rm -f $GREY"; fi + if [ "${1:-}" = grey ]; then $E shell "touch $GREY"; else $E shell "rm -f $GREY"; fi $E shell am force-stop $PKG $E shell am start -n $PKG/android.app.NativeActivity >/dev/null sleep 8 diff --git a/src/gputex.rs b/src/gputex.rs new file mode 100644 index 0000000..a24a70e --- /dev/null +++ b/src/gputex.rs @@ -0,0 +1,97 @@ +//! GPU-resident thumbnail textures. +//! +//! Slint's `Image::from_rgba8` uploads the pixels to the GPU *inside the +//! render frame that first presents them* (its Skia backend additionally +//! converts RGB8 byte-by-byte there). Measured on a Pixel 9 Pro at 120 Hz: +//! ~11 ms per 400 px first-present = the scroll hitch, even though the +//! median render is ~2 ms. +//! +//! Instead: the DECODE WORKER creates the texture on Slint's own wgpu +//! device and submits the upload to the queue — `write_texture` is async +//! (staging-buffer memcpy + GPU blit, no fence wait, no UI involvement). +//! The UI thread only wraps the already-resident texture in +//! `Image::try_from(texture)`; the first-present frame pays ~0. +//! +//! The device and queue are Slint's own, captured once from +//! `Window::set_rendering_notifier` (`GraphicsAPI::WGPU30`) at +//! `RenderingSetup` — so the texture is guaranteed to be sampleable by +//! Slint's Skia renderer (`import_wgpu_texture`), with no cross-context +//! import needed. +//! +//! Fallback: until `RenderingSetup` fires (or on a non-wgpu backend) +//! `upload_rgba` returns `None` and the caller delivers the RGBA8 bytes +//! the old way (`Image::from_rgba8`). + +use std::sync::OnceLock; + +/// The wgpu 30.x crate re-exported by Slint (same version it renders with). +pub use slint::wgpu_30::wgpu; + +static GPU: OnceLock<(wgpu::Device, wgpu::Queue)> = OnceLock::new(); + +/// Capture Slint's wgpu device + queue. Called from the rendering notifier +/// (UI thread, once, at `RenderingSetup`). +pub fn init(api: &slint::GraphicsAPI) { + if let slint::GraphicsAPI::WGPU30 { device, queue, .. } = api { + let _ = GPU.set((device.clone(), queue.clone())); + } +} + +/// True once Slint's wgpu device has been captured. +pub fn ready() -> bool { + GPU.get().is_some() +} + +/// Create a GPU-resident `Rgba8Unorm` texture from packed RGBA8 bytes and +/// submit the (async) upload. **Runs on the decode worker thread.** +/// Returns `None` until the device is captured (see fallback). +pub fn upload_rgba(rgba: &[u8], width: u32, height: u32) -> Option { + use wgpu::{ + Extent3d, Origin3d, TexelCopyBufferLayout, TexelCopyTextureInfo, TextureAspect, + TextureDescriptor, TextureDimension, TextureUsages, + }; + let (device, queue) = GPU.get()?; + let texture = device.create_texture(&TextureDescriptor { + label: Some("gallery-thumb"), + size: Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + // Slint's TryFrom requires TEXTURE_BINDING + + // RENDER_ATTACHMENT; COPY_DST is for our own upload. + usage: TextureUsages::TEXTURE_BINDING | TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_DST, + view_formats: &[], + }); + // wgpu/Vulkan: bytes_per_row must be a multiple of 256 — pad rows if + // needed (400 px wide = 1600 B rows -> 1664). + let raw_row = 4 * width as usize; + let bpr = (raw_row + 255) / 256 * 256; + let data: Vec = if bpr == raw_row { + rgba.to_vec() + } else { + let mut d = Vec::with_capacity(bpr * height as usize); + for y in 0..height as usize { + let s = y * raw_row; + d.extend_from_slice(&rgba[s..s + raw_row]); + d.resize(d.len() + (bpr - raw_row), 0); + } + d + }; + queue.write_texture( + TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: Origin3d::ZERO, + aspect: TextureAspect::All, + }, + &data, + TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bpr as u32), + rows_per_image: Some(height), + }, + Extent3d { width, height, depth_or_array_layers: 1 }, + ); + Some(texture) +} diff --git a/src/images.rs b/src/images.rs index 292e9df..23d620a 100644 --- a/src/images.rs +++ b/src/images.rs @@ -13,8 +13,10 @@ //! PNG: `image` crate (png feature), full decode + resize (27 files on the //! test device — not the hot path). //! -//! Output is RGB8 (content is opaque) and goes into -//! `slint::Image::from_rgb8(SharedPixelBuffer)` on the UI thread. +//! Pipeline output is RGB8 (content is opaque); the worker expands it to +//! RGBA8 (`to_rgba8`) before delivery so Slint can use `from_rgba8` +//! directly — its Skia backend has no RGB888 path and would otherwise do +//! the conversion on the UI thread inside the render frame. use std::io::Cursor; use std::path::Path; @@ -25,6 +27,21 @@ use image::imageops::FilterType; use image::{ImageBuffer, Rgb}; use turbojpeg::{Decompressor, Image, PixelFormat, ScalingFactor}; +/// Expand RGB8 to RGBA8 (A=255). Runs on the decode worker thread: Slint's +/// Skia backend has no RGB888 path and would convert byte-by-byte on the UI +/// thread inside the render frame (measured 10–11 ms per 400 px texture = +/// the scroll hitch). Doing it here costs ~0.3 ms off the UI thread. +pub fn to_rgba8(rgb: &[u8]) -> Vec { + let mut out = Vec::with_capacity(rgb.len() / 3 * 4); + for px in rgb.chunks_exact(3) { + out.push(px[0]); + out.push(px[1]); + out.push(px[2]); + out.push(255); + } + out +} + /// A finished thumbnail: RGB8, EXIF orientation applied, fitted into a /// `target x target` box (aspect preserved, never upscaled). #[derive(Debug, Clone)] diff --git a/src/lib.rs b/src/lib.rs index 28aded1..7e34d06 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -76,6 +76,7 @@ slint::include_modules!(); pub mod db; +pub mod gputex; pub mod images; pub mod scan; pub mod thumb; @@ -163,7 +164,8 @@ use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use slint::{ - ComponentHandle, Image, Model, ModelRc, Rgb8Pixel, SharedPixelBuffer, SharedString, Timer, + ComponentHandle, Image, Model, ModelRc, Rgb8Pixel, Rgba8Pixel, SharedPixelBuffer, SharedString, + Timer, TimerMode, VecModel, }; @@ -199,6 +201,13 @@ static MAX_PAINTED_LIVE: std::sync::atomic::AtomicUsize = /// Jank-hunt test knob (REMOVE after): 1x1 grey cell images (no texture work). static JANK_GREY_IMAGES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +/// Jank-hunt test knob (REMOVE after): disable background processing — +/// no new jobs are submitted (FILL/PRUNE skipped); the decode workers drain +/// and idle. Already-in-flight work still lands; evictions keep memory +/// bounded. New cells stay grey. Isolate: does scroll jank come from the +/// thumbnail pipeline at all? +static JANK_FREEZE: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); /// Per-tick (per-frame) model work budgets. The tick runs at display rate /// (~120 Hz): work MUST fit one frame, so updates are SPREAD across frames /// instead of batched (the old 16-row/100 ms batch was visible as a content @@ -207,6 +216,14 @@ static JANK_GREY_IMAGES: std::sync::atomic::AtomicBool = /// pixels actually change) — cheap enough for 4+4 per tick. const MAX_PAINT_CELLS_PER_TICK: usize = 2; const MAX_EVICT_CELLS_PER_TICK: usize = 4; +// Jank-hunt test knobs (REMOVE after): .gallery_paint / .gallery_evict / +// .gallery_tick_ms override the per-frame budgets at app start. +static JANK_PAINT: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(MAX_PAINT_CELLS_PER_TICK); +static JANK_EVICT: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(MAX_EVICT_CELLS_PER_TICK); +pub static JANK_TICK_MS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(DRAIN_INTERVAL_MS); /// Reconcile tick period. 8 ms ≈ one 120 Hz frame; Slint's event loop is /// VSync-paced on Android, so the actual cadence quantizes to the display /// refresh (8 ms on 120 Hz, ~16 ms on 60 Hz). Per-tick work is tiny and @@ -289,6 +306,28 @@ fn state() -> &'static AppState { JANK_GREY_IMAGES.store(true, Ordering::Relaxed); log::info!("JANKTEST grey images ON"); } + if std::path::Path::new("/sdcard/Download/.gallery_freeze").exists() { + JANK_FREEZE.store(true, Ordering::Relaxed); + log::info!("JANKTEST freeze ON — no background processing"); + } + if let Ok(s) = std::fs::read_to_string("/sdcard/Download/.gallery_paint") { + if let Ok(m) = s.trim().parse::() { + JANK_PAINT.store(m, Ordering::Relaxed); + log::info!("JANKTEST paint = {m}"); + } + } + if let Ok(s) = std::fs::read_to_string("/sdcard/Download/.gallery_evict") { + if let Ok(m) = s.trim().parse::() { + JANK_EVICT.store(m, Ordering::Relaxed); + log::info!("JANKTEST evict = {m}"); + } + } + if let Ok(s) = std::fs::read_to_string("/sdcard/Download/.gallery_tick_ms") { + if let Ok(m) = s.trim().parse::() { + JANK_TICK_MS.store(m.max(4), Ordering::Relaxed); + log::info!("JANKTEST tick_ms = {m}"); + } + } } AppState { engine, @@ -421,7 +460,7 @@ fn apply_cell_updates( ) -> usize { let mut painted_now = 0usize; for r in paints { - if painted_now >= MAX_PAINT_CELLS_PER_TICK { + if painted_now >= JANK_PAINT.load(Ordering::Relaxed) { break; // the rest is re-covered by the fill step } if !r.ok || r.id >= entries.len() || entries[r.id].path != r.path { @@ -446,10 +485,30 @@ fn apply_cell_updates( let mut g = SharedPixelBuffer::::new(1, 1); g.make_mut_bytes().fill(32); Image::from_rgb8(g) + } else if let Some(tex) = r.gpu.clone() { // cheap: Arc interior + // GPU-resident texture: the worker created it on Slint's own + // wgpu device and submitted the upload (async). Zero pixel + // work in this frame — first present costs ~0 (was ~11 ms + // with from_rgba8; see gputex). + match Image::try_from(tex) { + Ok(img) => img, + // Defensive: impossible with our format/usage — the CPU + // fallback bytes are NOT delivered in this case. + Err(e) => { + log::warn!("gputex import failed: {e}"); + let mut g = SharedPixelBuffer::::new(1, 1); + g.make_mut_bytes().fill(0); + Image::from_rgba8(g) + } + } } else { - let mut buffer = SharedPixelBuffer::::new(r.width, r.height); + // CPU fallback (device not captured yet / non-wgpu backend): + // worker delivers RGBA8 (A=255). Slint's Skia backend has no + // RGB888 path and would convert byte-by-byte in the render + // frame — see images::to_rgba8. + let mut buffer = SharedPixelBuffer::::new(r.width, r.height); buffer.make_mut_bytes().copy_from_slice(&r.rgb); - Image::from_rgb8(buffer) + Image::from_rgba8(buffer) }; cells.set_row_data( r.id % GRID_COLS, @@ -520,6 +579,48 @@ fn viewport_for(y: f32, height: f32, width: f32, n: usize) -> OptionAfter, us) and the gap between rendered +// frames (ms). Same monotonic clock as SCR, so input->render latency and +// edge-crossing correlation are computable in one file. +static FR_BEFORE_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +static FR_LAST_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn now_us() -> u64 { + NOW_T0.get_or_init(std::time::Instant::now).elapsed().as_micros() as u64 +} + +/// Install the per-frame logger (jank hunt; REMOVE after). Runs on the UI +/// thread during rendering: only atomics + a non-blocking log push. +fn install_frame_logger(window: &Main) { + use std::sync::atomic::Ordering; + let _ = window.window().set_rendering_notifier(|state, api| { + let now = now_us(); + match state { + // One-time: capture Slint's wgpu device so the decode workers + // can upload thumbnail textures off the UI thread (gputex). + slint::RenderingState::RenderingSetup => { + gputex::init(api); + log::info!("gputex: wgpu device {}", if gputex::ready() { "captured" } else { "NOT captured" }); + } + slint::RenderingState::BeforeRendering => { + FR_BEFORE_US.store(now, Ordering::Relaxed); + } + slint::RenderingState::AfterRendering => { + let before = FR_BEFORE_US.load(Ordering::Relaxed); + let last = FR_LAST_US.swap(now, Ordering::Relaxed); + let d_us = now.saturating_sub(before); + let gap_ms = if last == 0 { + 0.0 + } else { + (now - last) as f64 / 1000.0 + }; + log::info!("FR {:11.2} d={:6}us gap={:7.2}", now_ms(), d_us, gap_ms); + } + _ => {} + } + }); +} /// Monotonic ms since process start (wall clock could jump on NTP and be /// mistaken for jank). @@ -538,6 +639,7 @@ mod nblog { //! try to measure. Records are formatted at the call site and pushed //! into a bounded channel; a drain thread does the real //! `__android_log_write` and DROPS records when saturated. + use std::io::Write as _; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc; use log::{Level, Log, Metadata, Record}; @@ -557,7 +659,32 @@ mod nblog { std::thread::Builder::new() .name("log-drain".into()) .spawn(move || { + // Jank hunt (REMOVE after): mirror the cadence lines to a + // file — the logcat ring buffer rotates every few + // seconds under SCR/TICK volume and loses the data. + let mut perf: Option> = + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("/sdcard/Download/.gallery_perf.log") + .ok() + .map(std::io::BufWriter::new); + let mut perf_lines = 0usize; for (prio, msg) in rx { + if let Some(w) = perf.as_mut() { + if msg.starts_with("SCR ") + || msg.starts_with("TICK") + || msg.starts_with("APLY") + || msg.starts_with("FR ") + { + let _ = writeln!(w, "{}", msg); + perf_lines += 1; + if perf_lines >= 32 { + let _ = w.flush(); + perf_lines = 0; + } + } + } let b = msg.into_bytes(); let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); let mut safe: Vec = @@ -647,7 +774,7 @@ fn start_drain_timer(weak: &slint::Weak
) { let timer = Timer::default(); timer.start( TimerMode::Repeated, - std::time::Duration::from_millis(DRAIN_INTERVAL_MS), + std::time::Duration::from_millis(JANK_TICK_MS.load(Ordering::Relaxed)), move || { let Some(app) = STATE.get() else { return }; let Some(w) = weak.upgrade() else { return }; @@ -819,20 +946,29 @@ fn start_drain_timer(weak: &slint::Weak
) { // evictions also fit the frame budget. let to_clear: Vec = { let painted = app.painted.lock().unwrap(); - let overflow = painted.len().saturating_sub(MAX_PAINTED_LIVE.load(Ordering::Relaxed)); - let lo = viewport.start.saturating_sub(RETENTION_ROWS * GRID_COLS); - let hi = (viewport.end + RETENTION_ROWS * GRID_COLS).min(entries.len()); - let center = (viewport.start + viewport.end) / 2; - let mut far: Vec = painted - .iter() - .copied() - .filter(|id| *id < lo || *id >= hi) - .collect(); - far.sort_by_key(|id| center.abs_diff(*id)); // nearest first - far.reverse(); // farthest first - let budget = MAX_EVICT_CELLS_PER_TICK; - let n = far.len().min(budget.max(overflow.min(2 * budget))); - far.into_iter().take(n).collect() + let cap = MAX_PAINTED_LIVE.load(Ordering::Relaxed); + let overflow = painted.len().saturating_sub(cap); + // Burst eviction: steady state (painted << cap) does NO + // eviction work at all — every tick's only model change is + // the paint. Near the cap, evict in bursts (farthest + // first) to get back to the low-water mark. + if painted.len() < cap.saturating_sub(12) && overflow == 0 { + Vec::new() + } else { + let lo = viewport.start.saturating_sub(RETENTION_ROWS * GRID_COLS); + let hi = (viewport.end + RETENTION_ROWS * GRID_COLS).min(entries.len()); + let center = (viewport.start + viewport.end) / 2; + let mut far: Vec = painted + .iter() + .copied() + .filter(|id| *id < lo || *id >= hi) + .collect(); + far.sort_by_key(|id| center.abs_diff(*id)); // nearest first + far.reverse(); // farthest first + let budget = JANK_EVICT.load(Ordering::Relaxed).max(4); + let n = far.len().min(budget.max(overflow.min(2 * budget))); + far.into_iter().take(n).collect() + } }; // 3. APPLY: in-place nested cell updates (≤4 paints + ≤4 @@ -841,13 +977,24 @@ fn start_drain_timer(weak: &slint::Weak
) { let painted_now = apply_cell_updates(app, m, &entries, &paints, &to_clear); let apply_us = apply_t0.elapsed().as_micros(); // jank hunt if apply_us > 1500 { // jank hunt (REMOVE after) - log::info!("APLY {}us p={} c={}", apply_us, painted_now, to_clear.len()); + log::info!("APLY {:11.2} {}us p={} c={}", now_ms(), apply_us, painted_now, to_clear.len()); } // 4. FILL: every viewport cell without pixels and without an // in-flight job gets submitted now, center-first so the eye is // filled first. Restores the invariant: after this step every // viewport cell is painted, in flight, or failed. + // 4b. PRUNE: drop queued (not-yet-started) jobs outside the retention band. + // A fast scroll leaves queued work for cells the user has passed; + // letting it linger would make the next viewport's work wait behind + // it (the reported "stop → long wait → pop" residual). If the user + // comes back, the disk cache serves those cells in ~10 ms, never a + // re-decode. In-flight jobs are untouched; their out-of-viewport + // results are dropped at consumption. + // Jank-hunt (REMOVE after): both are skipped under FREEZE — the + // decode workers then drain and idle, isolating the scroll path + // from all thumbnail-pipeline activity. + if !JANK_FREEZE.load(Ordering::Relaxed) { { let painted = app.painted.lock().unwrap(); let failed = app.failed.lock().unwrap(); @@ -886,13 +1033,6 @@ fn start_drain_timer(weak: &slint::Weak
) { } } - // 4b. PRUNE: drop queued (not-yet-started) jobs outside the retention band. - // A fast scroll leaves queued work for cells the user has passed; - // letting it linger would make the next viewport's work wait behind - // it (the reported "stop → long wait → pop" residual). If the user - // comes back, the disk cache serves those cells in ~10 ms, never a - // re-decode. In-flight jobs are untouched; their out-of-viewport - // results are dropped at consumption. { let lo = viewport.start.saturating_sub(MARGIN_ROWS * GRID_COLS); let hi = (viewport.end + MARGIN_ROWS * GRID_COLS).min(entries.len()); @@ -911,11 +1051,13 @@ fn start_drain_timer(weak: &slint::Weak
) { log::debug!("gallery: pruned {} stale queued jobs", dropped.len()); } } + } // !JANK_FREEZE // 5. Invariant (debug builds): the viewport is fully covered. // A violation means a visible cell is forgotten — the exact // bug class this design exists to make impossible. #[cfg(debug_assertions)] + if !JANK_FREEZE.load(Ordering::Relaxed) { { let painted = app.painted.lock().unwrap(); let inflight = app.inflight.lock().unwrap(); @@ -928,6 +1070,7 @@ fn start_drain_timer(weak: &slint::Weak
) { debug_assert!(covered, "viewport cell {id} is uncovered"); } } + } // 6. Diagnostics: stall alarm (rate-limited) + acceptance log. { @@ -963,13 +1106,14 @@ fn start_drain_timer(weak: &slint::Weak
) { // Jank hunt (REMOVE after): stall-only tick log. Log when the // gap since the previous tick exceeds one 60 Hz frame (a dropped // 120 Hz frame) or the tick itself was slow — full-cadence - // logging at 125 lines/s risks blocking in log_write and - // masquerading as jank. + // logging rotates the logcat ring buffer in seconds on the + // phone (its ~1.7 ms/tick baseline tripped the old 500 us + // threshold every tick). let tick_us = tick_t0.elapsed().as_micros(); let t_now = now_ms(); let last = f64::from_bits(LAST_TICK_T.swap(t_now.to_bits(), Ordering::Relaxed)); let gap = t_now - last; - if tick_us > 500 || gap > 16.0 { + if tick_us > 3000 || gap > 16.0 { log::info!("TICK {:11.2} gap={:.1} d={}us p={}", t_now, gap, tick_us, painted_now); } }, @@ -981,6 +1125,7 @@ fn start_drain_timer(weak: &slint::Weak
) { /// (streaming into the list on cold start). M3: the tick fills the viewport /// as it moves — top cells right after (re)paint, visible cells on scroll. pub fn start_scan(window: &Main) { + install_frame_logger(window); let model = ModelRc::new(VecModel::::default()); window.set_items(model); let weak = window.as_weak(); diff --git a/src/thumb.rs b/src/thumb.rs index 96ef9e6..7f03743 100644 --- a/src/thumb.rs +++ b/src/thumb.rs @@ -36,10 +36,14 @@ use sha1::{Digest, Sha1}; use crate::db; use crate::images; -/// Grid thumbnails: ~500 px (longest side). Cells render ~350–420 px physical -/// (3 columns) on a phone; 500 keeps them sharp. The target is part of the -/// cache key, so a future different-size target coexists in the same dir. -pub const GRID_TARGET: u32 = 500; +/// Grid thumbnails: ~400 px (longest side). Cells render ~350–420 px physical +/// (3 columns) on a phone. Was 500: each newly-painted cell's 700 KB texture +/// upload stalls the render frame 25–40 ms on the phone (measured 2026-10-28: +/// the 25–40 ms stall bucket tracks cell-entry rate and vanishes with 1×1 +/// test images). 400 cuts the upload ~36% at a 1.04× upscale — visually +/// identical in a 3-column grid. The target is part of the cache key, so a +/// future different-size target coexists in the same dir. +pub const GRID_TARGET: u32 = 400; const JPEG_QUALITY: u8 = 85; /// Per-worker queue cap. A burst (one tick's submissions) is never bigger @@ -85,7 +89,15 @@ pub struct JobResult { /// its in-flight marker; the cell is NOT failed — a re-entry re-submits /// it (disk-cache hit if it was ever decoded, otherwise a fresh decode). pub skipped: bool, - /// RGB8, orientation applied, fitted to the engine's target size. + /// GPU-resident thumbnail texture (created by the worker on Slint's + /// own wgpu device; upload already submitted, async). When `Some`, + /// `rgb` is empty and the UI wraps it in `Image::try_from(tex)` — + /// zero pixel work in the render frame. + pub gpu: Option, + /// Fallback only (gpu == None): RGBA8 (A=255), orientation applied, + /// fitted to the engine's target size. Expanded on the worker because + /// Slint's Skia backend can't take RGB888 (it would convert + /// byte-by-byte on the UI thread during the render frame). pub rgb: Vec, pub width: u32, pub height: u32, @@ -380,6 +392,7 @@ fn dispatch_loop( path: j.path, ok: false, skipped: true, + gpu: None, rgb: Vec::new(), width: 0, height: 0, @@ -465,12 +478,24 @@ fn worker_loop( Some(t) => (t.rgb, t.width, t.height), None => (Vec::new(), 0, 0), }; + // Expand RGB8 -> RGBA8 HERE (worker thread), not in Slint's render + // pass — see JobResult docs. Cost: ~0.3 ms per 400 px thumb. + let rgba = if ok { crate::images::to_rgba8(&rgb) } else { rgb }; + // Upload to the GPU on Slint's own device/queue (async, no UI + // involvement). On success the CPU bytes are NOT sent over the + // channel — the texture is already resident. See gputex. + let gpu = crate::gputex::upload_rgba(&rgba, width, height); + let rgb = match &gpu { + Some(_) => Vec::new(), + None => rgba, + }; let _ = res_tx.send(JobResult { id: job.id, gen: job.gen, path: job.path, ok, skipped: false, + gpu, rgb, width, height,