Baseline-60 control build: stock crates.io Slint, no vendored fixes

shim: 512 for aarch64-linux-android
Using package `gallery` in `/mnt/Backup/prog/android/gallery/Cargo.toml`
Compiling apk resources...
Linking apk resources...
Optimizing apk resources...
Compiling Java sources...
Creating classes.jar from compiled Java sources...
 'classes.dex'...
 'lib/arm64-v8a/libgallery.so'...
Verifying alignment of /mnt/Backup/prog/android/gallery/target/release/apk/gallery.apk (4)...
      49 AndroidManifest.xml (OK - compressed)
    1072 resources.arsc (OK)
    1153 classes.dex (OK - compressed)
    1635 lib/arm64-v8a/libgallery.so (OK - compressed)
Verification succesful
Signing `/mnt/Backup/prog/android/gallery/target/release/apk/gallery.apk` with keystore `/home/gmp/.android/debug.keystore`
APK: target/release/apk/gallery.apk (512) strips the [patch.crates-io] section so
the whole Slint stack (renderer, backend, core, wgpu-hal) resolves to
the stock 1.18.1 sources: no render-ahead, no vsync-locked render, no
app-owned frame driver, no wake-fd trampoline, no vkshim, no perf
instrumentation. All vendor-internal references in the app are now
#[cfg(feature = "perf")]-gated so the stock build is genuinely stock.

Purpose: the CONTROL experiment for the 120Hz zero-jank campaign.
If the grid still janks on the phone at 60 Hz with this build, the
jank lives in the app / GPU pipeline (drain tick, texture uploads,
model updates), not in the vendored fix stack.

60 Hz lock: NOT via JNI — the public API turned out to be
unavailable (Display.setRefreshRate is hidden-API-blocked;
WindowManagerImpl lacks setPreferredDisplayRefreshRate on both
tested devices). Instead the display is locked with the device's
own user setting:
(restored after the test). The baseline60 feature is a pure
marker; the build is otherwise identical to the app code.

Verified on emulator (x86_64, 60 Hz, SwiftShader): launch, grid
render, thumbnail fill, scroll + prefetch all work.
This commit is contained in:
Greg Pomerantz 2026-10-30 22:05:28 -04:00
parent 32aa2af9cb
commit df4e1bd6f8
6 changed files with 126 additions and 12 deletions

11
Cargo.lock generated
View File

@ -1865,6 +1865,8 @@ dependencies = [
[[package]] [[package]]
name = "i-slint-backend-android-activity" name = "i-slint-backend-android-activity"
version = "1.18.1" version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70d538003a566d7312572ac1435be0704934e46a63d55abdabede513f72332cb"
dependencies = [ dependencies = [
"android-activity 0.5.2", "android-activity 0.5.2",
"android-activity 0.6.1", "android-activity 0.6.1",
@ -1873,8 +1875,6 @@ dependencies = [
"i-slint-core", "i-slint-core",
"i-slint-renderer-skia", "i-slint-renderer-skia",
"jni 0.22.4", "jni 0.22.4",
"libc",
"log",
"ndk 0.9.0", "ndk 0.9.0",
"raw-window-handle", "raw-window-handle",
] ]
@ -2037,6 +2037,8 @@ dependencies = [
[[package]] [[package]]
name = "i-slint-core" name = "i-slint-core"
version = "1.18.1" version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2ea15059d70bc31b1a8b1eb4c34f2c5df04cb7e53f36179b43c0261231d646"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"async-channel", "async-channel",
@ -2123,6 +2125,8 @@ dependencies = [
[[package]] [[package]]
name = "i-slint-renderer-skia" name = "i-slint-renderer-skia"
version = "1.18.1" version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc9c1cf02385f1d26fc1f423544e52390a8e5c2d89420a8042aa94b4fe109d1f"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"cfg-if", "cfg-if",
@ -2134,7 +2138,6 @@ dependencies = [
"i-slint-common", "i-slint-common",
"i-slint-core", "i-slint-core",
"i-slint-core-macros", "i-slint-core-macros",
"log",
"lyon_path", "lyon_path",
"objc2 0.6.5", "objc2 0.6.5",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
@ -5423,6 +5426,8 @@ dependencies = [
[[package]] [[package]]
name = "wgpu-hal" name = "wgpu-hal"
version = "30.0.1" version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
dependencies = [ dependencies = [
"android_system_properties", "android_system_properties",
"arrayvec", "arrayvec",

View File

@ -37,6 +37,11 @@ i-slint-backend-android-activity = { version = "1.18.1" }
# Pure code flag: gates the FR/STG/DECODE file-mirror instrumentation # Pure code flag: gates the FR/STG/DECODE file-mirror instrumentation
# (no dependencies — the backend dep is now unconditional for the fix). # (no dependencies — the backend dep is now unconditional for the fix).
perf = [] 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] [build-dependencies]
slint-build = "1.18.1" slint-build = "1.18.1"

View File

@ -54,11 +54,29 @@ arg1=${1:-x86_64}
arg2=${2:-install} arg2=${2:-install}
profile=--release profile=--release
features=perf
case $arg1 in case $arg1 in
debug) target=x86_64-linux-android; profile="" ;; debug) target=x86_64-linux-android; profile="" ;;
debug-aarch64) target=aarch64-linux-android; profile=""; export ANDROID_ABI=arm64-v8a ;; debug-aarch64) target=aarch64-linux-android; profile=""; export ANDROID_ABI=arm64-v8a ;;
x86_64) target=x86_64-linux-android ;; x86_64) target=x86_64-linux-android ;;
aarch64) target=aarch64-linux-android; export ANDROID_ABI=arm64-v8a ;; 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 ;; *) echo "unknown arch: $arg1" >&2; exit 1 ;;
esac 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 "$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}" 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 apk=target/$profile_dir/apk/gallery.apk
echo "APK: $apk ($(du -h "$apk" | cut -f1))" echo "APK: $apk ($(du -h "$apk" | cut -f1))"

