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).
This commit is contained in:
Greg Pomerantz 2026-10-30 11:06:58 -04:00
parent 99d155e859
commit 7c2d0f354e
4 changed files with 264 additions and 16 deletions

View File

@ -268,22 +268,91 @@ impl i_slint_core::platform::Platform for AndroidPlatform {
// to one slot of extra input latency.
let do_render = if locked {
// Present EVERY vsync while animating (not only on content
// change): the regular 120 Hz present stream keeps the Mali
// present path warm (PRE ≈ 0.4 ms) and locks the display
// cadence. Re-rendering unchanged content is cheap with
// Skia's incremental pipeline.
self.window.pending_redraw.take();
saw_wake
// change): the regular 120 Hz present stream locks the
// display cadence. Re-rendering unchanged content is cheap
// with Skia's incremental pipeline.
//
// IMPORTANT: do NOT `pending_redraw.take()` here. The render
// is driven by the vsync wake, but the pending flag also
// carries the motion events' invalidations. Consuming it
// here starves the follow-up unlocked iteration (which runs
// 0.4 ms later, before the next motion event arrives) of
// anything to render, collapsing the cadence to 60 Hz
// (one render per two vsyncs). Letting the flag survive
// means the next iteration renders too; if the content is
// unchanged the extra render is invisible.
//
// FRESHNESS GUARD: with the early waker (vsync.rs), TWO
// wakes can arrive per slot — the early one (~0.8 ms BEFORE
// the vsync edge) and the Choreographer's late fallback
// (~5 ms after it). The first one of each slot renders;
// the second must NOT (double render = 2× the slot budget =
// 60 Hz collapse). The intervals (measured):
// late-after-early = ~5.8 ms → must be REJECTED
// early-after-late = ~7.5 ms → must be ACCEPTED
// same-kind = ~8.33 ms → ACCEPTED
// Threshold `period − 1.5 ms` (≈6.8 ms) sits between 5.8
// and 7.5 with ~0.7-1.0 ms of jitter margin on both sides.
// (A 0.7-period threshold rejects early-after-late and a
// 1.0-period threshold locks the cadence onto the late
// wakes — both measured to fragment the burst.)
let __now_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns();
let __period_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS
.load(std::sync::atomic::Ordering::Relaxed);
let __last = i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS
.load(std::sync::atomic::Ordering::Relaxed);
let __min_gap = __period_ns.saturating_sub(1_500_000);
let __fresh = __period_ns == 0
|| __now_ns.saturating_sub(__last) >= __min_gap;
if __fresh {
i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS
.store(__now_ns, std::sync::atomic::Ordering::Relaxed);
}
saw_wake && __fresh
} else {
self.window.pending_redraw.get()
};
if do_render {
self.window.pending_redraw.take();
// RENDER_AHEAD: activate the hold-without-present pipeline
// only for locked (animating) renders; an unlocked render
// presents immediately (see RENDER_AHEAD_ACTIVE).
// Locked (vsync-wake) renders leave the flag set so the
// follow-up unlocked iteration renders too (60 Hz collapse,
// see the do_render note); unlocked renders consume it.
if !locked {
self.window.pending_redraw.take();
}
// RENDER_AHEAD (hold-without-present) is DISABLED: the held
// frame is presented at the start of the NEXT render, i.e.
// after the next vsync wake's Choreographer latency
// (measured 3.2–6 ms on this device) plus the present cost —
// landing at vsync+12.4–15.2 ms against a 13.8 ms latch
// deadline: a 1.4 ms margin that missed 52% of frames
// (one slot late) during real slow scrolls. Presenting at
// the END of the render instead still left 51% one slot
// late (the wake-arrival latency 2.4–6 ms + render 3–9 ms
// make the present phase random). The actual fix is the
// PRESENT GATE: the render still runs on the vsync wake
// (locked pacing kept), but `Queue::present` sleeps to a
// fixed vsync-grid phase (next edge + 1.5 ms, from the
// Choreographer frameTimeNanos), so cadence is exactly one
// period. See `vkprobe::present_gate` and
// docs/BUGS/scroll-jank-120hz.md §9.
i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE
.store(locked, std::sync::atomic::Ordering::Relaxed);
.store(false, std::sync::atomic::Ordering::Relaxed);
// PRESENT GATE: quantize the present to the vsync grid while
// animating (drag/fling) — including the follow-up UNLOCKED
// renders (see the do_render note above), so every present
// in the burst lands on the same grid phase. One-off
// redraws (taps, model updates) stay ungated for minimum
// latency.
// PRESENT GATE: DISABLED. Sleeping on the UI thread to
// quantize the present proved counterproductive — every ms
// of gate sleep adds to the slot budget (wake 5.3 + core 3
// + gate 3 + present 1 = 12.3 ms > 8.3 ms slot → 60 Hz
// collapse, measured). The early waker (vsync.rs) instead
// moves the RENDER START to the vsync edge, so the present
// lands at a constant natural phase (~edge+3 ms) with no
// sleep at all. The gate code stays for reference.
i_slint_renderer_skia::wgpu_30_surface::vkprobe::PRESENT_GATE_ON
.store(false, std::sync::atomic::Ordering::Relaxed);
stg::S_RENDER_START.store(t2, std::sync::atomic::Ordering::Relaxed);
self.window.do_render()?;
let t3 = stg::now_us();
@ -320,6 +389,7 @@ impl i_slint_core::platform::Platform for AndroidPlatform {
stg::input_snapshot(),
stg::timeout_snapshot(),
stg::wake_snapshot(),
(locked, saw_wake, pr_before, do_render),
);
}
}

