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:
parent
d38dcd3495
commit
ed52109dba
104
Cargo.lock
generated
104
Cargo.lock
generated
|
|
@ -784,6 +784,8 @@ version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"termcolor",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1102,6 +1104,21 @@ dependencies = [
|
||||||
"rustix 0.38.44",
|
"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]]
|
[[package]]
|
||||||
name = "drm-ffi"
|
name = "drm-ffi"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
|
|
@ -1254,7 +1271,7 @@ dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"fnv",
|
"fnv",
|
||||||
"glow",
|
"glow 0.18.0",
|
||||||
"imgref",
|
"imgref",
|
||||||
"itertools 0.15.0",
|
"itertools 0.15.0",
|
||||||
"log",
|
"log",
|
||||||
|
|
@ -1263,6 +1280,7 @@ dependencies = [
|
||||||
"swash",
|
"swash",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1544,7 +1562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a"
|
checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"drm",
|
"drm 0.14.1",
|
||||||
"drm-fourcc",
|
"drm-fourcc",
|
||||||
"gbm-sys",
|
"gbm-sys",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -1625,6 +1643,18 @@ version = "0.3.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
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]]
|
[[package]]
|
||||||
name = "glow"
|
name = "glow"
|
||||||
version = "0.18.0"
|
version = "0.18.0"
|
||||||
|
|
@ -1856,18 +1886,20 @@ dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"calloop 0.14.5",
|
"calloop 0.14.5",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"drm",
|
"drm 0.14.1",
|
||||||
"gbm",
|
"gbm",
|
||||||
"glutin",
|
"glutin",
|
||||||
"i-slint-common",
|
"i-slint-common",
|
||||||
"i-slint-core",
|
"i-slint-core",
|
||||||
"i-slint-renderer-femtovg",
|
"i-slint-renderer-femtovg",
|
||||||
|
"i-slint-renderer-skia",
|
||||||
"i-slint-renderer-software",
|
"i-slint-renderer-software",
|
||||||
"input",
|
"input",
|
||||||
"libseat",
|
"libseat",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"nix",
|
"nix",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
|
"wgpu",
|
||||||
"xkbcommon",
|
"xkbcommon",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1887,6 +1919,7 @@ dependencies = [
|
||||||
"i-slint-core",
|
"i-slint-core",
|
||||||
"i-slint-core-macros",
|
"i-slint-core-macros",
|
||||||
"i-slint-renderer-femtovg",
|
"i-slint-renderer-femtovg",
|
||||||
|
"i-slint-renderer-skia",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2074,7 +2107,7 @@ dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"const-field-offset",
|
"const-field-offset",
|
||||||
"femtovg",
|
"femtovg",
|
||||||
"glow",
|
"glow 0.18.0",
|
||||||
"i-slint-common",
|
"i-slint-common",
|
||||||
"i-slint-core",
|
"i-slint-core",
|
||||||
"i-slint-core-macros",
|
"i-slint-core-macros",
|
||||||
|
|
@ -2084,6 +2117,7 @@ dependencies = [
|
||||||
"rgb",
|
"rgb",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2097,7 +2131,7 @@ dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"clru",
|
"clru",
|
||||||
"const-field-offset",
|
"const-field-offset",
|
||||||
"glow",
|
"glow 0.18.0",
|
||||||
"glutin",
|
"glutin",
|
||||||
"i-slint-common",
|
"i-slint-common",
|
||||||
"i-slint-core",
|
"i-slint-core",
|
||||||
|
|
@ -2576,6 +2610,17 @@ dependencies = [
|
||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "khronos_api"
|
name = "khronos_api"
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
|
|
@ -4267,6 +4312,7 @@ dependencies = [
|
||||||
"slint-macros",
|
"slint-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"vtable",
|
"vtable",
|
||||||
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -4382,6 +4428,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"as-raw-xcb-connection",
|
"as-raw-xcb-connection",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
"drm 0.14.1",
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
|
|
@ -4586,6 +4633,15 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "text-size"
|
name = "text-size"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
|
|
@ -5277,12 +5333,18 @@ dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"document-features",
|
"document-features",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"naga",
|
||||||
|
"parking_lot",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"profiling",
|
"profiling",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
"wgpu-core",
|
"wgpu-core",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
|
|
@ -5315,6 +5377,8 @@ dependencies = [
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
"wgpu-core-deps-apple",
|
"wgpu-core-deps-apple",
|
||||||
|
"wgpu-core-deps-emscripten",
|
||||||
|
"wgpu-core-deps-wasm",
|
||||||
"wgpu-core-deps-windows-linux-android",
|
"wgpu-core-deps-windows-linux-android",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
"wgpu-naga-bridge",
|
"wgpu-naga-bridge",
|
||||||
|
|
@ -5330,6 +5394,24 @@ dependencies = [
|
||||||
"wgpu-hal",
|
"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]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-windows-linux-android"
|
name = "wgpu-core-deps-windows-linux-android"
|
||||||
version = "30.0.1"
|
version = "30.0.1"
|
||||||
|
|
@ -5354,13 +5436,19 @@ dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
|
"drm 0.15.0",
|
||||||
|
"glow 0.17.0",
|
||||||
|
"glutin_wgl_sys",
|
||||||
"gpu-allocator",
|
"gpu-allocator",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
|
"js-sys",
|
||||||
|
"khronos-egl",
|
||||||
"libc",
|
"libc",
|
||||||
"libloading",
|
"libloading",
|
||||||
"log",
|
"log",
|
||||||
"naga",
|
"naga",
|
||||||
"naga-types",
|
"naga-types",
|
||||||
|
"ndk-sys 0.6.0+11769913",
|
||||||
"objc2 0.6.5",
|
"objc2 0.6.5",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
|
|
@ -5380,10 +5468,14 @@ dependencies = [
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wayland-sys",
|
||||||
|
"web-sys",
|
||||||
"wgpu-naga-bridge",
|
"wgpu-naga-bridge",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core",
|
"windows-core",
|
||||||
|
"windows-result",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5404,10 +5496,12 @@ checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"naga-types",
|
"naga-types",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,14 @@ edition = "2021"
|
||||||
crate-type = ["cdylib", "lib"]
|
crate-type = ["cdylib", "lib"]
|
||||||
|
|
||||||
[dependencies]
|
[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"
|
log = "0.4"
|
||||||
rusqlite = { version = "0.41.0", features = ["bundled"] }
|
rusqlite = { version = "0.41.0", features = ["bundled"] }
|
||||||
sha1 = "0.11.0"
|
sha1 = "0.11.0"
|
||||||
|
|
|
||||||
26
HANDOFF.md
26
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.
|
- 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`
|
- Gitea: push target `git+ssh://gitea@git.wow.st/gmp/gallery.git`
|
||||||
(repo must exist on the server first, like the other gmp/* repos).
|
(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).
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ esac
|
||||||
profile_dir=release; [ -z "$profile" ] && profile_dir=debug
|
profile_dir=release; [ -z "$profile" ] && profile_dir=debug
|
||||||
|
|
||||||
# Optional: ADB_SERIAL picks the device when several are attached (see AGENTS.md).
|
# 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
|
cargo apk2 build $profile --target "$target" --lib
|
||||||
apk=target/$profile_dir/apk/gallery.apk
|
apk=target/$profile_dir/apk/gallery.apk
|
||||||
|
|
|
||||||
|
|
@ -22,32 +22,39 @@ APK=target/release/apk/gallery.apk
|
||||||
PKG=wow.gmp.gallery
|
PKG=wow.gmp.gallery
|
||||||
CMD=/sdcard/Download/.gallery_cmd
|
CMD=/sdcard/Download/.gallery_cmd
|
||||||
GREY=/sdcard/Download/.gallery_grey
|
GREY=/sdcard/Download/.gallery_grey
|
||||||
|
PERF=/sdcard/Download/.gallery_perf.log
|
||||||
|
|
||||||
[ -f "$APK" ] || { echo "build first: ./scripts/build.sh aarch64"; exit 1; }
|
[ -f "$APK" ] || { echo "build first: ./scripts/build.sh aarch64"; exit 1; }
|
||||||
|
|
||||||
scroll_window() { # $1 = label, $2 = tag, $3 = seconds
|
scroll_window() { # $1 = label, $2 = tag, $3 = seconds
|
||||||
local out="/tmp/jank_$2.txt"
|
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)..."
|
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"
|
sleep "$3"
|
||||||
kill "$LP" 2>/dev/null
|
$E pull "$PERF" "$out" >/dev/null 2>&1
|
||||||
python3 - "$out" << 'EOF'
|
python3 - "$out" << 'EOF'
|
||||||
import re, sys
|
import re, sys
|
||||||
ev = []
|
ev = []
|
||||||
for line in open(sys.argv[1], errors="ignore"):
|
for line in open(sys.argv[1], errors="ignore"):
|
||||||
m = re.search(r"SCRDBG Instant \{ tv_sec: (\d+), tv_nsec: (\d+) \}", line)
|
m = re.match(r"SCR +([\d.]+)", line)
|
||||||
if m: ev.append(int(m.group(1))*1000 + int(m.group(2))/1e6)
|
if m: ev.append(float(m.group(1)))
|
||||||
if len(ev) < 10:
|
if len(ev) < 10:
|
||||||
print(f" {sys.argv[1]}: only {len(ev)} scroll events — not enough data"); sys.exit()
|
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)]
|
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]
|
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"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
|
EOF
|
||||||
|
echo " TICK-stall lines: $(grep -c '^TICK' $out 2>/dev/null) APLY lines: $(grep -c '^APLY' $out 2>/dev/null)"
|
||||||
}
|
}
|
||||||
|
|
||||||
check_prios() {
|
check_prios() {
|
||||||
|
|
@ -58,7 +65,7 @@ check_prios() {
|
||||||
|
|
||||||
install_and_start() { # $1 = "grey" to enable the grey knob before launch
|
install_and_start() { # $1 = "grey" to enable the grey knob before launch
|
||||||
$E install -r "$APK" >/dev/null
|
$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 force-stop $PKG
|
||||||
$E shell am start -n $PKG/android.app.NativeActivity >/dev/null
|
$E shell am start -n $PKG/android.app.NativeActivity >/dev/null
|
||||||
sleep 8
|
sleep 8
|
||||||
|
|
|
||||||
97
src/gputex.rs
Normal file
97
src/gputex.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -13,8 +13,10 @@
|
||||||
//! PNG: `image` crate (png feature), full decode + resize (27 files on the
|
//! PNG: `image` crate (png feature), full decode + resize (27 files on the
|
||||||
//! test device — not the hot path).
|
//! test device — not the hot path).
|
||||||
//!
|
//!
|
||||||
//! Output is RGB8 (content is opaque) and goes into
|
//! Pipeline output is RGB8 (content is opaque); the worker expands it to
|
||||||
//! `slint::Image::from_rgb8(SharedPixelBuffer)` on the UI thread.
|
//! 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::io::Cursor;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
@ -25,6 +27,21 @@ use image::imageops::FilterType;
|
||||||
use image::{ImageBuffer, Rgb};
|
use image::{ImageBuffer, Rgb};
|
||||||
use turbojpeg::{Decompressor, Image, PixelFormat, ScalingFactor};
|
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
|
/// A finished thumbnail: RGB8, EXIF orientation applied, fitted into a
|
||||||
/// `target x target` box (aspect preserved, never upscaled).
|
/// `target x target` box (aspect preserved, never upscaled).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
|
||||||
181
src/lib.rs
181
src/lib.rs
|
|
@ -76,6 +76,7 @@
|
||||||
slint::include_modules!();
|
slint::include_modules!();
|
||||||
|
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod gputex;
|
||||||
pub mod images;
|
pub mod images;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
pub mod thumb;
|
pub mod thumb;
|
||||||
|
|
@ -163,7 +164,8 @@ use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use slint::{
|
use slint::{
|
||||||
ComponentHandle, Image, Model, ModelRc, Rgb8Pixel, SharedPixelBuffer, SharedString, Timer,
|
ComponentHandle, Image, Model, ModelRc, Rgb8Pixel, Rgba8Pixel, SharedPixelBuffer, SharedString,
|
||||||
|
Timer,
|
||||||
TimerMode, VecModel,
|
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).
|
/// Jank-hunt test knob (REMOVE after): 1x1 grey cell images (no texture work).
|
||||||
static JANK_GREY_IMAGES: std::sync::atomic::AtomicBool =
|
static JANK_GREY_IMAGES: std::sync::atomic::AtomicBool =
|
||||||
std::sync::atomic::AtomicBool::new(false);
|
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
|
/// 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
|
/// (~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
|
/// 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.
|
/// pixels actually change) — cheap enough for 4+4 per tick.
|
||||||
const MAX_PAINT_CELLS_PER_TICK: usize = 2;
|
const MAX_PAINT_CELLS_PER_TICK: usize = 2;
|
||||||
const MAX_EVICT_CELLS_PER_TICK: usize = 4;
|
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
|
/// 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
|
/// 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
|
/// 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);
|
JANK_GREY_IMAGES.store(true, Ordering::Relaxed);
|
||||||
log::info!("JANKTEST grey images ON");
|
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 {
|
AppState {
|
||||||
engine,
|
engine,
|
||||||
|
|
@ -421,7 +460,7 @@ fn apply_cell_updates(
|
||||||
) -> usize {
|
) -> usize {
|
||||||
let mut painted_now = 0usize;
|
let mut painted_now = 0usize;
|
||||||
for r in paints {
|
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
|
break; // the rest is re-covered by the fill step
|
||||||
}
|
}
|
||||||
if !r.ok || r.id >= entries.len() || entries[r.id].path != r.path {
|
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);
|
let mut g = SharedPixelBuffer::<Rgb8Pixel>::new(1, 1);
|
||||||
g.make_mut_bytes().fill(32);
|
g.make_mut_bytes().fill(32);
|
||||||
Image::from_rgb8(g)
|
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 {
|
} 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);
|
buffer.make_mut_bytes().copy_from_slice(&r.rgb);
|
||||||
Image::from_rgb8(buffer)
|
Image::from_rgba8(buffer)
|
||||||
};
|
};
|
||||||
cells.set_row_data(
|
cells.set_row_data(
|
||||||
r.id % GRID_COLS,
|
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 =
|
static LAST_SCR_Y: std::sync::atomic::AtomicI64 =
|
||||||
std::sync::atomic::AtomicI64::new(i64::MIN);
|
std::sync::atomic::AtomicI64::new(i64::MIN);
|
||||||
static LAST_TICK_T: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
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
|
/// Monotonic ms since process start (wall clock could jump on NTP and be
|
||||||
/// mistaken for jank).
|
/// mistaken for jank).
|
||||||
|
|
@ -538,6 +639,7 @@ mod nblog {
|
||||||
//! try to measure. Records are formatted at the call site and pushed
|
//! try to measure. Records are formatted at the call site and pushed
|
||||||
//! into a bounded channel; a drain thread does the real
|
//! into a bounded channel; a drain thread does the real
|
||||||
//! `__android_log_write` and DROPS records when saturated.
|
//! `__android_log_write` and DROPS records when saturated.
|
||||||
|
use std::io::Write as _;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use log::{Level, Log, Metadata, Record};
|
use log::{Level, Log, Metadata, Record};
|
||||||
|
|
@ -557,7 +659,32 @@ mod nblog {
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("log-drain".into())
|
.name("log-drain".into())
|
||||||
.spawn(move || {
|
.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 {
|
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 b = msg.into_bytes();
|
||||||
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
||||||
let mut safe: Vec<u8> =
|
let mut safe: Vec<u8> =
|
||||||
|
|
@ -647,7 +774,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
||||||
let timer = Timer::default();
|
let timer = Timer::default();
|
||||||
timer.start(
|
timer.start(
|
||||||
TimerMode::Repeated,
|
TimerMode::Repeated,
|
||||||
std::time::Duration::from_millis(DRAIN_INTERVAL_MS),
|
std::time::Duration::from_millis(JANK_TICK_MS.load(Ordering::Relaxed)),
|
||||||
move || {
|
move || {
|
||||||
let Some(app) = STATE.get() else { return };
|
let Some(app) = STATE.get() else { return };
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
|
@ -819,7 +946,15 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
||||||
// evictions also fit the frame budget.
|
// evictions also fit the frame budget.
|
||||||
let to_clear: Vec<usize> = {
|
let to_clear: Vec<usize> = {
|
||||||
let painted = app.painted.lock().unwrap();
|
let painted = app.painted.lock().unwrap();
|
||||||
let overflow = painted.len().saturating_sub(MAX_PAINTED_LIVE.load(Ordering::Relaxed));
|
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 lo = viewport.start.saturating_sub(RETENTION_ROWS * GRID_COLS);
|
||||||
let hi = (viewport.end + RETENTION_ROWS * GRID_COLS).min(entries.len());
|
let hi = (viewport.end + RETENTION_ROWS * GRID_COLS).min(entries.len());
|
||||||
let center = (viewport.start + viewport.end) / 2;
|
let center = (viewport.start + viewport.end) / 2;
|
||||||
|
|
@ -830,9 +965,10 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
||||||
.collect();
|
.collect();
|
||||||
far.sort_by_key(|id| center.abs_diff(*id)); // nearest first
|
far.sort_by_key(|id| center.abs_diff(*id)); // nearest first
|
||||||
far.reverse(); // farthest first
|
far.reverse(); // farthest first
|
||||||
let budget = MAX_EVICT_CELLS_PER_TICK;
|
let budget = JANK_EVICT.load(Ordering::Relaxed).max(4);
|
||||||
let n = far.len().min(budget.max(overflow.min(2 * budget)));
|
let n = far.len().min(budget.max(overflow.min(2 * budget)));
|
||||||
far.into_iter().take(n).collect()
|
far.into_iter().take(n).collect()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. APPLY: in-place nested cell updates (≤4 paints + ≤4
|
// 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 painted_now = apply_cell_updates(app, m, &entries, &paints, &to_clear);
|
||||||
let apply_us = apply_t0.elapsed().as_micros(); // jank hunt
|
let apply_us = apply_t0.elapsed().as_micros(); // jank hunt
|
||||||
if apply_us > 1500 { // jank hunt (REMOVE after)
|
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
|
// 4. FILL: every viewport cell without pixels and without an
|
||||||
// in-flight job gets submitted now, center-first so the eye is
|
// in-flight job gets submitted now, center-first so the eye is
|
||||||
// filled first. Restores the invariant: after this step every
|
// filled first. Restores the invariant: after this step every
|
||||||
// viewport cell is painted, in flight, or failed.
|
// 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 painted = app.painted.lock().unwrap();
|
||||||
let failed = app.failed.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 lo = viewport.start.saturating_sub(MARGIN_ROWS * GRID_COLS);
|
||||||
let hi = (viewport.end + MARGIN_ROWS * GRID_COLS).min(entries.len());
|
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());
|
log::debug!("gallery: pruned {} stale queued jobs", dropped.len());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // !JANK_FREEZE
|
||||||
|
|
||||||
// 5. Invariant (debug builds): the viewport is fully covered.
|
// 5. Invariant (debug builds): the viewport is fully covered.
|
||||||
// A violation means a visible cell is forgotten — the exact
|
// A violation means a visible cell is forgotten — the exact
|
||||||
// bug class this design exists to make impossible.
|
// bug class this design exists to make impossible.
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
|
if !JANK_FREEZE.load(Ordering::Relaxed) {
|
||||||
{
|
{
|
||||||
let painted = app.painted.lock().unwrap();
|
let painted = app.painted.lock().unwrap();
|
||||||
let inflight = app.inflight.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");
|
debug_assert!(covered, "viewport cell {id} is uncovered");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Diagnostics: stall alarm (rate-limited) + acceptance log.
|
// 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
|
// Jank hunt (REMOVE after): stall-only tick log. Log when the
|
||||||
// gap since the previous tick exceeds one 60 Hz frame (a dropped
|
// gap since the previous tick exceeds one 60 Hz frame (a dropped
|
||||||
// 120 Hz frame) or the tick itself was slow — full-cadence
|
// 120 Hz frame) or the tick itself was slow — full-cadence
|
||||||
// logging at 125 lines/s risks blocking in log_write and
|
// logging rotates the logcat ring buffer in seconds on the
|
||||||
// masquerading as jank.
|
// phone (its ~1.7 ms/tick baseline tripped the old 500 us
|
||||||
|
// threshold every tick).
|
||||||
let tick_us = tick_t0.elapsed().as_micros();
|
let tick_us = tick_t0.elapsed().as_micros();
|
||||||
let t_now = now_ms();
|
let t_now = now_ms();
|
||||||
let last = f64::from_bits(LAST_TICK_T.swap(t_now.to_bits(), Ordering::Relaxed));
|
let last = f64::from_bits(LAST_TICK_T.swap(t_now.to_bits(), Ordering::Relaxed));
|
||||||
let gap = t_now - last;
|
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);
|
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
|
/// (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.
|
/// as it moves — top cells right after (re)paint, visible cells on scroll.
|
||||||
pub fn start_scan(window: &Main) {
|
pub fn start_scan(window: &Main) {
|
||||||
|
install_frame_logger(window);
|
||||||
let model = ModelRc::new(VecModel::<Item>::default());
|
let model = ModelRc::new(VecModel::<Item>::default());
|
||||||
window.set_items(model);
|
window.set_items(model);
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
|
|
|
||||||
35
src/thumb.rs
35
src/thumb.rs
|
|
@ -36,10 +36,14 @@ use sha1::{Digest, Sha1};
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::images;
|
use crate::images;
|
||||||
|
|
||||||
/// Grid thumbnails: ~500 px (longest side). Cells render ~350–420 px physical
|
/// Grid thumbnails: ~400 px (longest side). Cells render ~350–420 px physical
|
||||||
/// (3 columns) on a phone; 500 keeps them sharp. The target is part of the
|
/// (3 columns) on a phone. Was 500: each newly-painted cell's 700 KB texture
|
||||||
/// cache key, so a future different-size target coexists in the same dir.
|
/// upload stalls the render frame 25–40 ms on the phone (measured 2026-10-28:
|
||||||
pub const GRID_TARGET: u32 = 500;
|
/// 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;
|
const JPEG_QUALITY: u8 = 85;
|
||||||
|
|
||||||
/// Per-worker queue cap. A burst (one tick's submissions) is never bigger
|
/// 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
|
/// 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).
|
/// it (disk-cache hit if it was ever decoded, otherwise a fresh decode).
|
||||||
pub skipped: bool,
|
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 rgb: Vec<u8>,
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
|
|
@ -380,6 +392,7 @@ fn dispatch_loop(
|
||||||
path: j.path,
|
path: j.path,
|
||||||
ok: false,
|
ok: false,
|
||||||
skipped: true,
|
skipped: true,
|
||||||
|
gpu: None,
|
||||||
rgb: Vec::new(),
|
rgb: Vec::new(),
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
|
|
@ -465,12 +478,24 @@ fn worker_loop(
|
||||||
Some(t) => (t.rgb, t.width, t.height),
|
Some(t) => (t.rgb, t.width, t.height),
|
||||||
None => (Vec::new(), 0, 0),
|
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 {
|
let _ = res_tx.send(JobResult {
|
||||||
id: job.id,
|
id: job.id,
|
||||||
gen: job.gen,
|
gen: job.gen,
|
||||||
path: job.path,
|
path: job.path,
|
||||||
ok,
|
ok,
|
||||||
skipped: false,
|
skipped: false,
|
||||||
|
gpu,
|
||||||
rgb,
|
rgb,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user