View File

@ -81,7 +81,7 @@ pub mod images;
pub mod scan; pub mod scan;
pub mod thumb; pub mod thumb;
pub mod viewer; pub mod viewer;
#[cfg(target_os = "android")] #[cfg(feature = "perf")]
mod vkshim; mod vkshim;
/// Thread-priority helpers (jank fix): the app process runs at nice -10, /// 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, // update_timers_and_animations, events = process_event,
// poll = full poll_events, lastrender = full do_render of // poll = full poll_events, lastrender = full do_render of
// the previous frame (draw + wgpu surface tail). // 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) = let (s0, s1, s2, _s3, _s4, stm, sev, nev) =
i_slint_backend_android_activity::stg::snapshot(); 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) { fn android_main(app: slint::android::AndroidApp) {
prio::set_self_nice(-10); // Boost UI thread priority to urgent display (-10) prio::set_self_nice(-10); // Boost UI thread priority to urgent display (-10)
nblog::init(); 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 // 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 // edge (not on the input event's random phase) and use the render-ahead
// pipeline (present the previous frame BEFORE preparing the next one), // 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 // phase + 4–10 ms full render) — the measured cause of the 120Hz
// scroll jank on Mali. The backend's event loop activates the // scroll jank on Mali. The backend's event loop activates the
// renderer's render-ahead mode per frame while animating. // 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 i_slint_backend_android_activity::stg::VSYNC_LOCKED_RENDER
.store(true, std::sync::atomic::Ordering::Relaxed); .store(true, std::sync::atomic::Ordering::Relaxed);
// Jank-hunt (`perf` feature): log every event-loop iteration so the // Jank-hunt (`perf` feature): log every event-loop iteration so the

View File

@ -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)") print(f" ... {len(eps)-max_episodes} more episodes (full data in log)")
# --------------------------------------------------------------- the capture # --------------------------------------------------------------- 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": []} rpt = {"valid": True, "notes": []}
# 1. device + identity # 1. device + identity
d = adb("devices").stdout 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 # 2. clean slate: the log can only contain THIS process
adb_shell(f"am force-stop {app}") adb_shell(f"am force-stop {app}")
time.sleep(1) time.sleep(1)
for f in ("it.log", "perf.log"): adb_shell(f"rm -f {log_path}")
adb_shell(f"rm -f /sdcard/Android/data/{app}/files/{f}")
n0 = adb_shell(f"dumpsys activity activities | grep -c '{app}'").stdout.strip() n0 = adb_shell(f"dumpsys activity activities | grep -c '{app}'").stdout.strip()
rpt["activity_records_after_forcestop"] = n0 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) os.makedirs(outdir, exist_ok=True)
name = time.strftime("cap_%H%M%S") + (f"_{tag}" if tag else "") name = time.strftime("cap_%H%M%S") + (f"_{tag}" if tag else "")
dest = os.path.join(outdir, name + ".log") 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): if r.returncode != 0 or not os.path.exists(dest):
print("LOG PULL FAILED:\n", r.stdout, r.stderr) print("LOG PULL FAILED:\n", r.stdout, r.stderr)
return 2 return 2
@ -363,10 +366,12 @@ def main():
ap.add_argument("--tag", default=None, help="build tag for provenance") 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("--tag-file", default=None, help="APK path; sha256 recorded")
ap.add_argument("--outdir", default="/tmp/jank") 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() args = ap.parse_args()
if args.cmd == "capture": if args.cmd == "capture":
sys.exit(capture(args.app, args.drags, args.tag, args.tag_file, 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: else:
if not args.log: if not args.log:
ap.print_usage(); sys.exit(2) ap.print_usage(); sys.exit(2)

View File

@ -231,6 +231,60 @@ impl i_slint_core::platform::Platform for AndroidPlatform {
.store(pe - pr, std::sync::atomic::Ordering::Relaxed); .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 t_start = stg::now_us();
let timeout_ms = timeout.map(|d| d.as_millis() as u64).unwrap_or(u64::MAX); 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); 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 __wake_ok = saw_wake && (__p == 0 || __sw > __sn);
let __accept = __driver || __wake_ok || __backstop; let __accept = __driver || __wake_ok || __backstop;
if __accept { 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 i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS
.store(__now_ns, std::sync::atomic::Ordering::Relaxed); .store(__now_ns, std::sync::atomic::Ordering::Relaxed);
if __sw > last_rendered_slot { if __sw > last_rendered_slot {