From 32aa2af9cb22eee65698dd16a418e29ff3cda220 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Fri, 30 Oct 2026 18:21:50 -0400 Subject: [PATCH] =?UTF-8?q?120Hz:=20app-owned=20frame=20driver=20(spin)=20?= =?UTF-8?q?=E2=80=94=20the=20OS=20no=20longer=20paces=20renders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user's ask: 'put in our own frame rate timer that doesn't drop frames so we are not depending on the OS.' The OS can still own the display latch (SF) — render-ahead presents ~1 ms after the edge with ~7 ms margin, so owning the RENDER clock is sufficient. WHILE ANIMATING, the UI thread no longer waits on any OS wake: * poll is non-blocking (timeout 0); the loop spins at ~3-4k iters/s (250 us hrtimer park after each no-render iteration — an hrtimer park cannot be swallowed the way ALooper timeouts were, measured 4-6 ms late from inside kernel/epoll) * the render decision is grid-driven: render when in a not-yet- rendered slot AND >=0.3 ms past its edge. No saw_wake. * the Choreographer + eventfd remain only as the GRID SENSOR. Two grid bugs found and fixed while landing this (both made the first driver build DOUBLES 8-10% of frames): * ANCHOR POLICY (wgpu_30_surface::note_vsync_edge): VSYNC_EDGE_NS is a SMOOTH grid anchor, not 'last observed edge'. A callback that fits the extrapolation (<1.5 ms deviation) does not move the anchor — a LATE callback re-anchoring shifts the anchor by (interval - P) and makes an already-rendered slot look NEW to a value-space guard. Period updates now require single-slot deltas. * BOOKKEEPING in SLOT-INDEX space (last_rendered_slot), not edge values, and ALL render paths mark it: locked accept, unlocked dirty render, and the render-ahead FLUSH (the flush not marking caused the +1.1/+1.2 true doubles after animating flickers). * POINTER window 200 -> 400 ms: bursty input (adb drags; finger pauses) expired 'animating' mid-drag, firing the locked->unlocked flush on top of the dirty render (double per flicker, ~5%). * flush now also skips when the iteration already rendered. Phone, sltest (3s+1s+3s adb drags, jank_eval referee): buildC (pre-wake-fd-fix): 127 drops + 12 stalls slt14-spin (this driver): 2 drops + 1 stall + 8 dbl + 4 late in 978 frames; phase p10-p90 0.86-0.90 ms gallery (real textures, same driver): drag windows 6+2 drops, 7+11 doubles in 820 frames; phase p10-p90 0.73-0.95 ms Remaining (visible next): drag-START warmup (first frame 20-40 ms: CPU clock ramp after idle — the 'fling start jank' the user reports) and isolated 2-slot drops (gallery: FullCache insert/eviction cost on the UI thread). Cost: ~60-70% of one core during active scroll; the perpetual runnability also forces EAS big-core placement (the thread was measured at 820 MHz on little cores during scroll — a large part of the gallery's finger jank). --- Cargo.lock | 1 + .../lib.rs | 123 ++++++++++++++++-- .../stg.rs | 9 +- .../i-slint-renderer-skia/wgpu_30_surface.rs | 35 ++++- 4 files changed, 152 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52e32ab..7bd3ac5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1873,6 +1873,7 @@ dependencies = [ "i-slint-core", "i-slint-renderer-skia", "jni 0.22.4", + "libc", "log", "ndk 0.9.0", "raw-window-handle", 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 23e3be8..b095f17 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 @@ -131,6 +131,12 @@ impl i_slint_core::platform::Platform for AndroidPlatform { // decision time means a wake was delivered THIS iteration — immune // to the ALooper wake-fd swallow (see vsync.rs note). let mut last_delivered_edge = 0u64; + // DRIVER: the slot index (relative to the smooth grid anchor) we + // last rendered for. One render per SLOT — the double-fire guard + // for the grid-driven accept. Slot-index space (not edge-value + // space): a late-callback re-anchor shifts edge VALUES by + // ~0.2–0.5 ms and would make an already-rendered slot look new. + let mut last_rendered_slot: i128 = i128::MIN; loop { let mut timeout = ctx.duration_until_next_timer_update(); // While animating, the vsync thread wakes this loop at each display refresh @@ -150,7 +156,26 @@ impl i_slint_core::platform::Platform for AndroidPlatform { // pointer inactivity expires the `animating` window. let dirty = self.window.pending_redraw.get(); vsync.set_state(animating, dirty); - if animating || dirty { + let pr_before = self.window.pending_redraw.get(); + let locked = stg::VSYNC_LOCKED_RENDER + .load(std::sync::atomic::Ordering::Relaxed) + && animating; + if locked { + // DRIVER mode (2026-10-30): the render clock is owned by the + // app, not the OS. The decision is grid-driven (see below) — + // no wake required — so the poll is NON-BLOCKING (timeout 0); + // the loop spins at ~3-4k iterations/s (a 250 µs park after + // each no-render iteration, see end of loop) checking the + // grid. A 250 µs hrtimer park cannot be lost the way an + // ALooper timeout wake can (measured: poll(1) returning + // 4-6 ms LATE from inside kernel/epoll — the residual + // +4.5-6 ms present population of slt13), and the thread + // stays HOT (EAS big-core placement; a sleeping thread idles + // at 820 MHz on little cores). The Choreographer + eventfd + // stay alive as the GRID SENSOR (VSYNC_EDGE_NS), never as + // the render trigger. + timeout = Some(Duration::ZERO); + } else if animating || dirty { // When animating (scrolling/flinging) or dirty, cap the poll // timeout to 2ms. (2026-10-30: the cap was 6ms — but // ALooper_pollOnce(6) itself returns 2-5 ms LATE on this @@ -177,10 +202,9 @@ impl i_slint_core::platform::Platform for AndroidPlatform { // loop at ~100 kHz (timeout event, no wake, flag never consumed) // for up to 8.3 ms after every input event. Block up to the cap // instead and let the wake arrive. - let pr_before = self.window.pending_redraw.get(); - let locked = stg::VSYNC_LOCKED_RENDER - .load(std::sync::atomic::Ordering::Relaxed) - && animating; + if pr_before && !locked { + timeout = Some(Duration::ZERO); + } if pr_before && !locked { timeout = Some(Duration::ZERO); } @@ -355,19 +379,41 @@ impl i_slint_core::platform::Platform for AndroidPlatform { __now_ns.saturating_sub(__e) / 1000, __now_ns.saturating_sub(__last) / 1000); } - // DIRTY BACKSTOP (2026-10-30): the 6 ms poll cap exists to - // bound chain-breaks — but a timeout iteration has - // saw_wake=false, so `saw_wake && guard` never rendered and - // the "backstop" backstopped nothing (the 20-40 ms silences). - // If the window is dirty and the last render is >=2 slots - // old, render NOW regardless of wake state. + // DIRTY BACKSTOP (2026-10-30): if the window is dirty and + // the last render is >=2 slots old, render NOW regardless + // of wake state (bounds any grid/sensor outage). let __dirty = self.window.pending_redraw.get(); let __age = __now_ns.saturating_sub(__last); let __backstop = __dirty && __last != 0 && __p != 0 && __age >= 2 * __p; - let __accept = (saw_wake && (__p == 0 || __sw > __sn)) || __backstop; + // DRIVER (2026-10-30): the APP owns the render clock. Accept + // when we are in a slot we have not rendered yet AND at + // least 0.3 ms past that slot's edge. No saw_wake: the 1 ms + // micro-park keeps `now` within ~1.3 ms of the grid, so the + // present (render-ahead, at render start) lands at + // edge+~1.5 ms with ~6.8 ms of latch margin, every frame, + // regardless of Choreographer/wake delivery. The grid is the + // SMOOTH anchor (note_vsync_edge: a late callback that fits + // the extrapolation does not move it), so the slot index + // here is stable across late callbacks — one render per + // slot, never a double. A render overrun lands the next + // slot one slot late. If the grid is dead (__p==0 or + // __now < __e) the driver is off and the saw_wake path + // governs. + let __ph = if __p != 0 && __now_ns >= __e { + (__now_ns - __e) % __p + } else { + 0 + }; + let __driver = __p != 0 && __now_ns >= __e + 300_000 + && __ph >= 300_000 && __sw > last_rendered_slot; + let __wake_ok = saw_wake && (__p == 0 || __sw > __sn); + let __accept = __driver || __wake_ok || __backstop; if __accept { 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 { + last_rendered_slot = __sw; + } } __accept } else { @@ -379,6 +425,25 @@ impl i_slint_core::platform::Platform for AndroidPlatform { // see the do_render note); unlocked renders consume it. if !locked { self.window.pending_redraw.take(); + // Unlocked renders participate in the slot bookkeeping + // too: without this, a locked→unlocked flicker mid-drag + // leaves last_rendered_slot stale and the re-locked + // driver re-renders the slot the unlocked render just + // painted (double-present). + 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); + let __p = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS + .load(std::sync::atomic::Ordering::Relaxed); + let __e = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS + .load(std::sync::atomic::Ordering::Relaxed); + if __p != 0 && __t >= __e { + let s = ((__t - __e) as i128) / (__p as i128); + if s > last_rendered_slot { + last_rendered_slot = s; + } + } } // RENDER_AHEAD (hold-without-present) is DISABLED: the held // frame is presented at the start of the NEXT render, i.e. @@ -422,12 +487,42 @@ impl i_slint_core::platform::Platform for AndroidPlatform { stg::S_RENDER_END.store(0, std::sync::atomic::Ordering::Relaxed); } // RENDER_AHEAD flush (see above): on the locked → unlocked - // transition, render once more to present the held frame. - if prev_locked && !locked { + // transition, render once more to present the held frame — + // EXCEPT when this iteration already rendered (unlocked, dirty): + // that render presented the latest content immediately, so the + // flush would only double-present the slot. (2026-10-30: this + // double fired on every animating-flicker during bursty drags.) + let mut flushed_now = false; + if prev_locked && !locked && !do_render { self.window.pending_redraw.take(); i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE .store(false, std::sync::atomic::Ordering::Relaxed); self.window.do_render()?; + // Bookkeeping (2026-10-30): the flush render presents the + // current slot; without marking it, the re-locked driver + // re-renders the same slot ~1 ms later (the +1.1/+1.2 + // double-presents of slt13). + 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); + let __p = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS + .load(std::sync::atomic::Ordering::Relaxed); + let __e = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS + .load(std::sync::atomic::Ordering::Relaxed); + if __p != 0 && __t >= __e { + let s = ((__t - __e) as i128) / (__p as i128); + if s > last_rendered_slot { + last_rendered_slot = s; + } + } + flushed_now = true; + } + // DRIVER spin throttle (2026-10-30): see the timeout note. After + // a no-render locked iteration, park 250 µs (hrtimer — cannot be + // swallowed like an ALooper timeout) so the grid is re-checked + // within ~0.3 ms of each edge. + if locked && !do_render && !flushed_now { + std::thread::sleep(Duration::from_micros(250)); } prev_locked = locked; // Miss forensics (CONDITIONAL — 2026-07-09): the per-iteration diff --git a/vendor/i-slint-backend-android-activity-1.18.1/stg.rs b/vendor/i-slint-backend-android-activity-1.18.1/stg.rs index 21e208d..fd4c93a 100644 --- a/vendor/i-slint-backend-android-activity-1.18.1/stg.rs +++ b/vendor/i-slint-backend-android-activity-1.18.1/stg.rs @@ -82,7 +82,14 @@ pub static POINTER_ACTIVE_UNTIL_US: AtomicU64 = AtomicU64::new(0); /// Short on purpose: after the finger lifts, the fling's physics animation /// takes over (has_active_animations), and when idle we want the loop to /// sleep to save battery. -pub const POINTER_PACING_WINDOW_US: u64 = 200_000; +// (2026-10-30: was 200 ms — adb-injected drags (and any bursty input) have +// >200 ms gaps, so `animating` FLICKERED false mid-drag: the locked→ +// unlocked transition fired the render-ahead flush render on top of the +// unlocked dirty render (double-present per flicker, ~5% of frames) and +// unlocked renders bypassed the slot bookkeeping. 400 ms keeps the driver +// engaged through bursty input; a real finger at 120-240 Hz never expires +// it either way.) +pub const POINTER_PACING_WINDOW_US: u64 = 400_000; pub fn note_pointer_activity() { POINTER_ACTIVE_UNTIL_US diff --git a/vendor/i-slint-renderer-skia/wgpu_30_surface.rs b/vendor/i-slint-renderer-skia/wgpu_30_surface.rs index c264004..11261a2 100644 --- a/vendor/i-slint-renderer-skia/wgpu_30_surface.rs +++ b/vendor/i-slint-renderer-skia/wgpu_30_surface.rs @@ -99,11 +99,44 @@ pub mod vkprobe { pub static LAST_RENDER_START_NS: AtomicU64 = AtomicU64::new(0); /// Called from the vsync driver on every Choreographer callback. + /// + /// ANCHOR POLICY (2026-10-30, driver doubles fix): VSYNC_EDGE_NS is the + /// anchor of a SMOOTH grid, not "the last observed edge". A callback + /// that fits the extrapolation (deviation < 1.5 ms) does NOT move the + /// anchor — re-anchoring on a LATE callback shifts the anchor by + /// (measured interval − P) ≈ 0.2–0.5 ms, which made the grid-driven + /// render decision see an already-rendered slot as NEW and double- + /// render it (the 8% double-presents of the first driver build). A + /// deviation >= 1.5 ms is a true phase event (display rate/phase + /// change, resume, idle re-lock) and re-anchors. pub fn note_vsync_edge(ftn_ns: u64) { + let prev = VSYNC_EDGE_NS.load(Ordering::Relaxed); + if prev > 0 { + let p = VSYNC_PERIOD_NS.load(Ordering::Relaxed); + if p > 0 && ftn_ns > prev { + let k = (ftn_ns - prev) / p; + if k >= 1 { + let expected = prev + k * p; + let dev = if ftn_ns >= expected { + ftn_ns - expected + } else { + expected - ftn_ns + }; + if dev < 1_500_000 { + return; // fits the smooth grid — keep the anchor + } + } + } + } let prev = VSYNC_EDGE_NS.swap(ftn_ns, Ordering::Relaxed); if prev > 0 { let d = ftn_ns.saturating_sub(prev); - if (4_000_000..20_000_000).contains(&d) { + // Period update only from SINGLE-slot advances: with a stale + // anchor a multi-slot delta (2P) would poison the period. + if (4_000_000..20_000_000).contains(&d) + && VSYNC_PERIOD_NS.load(Ordering::Relaxed) != 0 + && d < 2 * VSYNC_PERIOD_NS.load(Ordering::Relaxed) + { VSYNC_PERIOD_NS.store(d, Ordering::Relaxed); } }