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:
parent
01c15795ff
commit
32aa2af9cb
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
35
vendor/i-slint-renderer-skia/wgpu_30_surface.rs
vendored
35
vendor/i-slint-renderer-skia/wgpu_30_surface.rs
vendored
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user