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.
This commit is contained in:
Greg Pomerantz 2026-10-28 10:57:15 -04:00
parent d38dcd3495
commit ed52109dba
9 changed files with 472 additions and 54 deletions

104
Cargo.lock generated
View File

@ -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]]

View File

@ -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<wgpu::Texture>` +
# 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"

View File

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

View File

@ -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

View File

@ -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

97
src/gputex.rs Normal file
View File

@ -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<wgpu::Texture> {
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<Texture> 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<u8> = 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)
}

View File

@ -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<u8> {
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)]

View File

@ -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::<usize>() {
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::<usize>() {
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::<u64>() {
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::<Rgb8Pixel>::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::<Rgba8Pixel>::new(1, 1);
g.make_mut_bytes().fill(0);
Image::from_rgba8(g)
}
}
} else {
let mut buffer = SharedPixelBuffer::<Rgb8Pixel>::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::<Rgba8Pixel>::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) -> Option<Range<usize
static LAST_SCR_Y: std::sync::atomic::AtomicI64 =
std::sync::atomic::AtomicI64::new(i64::MIN);
static LAST_TICK_T: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
// Per-frame ground truth via Window::set_rendering_notifier: FR lines carry
// the app-side render cost (Before->After, 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::io::BufWriter<std::fs::File>> =
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<u8> =
@ -647,7 +774,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
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<Main>) {
// evictions also fit the frame budget.
let to_clear: Vec<usize> = {
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<usize> = 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<usize> = 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<Main>) {
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<Main>) {
}
}
// 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<Main>) {
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<Main>) {
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<Main>) {
// 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<Main>) {
/// (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::<Item>::default());
window.set_items(model);
let weak = window.as_weak();

View File

@ -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<crate::gputex::wgpu::Texture>,
/// 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<u8>,
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,