gallery/vendor/i-slint-backend-android-activity-1.18.1/vsync.rs
Greg Pomerantz 7c2d0f354e 120Hz: early waker thread + render freshness guard (vsync-edge pacing)
The Choreographer vsync callback reaches the UI thread only 2.5-6 ms
AFTER the vsync edge (measured on the phone). With a 2.5-5 ms Skia
render plus present, that left no phase margin inside the 8.3 ms slot:
51% of frames landed one slot late (judder) and ~4% two slots late
(hitches), on every build tested so far.

Attempted fixes, all measured and retired (see commit history):
- render-ahead hold (present previous frame at next render start):
  the held present lands after the NEXT wake's 3.2-6 ms Choreographer
  latency -> 52% one-slot late.
- present-at-end + present GATE (sleep on the UI thread to quantize
  the present to the vsync grid): the gate sleep (2.5-4 ms) added to
  the slot budget (wake 5.3 + core 3 + gate 3 + present 1 = 12.3 ms >
  8.3 ms) -> cadence collapsed to 60 Hz.

Working fix (this commit):
- vsync.rs: an 'early waker' thread learns the vsync grid from the
  callback's frame_time_nanos (vkprobe::note_vsync_edge) and wakes the
  main looper ~0.8 ms BEFORE each edge while animating. Render start
  moves from edge+2.5..6 to edge-0.8..+0.5, so the present lands at a
  natural ~edge+3..5 ms phase with no UI-thread sleep.
- lib.rs: a render freshness guard (min gap = period - 1.5 ms) makes
  the FIRST wake of each slot (early or the late Choreographer
  fallback) render and rejects the second (double render = 60 Hz
  collapse). Threshold sits between late-after-early (5.8 ms, reject)
  and early-after-late (7.5 ms, accept).
- lib.rs: locked renders no longer consume pending_redraw (it carried
  the motion invalidations; consuming it starved the follow-up
  unlocked iteration -> 60 Hz collapse).
- stg.rs: IT lines now log the render decision (locked/saw_wake/
  pr_before/do_render); vkprobe logs gate sleep (GATE_US, now 0).

Measured (phone, 8580-file grid, slow 4 s drags):
  in-burst cadence: 8.29-8.44 ms median, p90 8.6-10.2 ms
  (was 8.36 median / 10.2 p90 with 3.7-20% two-slot gaps)
  in-burst >16.7 ms gaps: 0-2 per burst (was 3-13)
  present-to-present: 27% exact one slot, 33% one-slot-late steady
  state (imvisible), 3% f-jumps (Skia core 2.5-5 ms variance).
Residual judder lever: Skia core cost variance (next step).

Test hygiene reminders (HANDOFF 8.10): verify ResumedActivity, keep
the task clean (sz=1), record the perf-log byte offset before each
run (the file accumulates across process launches).
2026-10-30 11:06:58 -04:00

310 lines
11 KiB
Rust

// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
//! Drives animation frames from the display's vsync using the Android Choreographer.
//! Optimized for ultra-low latency: self-re-entrant Choreographer callbacks wake
//! the main loop directly on vsync without thread round-trip serialization delay.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
use crate::android_activity::AndroidAppWaker;
use crate::ndk::looper::{ForeignLooper, ThreadLooper};
mod ffi {
use core::ffi::{c_long, c_void};
#[repr(C)]
pub struct AChoreographer {
_unused: [u8; 0],
}
pub type FrameCallback = unsafe extern "C" fn(frame_time_nanos: c_long, data: *mut c_void);
#[link(name = "android")]
unsafe extern "C" {
pub fn AChoreographer_getInstance() -> *mut AChoreographer;
pub fn AChoreographer_postFrameCallback(
choreographer: *mut AChoreographer,
callback: FrameCallback,
data: *mut c_void,
);
}
}
struct Shared {
state: Mutex<State>,
cond: Condvar,
driving: AtomicBool,
looper: Mutex<Option<ForeignLooper>>,
main_waker: AndroidAppWaker,
choreographer: *mut ffi::AChoreographer,
}
unsafe impl Send for Shared {}
unsafe impl Sync for Shared {}
#[derive(Default)]
struct State {
animating: bool,
quit: bool,
callback_posted: bool,
}
pub struct VsyncDriver {
shared: Arc<Shared>,
handle: Option<JoinHandle<()>>,
waker_handle: Option<JoinHandle<()>>,
}
impl VsyncDriver {
pub fn new(main_waker: AndroidAppWaker) -> Self {
let shared = Arc::new(Shared {
state: Mutex::new(State::default()),
cond: Condvar::new(),
driving: AtomicBool::new(false),
looper: Mutex::new(None),
main_waker: main_waker.clone(),
choreographer: std::ptr::null_mut(),
});
let handle = std::thread::Builder::new()
.name("slint vsync".into())
.spawn({
let shared = shared.clone();
move || vsync_thread(shared)
})
.ok();
// EARLY WAKER (the real 120Hz fix, part 2): the Choreographer
// callback only reaches the UI thread ~2.5-6 ms AFTER the vsync
// edge — with a 2.5-5 ms Skia render plus present, that leaves
// no phase margin inside the 8.3 ms slot (measured: 51% of frames
// landed one slot late). This thread learns the vsync grid from
// the callback's `frame_time_nanos` (vkprobe::note_vsync_edge) and
// wakes the main loop slightly BEFORE each edge, so render start ≈
// the edge and present lands at a constant ~edge+3 ms phase.
// The Choreographer's late wake remains as fallback (the render
// freshness guard in the event loop prevents double renders).
let waker_handle = std::thread::Builder::new()
.name("slint early-waker".into())
.spawn({
let shared = shared.clone();
move || early_waker_thread(shared)
})
.ok();
Self {
shared,
handle,
waker_handle,
}
}
pub fn is_driving(&self) -> bool {
self.shared.driving.load(Ordering::Acquire)
}
pub fn set_animating(&self, animating: bool) {
let mut state = self.shared.state.lock().unwrap();
if state.animating != animating {
state.animating = animating;
drop(state);
if animating {
// Wake the vsync thread: it may be parked in `poll_once` (the
// Choreographer queue drained once the previous animation's
// self-sustaining callback chain ended). Waking the looper is
// what makes it re-check `state.animating` and post the first
// frame callback for this new animation.
if let Some(looper) = self.shared.looper.lock().unwrap().as_ref() {
looper.wake();
}
}
self.shared.cond.notify_one();
}
}
}
impl Drop for VsyncDriver {
fn drop(&mut self) {
let mut state = self.shared.state.lock().unwrap();
state.quit = true;
drop(state);
self.shared.cond.notify_one();
if let Some(looper) = self.shared.looper.lock().unwrap().as_ref() {
looper.wake();
}
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
if let Some(handle) = self.waker_handle.take() {
let _ = handle.join();
}
}
}
/// Wakes the main loop a few hundred microseconds before each vsync edge
/// (see the `early_waker_handle` docs in `VsyncDriver::new`). Runs at 120 Hz
/// only while animating; otherwise parks in short sleeps.
fn early_waker_thread(shared: Arc<Shared>) {
let waker = shared.main_waker.clone();
// Wake slightly BEFORE the edge so poll-returns + render-start lands on
// the edge; with a 2.5-5 ms Skia render the present then sits at a
// constant ~edge+3 ms (middle of the slot, ~2.5 ms of deadline margin
// both ways).
const LEAD_NS: u64 = 800_000;
loop {
{
let state = shared.state.lock().unwrap();
if state.quit {
return;
}
if !state.animating {
drop(state);
std::thread::sleep(std::time::Duration::from_millis(5));
continue;
}
}
let edge = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS
.load(Ordering::Relaxed);
let period = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS
.load(Ordering::Relaxed);
let now = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns();
// Grid unknown or stale (no callbacks for ~100 ms, e.g. the app
// went to the background): park briefly and re-check.
if edge == 0 || period == 0 || now == 0 || now > edge + 100_000_000 {
std::thread::sleep(std::time::Duration::from_millis(10));
continue;
}
// Next edge at/after (now + LEAD).
let mut target = edge + period;
while target < now.saturating_add(LEAD_NS) {
target += period;
}
let delay = target.saturating_sub(now + LEAD_NS);
if delay > 0 {
std::thread::sleep(std::time::Duration::from_nanos(delay));
}
let landed = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns()
.saturating_sub(target);
// Wake only if we landed within ~1 ms of the edge. If we overshot
// (CPU starvation), the Choreographer's late wake (~5 ms after the
// edge) covers the frame instead.
if landed <= 1_000_000 {
waker.wake();
}
}
}
// Jank-hunt: frame-alignment probes. `frame_time_nanos` is the Choreographer
// vsync timestamp (System.nanoTime epoch = CLOCK_MONOTONIC on Android); we
// ALSO stamp CLOCK_MONOTONIC here so VSYNC and PRES lines share one clock and
// we can measure, per presented frame, how far past the vsync edge it landed
// (phase). If presents systematically land in the back half of the 8.3 ms
// window the compositor shows them one vsync late → effective 60 Hz + judder
// even though our frame-to-frame cadence looks like 120 Hz.
unsafe fn mon_ns() -> u64 {
#[repr(C)]
struct Ts {
tv_sec: i64,
tv_nsec: i64,
}
unsafe extern "C" {
fn clock_gettime(clkid: i32, tp: *mut Ts) -> i32;
}
const CLOCK_MONOTONIC: i32 = 1;
let mut ts = Ts { tv_sec: 0, tv_nsec: 0 };
if unsafe { clock_gettime(CLOCK_MONOTONIC, &mut ts) } == 0 {
ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64
} else {
0
}
}
unsafe extern "C" fn frame_callback(
frame_time_nanos: core::ffi::c_long,
data: *mut core::ffi::c_void,
) {
let shared = &*(data as *const Shared);
// CRITICAL PATH: wake the main loop FIRST. The VSYNC log line below is
// a file write (50-200us typical, worse under I/O pressure) — doing it
// before the wake would push the main loop's render start (and thus the
// present phase) later in the slot.
shared.main_waker.wake();
// PRESENT GATE: publish the exact vsync edge (CLOCK_MONOTONIC ns — the
// same epoch as `mon_ns()` above) so the renderer can quantize its
// `Queue::present` to the vsync grid (wgpu_30_surface::vkprobe).
i_slint_renderer_skia::wgpu_30_surface::vkprobe::note_vsync_edge(
frame_time_nanos as u64,
);
let mon = unsafe { mon_ns() };
if mon > 0 {
log::info!("VSYNC ftn={} mon={}", frame_time_nanos, mon);
}
let mut state = shared.state.lock().unwrap();
if state.animating && !state.quit {
unsafe {
ffi::AChoreographer_postFrameCallback(
shared.choreographer,
frame_callback,
data,
);
}
} else {
state.callback_posted = false;
}
}
fn vsync_thread(shared: Arc<Shared>) {
let looper = ThreadLooper::prepare();
*shared.looper.lock().unwrap() = Some(looper.as_foreign().clone());
let choreographer = unsafe { ffi::AChoreographer_getInstance() };
if choreographer.is_null() {
return;
}
let ptr = Arc::as_ptr(&shared) as *mut Shared;
unsafe {
(*ptr).choreographer = choreographer;
}
shared.driving.store(true, Ordering::Release);
let raw_shared = Arc::into_raw(shared);
loop {
{
let mut state = unsafe { &(*raw_shared).state }.lock().unwrap();
if state.quit {
break;
}
// Post the FIRST frame callback ONLY when we're animating AND no
// callback is already pending. While animating, `frame_callback`
// re-posts itself on every vsync (self-sustaining chain), so a
// callback is always pending — we must NOT post another here, or
// the pending-callback count grows by one every vsync (1, 2, 3,
// 4…) and each vsync fires N callbacks → N main-loop wakes with N
// unbounded. That was the ~1600 iters/s busy-spin. See
// docs/BUGS/scroll-jank-120hz.md.
if state.animating && !state.callback_posted {
state.callback_posted = true;
unsafe {
ffi::AChoreographer_postFrameCallback(
choreographer,
frame_callback,
raw_shared as *mut core::ffi::c_void,
);
}
}
}
// Block until the next Choreographer message (a vsync fires the frame
// callback) or a wake (set_animating/quit). While animating the
// self-sustaining chain keeps delivering one message per vsync; when
// the animation stops the chain ends and this blocks until the next
// animation's set_animating(true) wakes the looper.
let _ = looper.poll_once();
}
let _ = unsafe { Arc::from_raw(raw_shared) };
}