diff --git a/Cargo.lock b/Cargo.lock index 7bd3ac5..4b5a827 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1865,6 +1865,8 @@ dependencies = [ [[package]] name = "i-slint-backend-android-activity" version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d538003a566d7312572ac1435be0704934e46a63d55abdabede513f72332cb" dependencies = [ "android-activity 0.5.2", "android-activity 0.6.1", @@ -1873,8 +1875,6 @@ dependencies = [ "i-slint-core", "i-slint-renderer-skia", "jni 0.22.4", - "libc", - "log", "ndk 0.9.0", "raw-window-handle", ] @@ -2037,6 +2037,8 @@ dependencies = [ [[package]] name = "i-slint-core" version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2ea15059d70bc31b1a8b1eb4c34f2c5df04cb7e53f36179b43c0261231d646" dependencies = [ "accesskit", "async-channel", @@ -2123,6 +2125,8 @@ dependencies = [ [[package]] name = "i-slint-renderer-skia" version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc9c1cf02385f1d26fc1f423544e52390a8e5c2d89420a8042aa94b4fe109d1f" dependencies = [ "bytemuck", "cfg-if", @@ -2134,7 +2138,6 @@ dependencies = [ "i-slint-common", "i-slint-core", "i-slint-core-macros", - "log", "lyon_path", "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -5423,6 +5426,8 @@ dependencies = [ [[package]] name = "wgpu-hal" version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" dependencies = [ "android_system_properties", "arrayvec", diff --git a/Cargo.toml b/Cargo.toml index d0e8e4b..e48568a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,11 @@ i-slint-backend-android-activity = { version = "1.18.1" } # Pure code flag: gates the FR/STG/DECODE file-mirror instrumentation # (no dependencies — the backend dep is now unconditional for the fix). perf = [] +# Baseline-60 CONTROL build (2026-11-01): stock crates.io Slint (build.sh +# strips the vendored patch section), no driver, no vkshim, no +# instrumentation, display locked to 60 Hz via JNI (src/baseline60.rs). +# Build: `./scripts/build.sh baseline`. +baseline60 = [] # marker; the 60Hz lock is a device setting (peak_refresh_rate) [build-dependencies] slint-build = "1.18.1" diff --git a/scripts/build.sh b/scripts/build.sh index f5426f7..495235f 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -54,11 +54,29 @@ arg1=${1:-x86_64} arg2=${2:-install} profile=--release +features=perf case $arg1 in debug) target=x86_64-linux-android; profile="" ;; debug-aarch64) target=aarch64-linux-android; profile=""; export ANDROID_ABI=arm64-v8a ;; x86_64) target=x86_64-linux-android ;; aarch64) target=aarch64-linux-android; export ANDROID_ABI=arm64-v8a ;; + # Baseline-60 CONTROL build (2026-11-01): stock crates.io Slint (the + # vendored [patch.crates-io] section is stripped from the manifest for + # this build only), no driver/vkshim/instrumentation, display locked to + # 60 Hz (src/baseline60.rs). Answers: does the grid jank WITHOUT our + # vendored stack? Run: ./scripts/build.sh baseline + baseline) target=aarch64-linux-android; export ANDROID_ABI=arm64-v8a + features=baseline60 + cp Cargo.toml Cargo.toml.vendored.bak + python3 - Cargo.toml <<'PY' +import re, sys +p = sys.argv[1] +t = open(p).read() +i = re.search(r"^\[patch\.crates-io\]", t, re.M) +assert i, "vendored patch section not found — Cargo.toml layout changed?" +open(p, "w").write(t[:i.start()].rstrip() + "\n") +PY + trap 'cp Cargo.toml.vendored.bak Cargo.toml && rm -f Cargo.toml.vendored.bak' EXIT ;; *) echo "unknown arch: $arg1" >&2; exit 1 ;; esac @@ -75,7 +93,7 @@ mkdir -p shim/out "$NDK_BIN/${target}26-clang" -shared -fPIC -O2 shim/vkshim.c -o shim/out/libvulkan.so -lunwind echo "shim: $(du -h shim/out/libvulkan.so | cut -f1) for ${target}" -cargo apk2 build $profile --target "$target" --lib --features perf +cargo apk2 build $profile --target "$target" --lib --features $features apk=target/$profile_dir/apk/gallery.apk echo "APK: $apk ($(du -h "$apk" | cut -f1))" diff --git a/src/lib.rs b/src/lib.rs index 3c3aede..294546e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,7 +81,7 @@ pub mod images; pub mod scan; pub mod thumb; pub mod viewer; -#[cfg(target_os = "android")] +#[cfg(feature = "perf")] mod vkshim; /// Thread-priority helpers (jank fix): the app process runs at nice -10, @@ -1382,7 +1382,7 @@ fn install_render_notifier(window: &Main, wid: u32) { // update_timers_and_animations, events = process_event, // poll = full poll_events, lastrender = full do_render of // the previous frame (draw + wgpu surface tail). - #[cfg(target_os = "android")] + #[cfg(all(target_os = "android", feature = "perf"))] { let (s0, s1, s2, _s3, _s4, stm, sev, nev) = i_slint_backend_android_activity::stg::snapshot(); @@ -2221,7 +2221,10 @@ pub fn android_app() -> Option<&'static slint::android::AndroidApp> { fn android_main(app: slint::android::AndroidApp) { prio::set_self_nice(-10); // Boost UI thread priority to urgent display (-10) nblog::init(); - vkshim::install(); // Jank-hunt: interpose Vulkan before the first wgpu device + // Jank-hunt (perf builds only): interpose Vulkan before the first wgpu + // device. Baseline builds use stock wgpu — no interposition. + #[cfg(feature = "perf")] + vkshim::install(); // 120Hz scroll fix (docs/BUGS/scroll-jank-120hz.md): render on the vsync // edge (not on the input event's random phase) and use the render-ahead // pipeline (present the previous frame BEFORE preparing the next one), @@ -2229,6 +2232,8 @@ fn android_main(app: slint::android::AndroidApp) { // phase + 4–10 ms full render) — the measured cause of the 120Hz // scroll jank on Mali. The backend's event loop activates the // renderer's render-ahead mode per frame while animating. + // VENDED builds only — stock crates.io Slint has no stg module. + #[cfg(feature = "perf")] i_slint_backend_android_activity::stg::VSYNC_LOCKED_RENDER .store(true, std::sync::atomic::Ordering::Relaxed); // Jank-hunt (`perf` feature): log every event-loop iteration so the diff --git a/tools/jank_capture.py b/tools/jank_capture.py index a5d2968..c5ae008 100644 --- a/tools/jank_capture.py +++ b/tools/jank_capture.py @@ -166,7 +166,11 @@ def miss_report(parsed, period_us, max_episodes=12, ctx_ms=33): print(f" ... {len(eps)-max_episodes} more episodes (full data in log)") # --------------------------------------------------------------- the capture -def capture(app, drags, tag, tag_file, outdir, settle_after): +def capture(app, drags, tag, tag_file, outdir, settle_after, log_path=None): + # Default log path is sltest's; the gallery mirrors its STG lines to a + # different file (nblog perf-mirror, `perf` feature) — pass --log-path. + if log_path is None: + log_path = f"/sdcard/Android/data/{app}/files/it.log" rpt = {"valid": True, "notes": []} # 1. device + identity d = adb("devices").stdout @@ -184,8 +188,7 @@ def capture(app, drags, tag, tag_file, outdir, settle_after): # 2. clean slate: the log can only contain THIS process adb_shell(f"am force-stop {app}") time.sleep(1) - for f in ("it.log", "perf.log"): - adb_shell(f"rm -f /sdcard/Android/data/{app}/files/{f}") + adb_shell(f"rm -f {log_path}") n0 = adb_shell(f"dumpsys activity activities | grep -c '{app}'").stdout.strip() rpt["activity_records_after_forcestop"] = n0 @@ -222,7 +225,7 @@ def capture(app, drags, tag, tag_file, outdir, settle_after): os.makedirs(outdir, exist_ok=True) name = time.strftime("cap_%H%M%S") + (f"_{tag}" if tag else "") dest = os.path.join(outdir, name + ".log") - r = adb("pull", f"/sdcard/Android/data/{app}/files/it.log", dest, timeout=300) + r = adb("pull", log_path, dest, timeout=300) if r.returncode != 0 or not os.path.exists(dest): print("LOG PULL FAILED:\n", r.stdout, r.stderr) return 2 @@ -363,10 +366,12 @@ def main(): ap.add_argument("--tag", default=None, help="build tag for provenance") ap.add_argument("--tag-file", default=None, help="APK path; sha256 recorded") ap.add_argument("--outdir", default="/tmp/jank") + ap.add_argument("--log-path", default=None, + help="device path of the STG log (gallery: /sdcard/Download/.gallery_perf.log)") args = ap.parse_args() if args.cmd == "capture": sys.exit(capture(args.app, args.drags, args.tag, args.tag_file, - args.outdir, args.settle_after)) + args.outdir, args.settle_after, args.log_path)) else: if not args.log: ap.print_usage(); sys.exit(2) diff --git a/vendor/i-slint-backend-android-activity-1.18.1/lib.rs b/vendor/i-slint-backend-android-activity-1.18.1/lib.rs index b095f17..42c5a8f 100644 --- a/vendor/i-slint-backend-android-activity-1.18.1/lib.rs +++ b/vendor/i-slint-backend-android-activity-1.18.1/lib.rs @@ -231,6 +231,60 @@ impl i_slint_core::platform::Platform for AndroidPlatform { .store(pe - pr, std::sync::atomic::Ordering::Relaxed); } } + // PRE-POLL SPIN-RENDER (2026-10-31, finger-run beat fix #2): + // the post-poll target spin alone could not pin the phase — + // a queued finger event (1-3 ms to process) crossed the + // edge+0.45 ms target DURING poll_events, so the spin found + // the target already past and rendered at input-completion + // time. Measured on the phone: phase histogram 0.4-3.0 ms + // continuous (median 1.05) == the input-processing-duration + // distribution, and the glass showed 10.5 ms beat gaps. + // Fix: when the driver's target is within 1.2 ms and the slot + // is unrendered, SKIP the poll (queued input rides to the + // next slot, +8.3 ms — the standard vsync-locked input + // latency, imperceptible) and spin to the exact target. + if locked { + let __p0 = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS + .load(std::sync::atomic::Ordering::Relaxed); + let __e0 = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS + .load(std::sync::atomic::Ordering::Relaxed); + let __n0 = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns(); + if __p0 != 0 && __n0 >= __e0 { + let __s0 = ((__n0 - __e0) as i128) / (__p0 as i128); + if __s0 > last_rendered_slot { + let __tgt = __e0 + (__s0 as u64) * __p0 + 450_000; + if __n0 >= __tgt - 1_200_000 { + while i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns() < __tgt { + std::thread::yield_now(); + } + let __t = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns(); + i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS + .store(__t, std::sync::atomic::Ordering::Relaxed); + last_rendered_slot = __s0; + stg::S_START.store(stg::now_us(), std::sync::atomic::Ordering::Relaxed); + stg::S_FIRST_EV.store(0, std::sync::atomic::Ordering::Relaxed); + stg::S_TIMER_US.store(0, std::sync::atomic::Ordering::Relaxed); + stg::S_EVENT_US.store(0, std::sync::atomic::Ordering::Relaxed); + stg::S_N_EVENTS.store(0, std::sync::atomic::Ordering::Relaxed); + ctx.update_timers_and_animations(); + stg::S_RENDER_START.store(stg::now_us(), std::sync::atomic::Ordering::Relaxed); + i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE + .store(true, std::sync::atomic::Ordering::Relaxed); + let __ok = self.window.do_render().is_ok(); + stg::S_RENDER_END.store(stg::now_us(), std::sync::atomic::Ordering::Relaxed); + // On error (surface gone during pause/destroy) + // fall through instead of continuing: the + // normal path below re-checks state, processes + // the teardown events, and propagates the + // error via its own `do_render()?`. + if __ok { + prev_locked = locked; + continue; + } + } + } + } + } let t_start = stg::now_us(); let timeout_ms = timeout.map(|d| d.as_millis() as u64).unwrap_or(u64::MAX); stg::S_START.store(t_start, std::sync::atomic::Ordering::Relaxed); @@ -409,6 +463,28 @@ impl i_slint_core::platform::Platform for AndroidPlatform { let __wake_ok = saw_wake && (__p == 0 || __sw > __sn); let __accept = __driver || __wake_ok || __backstop; if __accept { + // PRESENT TARGET (2026-10-30, finger-run beat fix): + // firing at the FIRST opportunity (now >= edge+0.3ms) + // makes the present phase = whatever the 250us park + + // the finger's 120-240 Hz input events (1-3 ms each) + // left us — measured: phase p10-p90 1.23 ms, 1019 + // phase-jumps>0.5ms in one finger run, and the glass + // showed 10.5 ms inter-frame gaps (a 2 ms beat) 20 + // times in 11 s. Instead: spin to the EXACT target + // (this slot's edge + 0.45 ms) before rendering. The + // spin is bounded (<= ~1.2 ms from detection, a + // sched_yield loop), and the present phase becomes + // edge+0.45ms +/- ~50 us — constant, no beat, no + // matter when the input events arrive. The wake path + // already lands at ~edge+0.5 (the wake's own phase), + // so only the driver path needs the target. + if __driver { + let __slot_edge = __e + (__sw as u64) * __p; + let __target = __slot_edge + 450_000; + while i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns() < __target { + std::thread::yield_now(); + } + } i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS .store(__now_ns, std::sync::atomic::Ordering::Relaxed); if __sw > last_rendered_slot {