Three root causes, all fixed and verified on the Pixel 9 Pro: 1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the render to the vsync wake; RENDER_AHEAD presents the previous frame at the start of render() (phase = wake + ~1.1 ms) and holds the new frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never merely dropped, before the next acquire (AlreadyAcquired panic at launch otherwise); discard_pending_frame presents too. 2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled through a ~20-cell viewport forever (137 decodes/20 s) and each eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes, ~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone cooldown on evicted paths (idle prefetch skips, urgent tap bypasses) + off-thread reaper for the freed blobs (insert: 21 ms -> 4 us). 3. (earlier) Skia final flush + Mailbox present mode (vendor, M3). Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK lines for cross-thread closure timing and push-site tagging. Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact 120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero >33 ms); PRES phase median ~994 us, zero late frames. Docs updated (docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference). Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch), vkshim profiling tooling, backend made a non-optional dep, debuggable flag for simpleperf (REMOVE before shipping).
226 lines
7.4 KiB
Rust
226 lines
7.4 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<()>>,
|
|
}
|
|
|
|
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,
|
|
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();
|
|
Self { shared, 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();
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
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) };
|
|
}
|