120Hz: app-owned frame driver (spin) — the OS no longer paces renders

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).
This commit is contained in:
Greg Pomerantz 2026-10-30 18:21:50 -04:00
parent 01c15795ff
commit 32aa2af9cb
4 changed files with 152 additions and 16 deletions

1
Cargo.lock generated
View File

@ -1873,6 +1873,7 @@ 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", "log",
"ndk 0.9.0", "ndk 0.9.0",
"raw-window-handle", "raw-window-handle",

View File

@ -131,6 +131,12 @@ impl i_slint_core::platform::Platform for AndroidPlatform {
// decision time means a wake was delivered THIS iteration — immune // decision time means a wake was delivered THIS iteration — immune
// to the ALooper wake-fd swallow (see vsync.rs note). // to the ALooper wake-fd swallow (see vsync.rs note).
let mut last_delivered_edge = 0u64; 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 { loop {
let mut timeout = ctx.duration_until_next_timer_update(); let mut timeout = ctx.duration_until_next_timer_update();
// While animating, the vsync thread wakes this loop at each display refresh // 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. // pointer inactivity expires the `animating` window.
let dirty = self.window.pending_redraw.get(); let dirty = self.window.pending_redraw.get();
vsync.set_state(animating, dirty); 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 // When animating (scrolling/flinging) or dirty, cap the poll
// timeout to 2ms. (2026-10-30: the cap was 6ms — but // timeout to 2ms. (2026-10-30: the cap was 6ms — but
// ALooper_pollOnce(6) itself returns 2-5 ms LATE on this // 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) // 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 // for up to 8.3 ms after every input event. Block up to the cap
// instead and let the wake arrive. // instead and let the wake arrive.
let pr_before = self.window.pending_redraw.get(); if pr_before && !locked {
let locked = stg::VSYNC_LOCKED_RENDER timeout = Some(Duration::ZERO);
.load(std::sync::atomic::Ordering::Relaxed) }
&& animating;
if pr_before && !locked { if pr_before && !locked {
timeout = Some(Duration::ZERO); 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(__e) / 1000,
__now_ns.saturating_sub(__last) / 1000); __now_ns.saturating_sub(__last) / 1000);
} }
// DIRTY BACKSTOP (2026-10-30): the 6 ms poll cap exists to // DIRTY BACKSTOP (2026-10-30): if the window is dirty and
// bound chain-breaks — but a timeout iteration has // the last render is >=2 slots old, render NOW regardless
// saw_wake=false, so `saw_wake && guard` never rendered and // of wake state (bounds any grid/sensor outage).
// 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.
let __dirty = self.window.pending_redraw.get(); let __dirty = self.window.pending_redraw.get();
let __age = __now_ns.saturating_sub(__last); let __age = __now_ns.saturating_sub(__last);
let __backstop = __dirty && __last != 0 && __p != 0 && __age >= 2 * __p; 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 { if __accept {
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 {
last_rendered_slot = __sw;
}
} }
__accept __accept
} else { } else {
@ -379,6 +425,25 @@ impl i_slint_core::platform::Platform for AndroidPlatform {
// see the do_render note); unlocked renders consume it. // see the do_render note); unlocked renders consume it.
if !locked { if !locked {
self.window.pending_redraw.take(); 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 // RENDER_AHEAD (hold-without-present) is DISABLED: the held
// frame is presented at the start of the NEXT render, i.e. // 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); stg::S_RENDER_END.store(0, std::sync::atomic::Ordering::Relaxed);
} }
// RENDER_AHEAD flush (see above): on the locked → unlocked // RENDER_AHEAD flush (see above): on the locked → unlocked
// transition, render once more to present the held frame. // transition, render once more to present the held frame —
if prev_locked && !locked { // 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(); self.window.pending_redraw.take();
i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE
.store(false, std::sync::atomic::Ordering::Relaxed); .store(false, std::sync::atomic::Ordering::Relaxed);
self.window.do_render()?; 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; prev_locked = locked;
// Miss forensics (CONDITIONAL — 2026-07-09): the per-iteration // Miss forensics (CONDITIONAL — 2026-07-09): the per-iteration

View File

@ -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 /// 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 /// takes over (has_active_animations), and when idle we want the loop to
/// sleep to save battery. /// 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() { pub fn note_pointer_activity() {
POINTER_ACTIVE_UNTIL_US POINTER_ACTIVE_UNTIL_US

View File

@ -99,11 +99,44 @@ pub mod vkprobe {
pub static LAST_RENDER_START_NS: AtomicU64 = AtomicU64::new(0); pub static LAST_RENDER_START_NS: AtomicU64 = AtomicU64::new(0);
/// Called from the vsync driver on every Choreographer callback. /// 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) { 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); let prev = VSYNC_EDGE_NS.swap(ftn_ns, Ordering::Relaxed);
if prev > 0 { if prev > 0 {
let d = ftn_ns.saturating_sub(prev); 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); VSYNC_PERIOD_NS.store(d, Ordering::Relaxed);
} }
} }