View File

@ -241,6 +241,7 @@ pub fn log_iter(
inp: (u32, u64, u64),
tocls: (u64, u64, u64),
wk: (u64, u64, u64),
dec: (bool, bool, bool, bool),
) {
// Jank-hunt fix: emit through the log facade (nblog) instead of a private
// BufWriter — two independent buffers appending to the same file made IT
@ -249,9 +250,10 @@ pub fn log_iter(
let live_rows = i_slint_core::model::repeater::LV_LIVE_ROWS
.load(std::sync::atomic::Ordering::Relaxed);
log::info!(
"IT {} poll={}us nev={} fe={}us timers={}us ev={}us render={}us anim={} to={}ms lv1={}/{} e={}/{}/{} ent={}/{} lv2={}/{} flk={}/{} inb={}/{} toc={}/{}/{} wk={}/{}/{} live={}",
"IT {} poll={}us nev={} fe={}us timers={}us ev={}us render={}us anim={} to={}ms lv1={}/{} e={}/{}/{} ent={}/{} lv2={}/{} flk={}/{} inb={}/{} toc={}/{}/{} wk={}/{}/{} live={} dec={}/{}/{}/{}",
start_us, poll_us, nev, first_ev_us, timer_us, event_us, render_us, animating as u8, timeout_ms,
lv1.0, lv1.1, lv1.2, lv1.3, lv1.4, lv1.5, lv1.6, lv2.0, lv2.1, flk.0, flk.1, inp.0, inp.2, tocls.0, tocls.1, tocls.2, wk.0, wk.1, wk.2, live_rows
lv1.0, lv1.1, lv1.2, lv1.3, lv1.4, lv1.5, lv1.6, lv2.0, lv2.1, flk.0, flk.1, inp.0, inp.2, tocls.0, tocls.1, tocls.2, wk.0, wk.1, wk.2, live_rows,
dec.0 as u8, dec.1 as u8, dec.2 as u8, dec.3 as u8
);
}

View File

@ -54,6 +54,7 @@ struct State {
pub struct VsyncDriver {
shared: Arc<Shared>,
handle: Option<JoinHandle<()>>,
waker_handle: Option<JoinHandle<()>>,
}
impl VsyncDriver {
@ -63,7 +64,7 @@ impl VsyncDriver {
cond: Condvar::new(),
driving: AtomicBool::new(false),
looper: Mutex::new(None),
main_waker,
main_waker: main_waker.clone(),
choreographer: std::ptr::null_mut(),
});
let handle = std::thread::Builder::new()
@ -73,7 +74,28 @@ impl VsyncDriver {
move || vsync_thread(shared)
})
.ok();
Self { shared, handle }
// 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 {
@ -112,6 +134,62 @@ impl Drop for VsyncDriver {
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();
}
}
}
@ -150,6 +228,12 @@ unsafe extern "C" fn frame_callback(
// 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);

View File

@ -61,6 +61,88 @@ pub mod vkprobe {
/// renders coming) and never reach the screen.
pub static RENDER_AHEAD_ACTIVE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
// ------------------------------------------------------------------
// PRESENT GATE (vsync-grid quantization, the real 120Hz fix).
//
// Measured on the phone: the Choreographer vsync callback reaches the
// UI thread 2.4–6 ms (median 3.3 ms) after the vsync edge, and the
// render takes 3–9 ms — so each frame's present lands at a RANDOM
// phase 8–14 ms after the edge against a 13.8 ms latch deadline.
// 51% of frames land one slot late (judder) and ~4% two slots late
// (visible hitch), independent of where in the render() call the
// present happens.
//
// Fix: quantize the PRESENT to the vsync grid. The vsync driver hands
// us the exact edge (Choreographer `frame_time_nanos`, CLOCK_MONOTONIC)
// via `note_vsync_edge`; before `Queue::present` we sleep (≤ ~2 ms) so
// the present lands at (next edge + 1.5 ms) — constant phase, ~4 ms of
// deadline margin, frame-to-frame gaps of exactly one period. See
// docs/BUGS/scroll-jank-120hz.md §9.
/// Latest Choreographer `frame_time_nanos` (CLOCK_MONOTONIC ns).
pub static VSYNC_EDGE_NS: AtomicU64 = AtomicU64::new(0);
/// Detected vsync period in ns (from consecutive callback deltas).
pub static VSYNC_PERIOD_NS: AtomicU64 = AtomicU64::new(0);
/// Gate enabled for the current render (set per-frame by the event
/// loop; true while animating/locked, false for one-off redraws so
/// taps and model updates keep minimum latency).
pub static PRESENT_GATE_ON: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Microseconds the most recent `present_gate()` slept (0 = no sleep).
pub static GATE_US: AtomicU64 = AtomicU64::new(0);
/// CLOCK_MONOTONIC ns when the last vsync-wake (locked) render STARTED.
/// The event loop uses it to skip a second wake (e.g. the Choreographer's
/// late fallback wake arriving ~5 ms after the early-waker's edge wake
/// already triggered a render) instead of double-rendering the slot.
pub static LAST_RENDER_START_NS: AtomicU64 = AtomicU64::new(0);
/// Called from the vsync driver on every Choreographer callback.
pub fn note_vsync_edge(ftn_ns: u64) {
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) {
VSYNC_PERIOD_NS.store(d, Ordering::Relaxed);
}
}
}
/// Sleep so the `Queue::present` that follows lands at (latest vsync
/// edge + one period + 1.5 ms). No-op when disabled, when the grid is
/// unknown/stale (no recent callbacks), or when already past target.
pub fn present_gate() {
if !PRESENT_GATE_ON.load(Ordering::Acquire) {
return;
}
let edge = VSYNC_EDGE_NS.load(Ordering::Relaxed);
let period = VSYNC_PERIOD_NS.load(Ordering::Relaxed);
if edge == 0 || period == 0 {
return;
}
let now = monotonic_ns();
if now == 0 || now > edge + 4 * period {
return; // stale grid (e.g. after resume) — don't gate
}
// Earliest grid phase (a vsync edge + 1.5 ms) strictly after now:
// T = edge + m*period + 1.5 ms with m = floor((now-edge-1.5ms)
// / period) + 1.
// NOTE: a Choreographer callback can fire MID-RENDER (the next
// edge's callback arrives at edge+~11.6 ms while a slow render is
// still running), which advances `edge` by one period — the
// smallest-phase rule above then just yields the same absolute T, so
// the mid-render callback can never add an extra slot of delay.
const PHASE_NS: u64 = 1_500_000;
let rel = now.saturating_sub(edge).saturating_sub(PHASE_NS) / period;
let target = edge + (rel + 1) * period + PHASE_NS;
let wait = target.saturating_sub(now);
// Only sleep for close phases; if the next grid phase is >4 ms out
// this frame is already running late for it — present immediately
// (the next frame re-syncs to the grid).
let slept = if wait > 200_000 && wait <= 4_000_000 { wait } else { 0 };
GATE_US.store(slept / 1000, Ordering::Relaxed);
if slept > 0 {
std::thread::sleep(std::time::Duration::from_nanos(slept));
}
}
pub fn scn_reset() {
crate::scn::reset();
@ -638,6 +720,10 @@ impl crate::Surface for WGPUSurface {
} else {
// Unlocked render: present immediately. (Any frame left in the
// held slot was already presented at the top of this call.)
// PRESENT GATE: while animating, land this present at a fixed
// vsync-grid phase (next edge + 1.5 ms) so frame-to-frame
// cadence is exactly one period (see `present_gate`).
vkprobe::present_gate();
self.wgpu.queue.present(frame);
vkprobe::now_us().saturating_sub(__t_pre)
};
@ -649,7 +735,13 @@ impl crate::Surface for WGPUSurface {
// against VSYNC mon= stamps: phase = pres_mon - previous_vsync_mon.
let __mon = vkprobe::monotonic_ns();
if __mon > 0 {
log::info!("PRES f={} mon={} pre_us={}", __f, __mon, __pre_us);
log::info!(
"PRES f={} mon={} pre_us={} gate_us={}",
__f,
__mon,
__pre_us,
vkprobe::GATE_US.load(std::sync::atomic::Ordering::Relaxed)
);
}
}