//! STG (stage-timestamp) instrumentation for the jank hunt, 2026-10-28. //! //! The app's FR hook (rendering notifier) measures render cost and frame //! gaps; this measures what happens AROUND rendering inside the event //! loop: Choreographer wake latency, timer callbacks (the app's 8 ms //! drain tick runs in `update_timers_and_animations`), and event //! processing. Timestamps are plain atomics — the UI thread only does //! atomic loads/stores here; the APP formats the log line from its own //! tick (never log from the UI thread: logd backpressure). use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; /// CLOCK_THREAD_CPUTIME_ID microseconds for the current thread — for a slow /// window, wall ≫ cpu ⇒ the thread was off-CPU (descheduled/blocked). pub fn thread_cpu_us() -> 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_THREAD_CPUTIME_ID: i32 = 3; let mut ts = Ts { tv_sec: 0, tv_nsec: 0 }; if unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) } == 0 { ts.tv_sec as u64 * 1_000_000 + ts.tv_nsec as u64 / 1000 } else { 0 } } /// CLOCK_MONOTONIC microseconds — the SAME clock as PRES/VSYNC mon= (the /// renderer's monotonic_ns) and the vkshim's ts=, so IT lines can be /// correlated frame-by-frame offline. pub fn now_us() -> 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 + ts.tv_nsec as u64 / 1000 } else { 0 } } // Per-iteration stage marks (us since process start); reset at loop top. pub static S_START: AtomicU64 = AtomicU64::new(0); pub static S_FIRST_EV: AtomicU64 = AtomicU64::new(0); pub static S_POLL_END: AtomicU64 = AtomicU64::new(0); pub static S_RENDER_START: AtomicU64 = AtomicU64::new(0); pub static S_RENDER_END: AtomicU64 = AtomicU64::new(0); // Accumulated per-iteration costs. pub static S_TIMER_US: AtomicU64 = AtomicU64::new(0); pub static S_EVENT_US: AtomicU64 = AtomicU64::new(0); pub static S_N_EVENTS: AtomicU64 = AtomicU64::new(0); // Full do_render duration of the PREVIOUS iteration (the AfterRendering // hook fires mid-render, so the current iteration's render can't be read // yet): covers renderer.draw PLUS the wgpu surface tail (flush_and_submit // gr_context.submit + queue.present — the Mali driver calls). pub static S_LAST_RENDER_US: AtomicU64 = AtomicU64::new(0); // Pointer-activity tracking for vsync pacing. While the user is dragging // (finger down + moving), Slint has NO active animation (content-y is set // directly by pointer events, not a physics animation), so // `has_active_animations()` is false and the vsync driver sleeps. Without // vsync pacing the event loop then sleeps until the next timer (16 ms on // this app), producing 16-30 ms frame gaps = visible jank. We stamp a // "pointer active until" deadline on every motion event so the event loop // keeps the vsync driver running for a short window after the last touch, // covering the direct drag; the momentum fling is covered separately by // `has_active_animations()` (the physics animation). See // docs/BUGS/scroll-jank-120hz.md. pub static POINTER_ACTIVE_UNTIL_US: AtomicU64 = AtomicU64::new(0); /// How long after the last motion event we keep vsync pacing alive (µs). /// 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 /// sleep to save battery. pub const POINTER_PACING_WINDOW_US: u64 = 200_000; pub fn note_pointer_activity() { POINTER_ACTIVE_UNTIL_US .store(now_us().saturating_add(POINTER_PACING_WINDOW_US), Ordering::Relaxed); } pub fn pointer_recently_active() -> bool { now_us() < POINTER_ACTIVE_UNTIL_US.load(Ordering::Relaxed) } // Input-batch probes: how many native input events arrive in one // `InputAvailable` batch, and how long `process_inputs` takes. A large // batch means the app let input pile up (it wasn't reading fast enough), // and draining the backlog in one event handler shows up as a big `ev` spike // (poll==ev, render=0). See docs/BUGS/scroll-jank-120hz.md. pub static INPUT_BATCH_MAX: AtomicU32 = AtomicU32::new(0); pub static INPUT_BATCH_TOTAL_EV: AtomicU64 = AtomicU64::new(0); pub static INPUT_PROCES_MAX_US: AtomicU64 = AtomicU64::new(0); pub fn input_note_batch(n: u32, proc_us: u64) { let cur = INPUT_BATCH_MAX.load(Ordering::Relaxed); if n > cur { INPUT_BATCH_MAX.store(n, Ordering::Relaxed); // remember the proc time that went with the biggest batch INPUT_BATCH_TOTAL_EV.store(proc_us, Ordering::Relaxed); } let c2 = INPUT_PROCES_MAX_US.load(Ordering::Relaxed); if proc_us > c2 { INPUT_PROCES_MAX_US.store(proc_us, Ordering::Relaxed); } } pub fn input_snapshot() -> (u32, u64, u64) { ( INPUT_BATCH_MAX.load(Ordering::Relaxed), INPUT_BATCH_TOTAL_EV.load(Ordering::Relaxed), INPUT_PROCES_MAX_US.load(Ordering::Relaxed), ) } /// Log a single event whose `process_event` took longer than the threshold. /// Lets us identify WHICH MainEvent variant is responsible for the big `ev` /// spikes (pointer input is ~2.5 ms max, so a 20 ms event is something else). /// Timeout-path counters: how often the loop's poll timeout is forced to /// ZERO (because `pending_redraw` was set at iteration start) vs the 6 ms /// animation cap vs a natural (larger) timer. A high ZERO count means the /// loop is busy-spinning instead of sleeping between frames. pub static TO_ZERO_COUNT: AtomicU64 = AtomicU64::new(0); pub static TO_CAP_COUNT: AtomicU64 = AtomicU64::new(0); pub static TO_NATURAL_COUNT: AtomicU64 = AtomicU64::new(0); pub fn note_timeout_class(kind: u8) { match kind { 0 => { TO_ZERO_COUNT.fetch_add(1, Ordering::Relaxed); } 1 => { TO_CAP_COUNT.fetch_add(1, Ordering::Relaxed); } _ => { TO_NATURAL_COUNT.fetch_add(1, Ordering::Relaxed); } } } pub fn timeout_snapshot() -> (u64, u64, u64) { ( TO_ZERO_COUNT.load(Ordering::Relaxed), TO_CAP_COUNT.load(Ordering::Relaxed), TO_NATURAL_COUNT.load(Ordering::Relaxed), ) } /// Wake-source counters: how many times each PollEvent variant was delivered /// to the loop. WAKE >> 120/s (vsync) means spurious waker firing. pub static WAKE_COUNT: AtomicU64 = AtomicU64::new(0); pub static TIMEOUT_EV_COUNT: AtomicU64 = AtomicU64::new(0); pub static INPUT_EV_COUNT: AtomicU64 = AtomicU64::new(0); pub fn wake_snapshot() -> (u64, u64, u64) { ( WAKE_COUNT.load(Ordering::Relaxed), TIMEOUT_EV_COUNT.load(Ordering::Relaxed), INPUT_EV_COUNT.load(Ordering::Relaxed), ) } pub fn log_slow_event(name: &str, us: u64, cpu_us: u64) { // Via the log facade (nblog) so ordering with the other perf lines is // preserved (single writer; see log_iter's comment). cpu_us = the // thread's own CPU time inside the window: wall≫cpu ⇒ off-CPU // (descheduled/blocked), wall≈cpu ⇒ pure CPU work. log::info!("SLOWEV {} {}us cpu={}us", name, us, cpu_us); } /// Jank-hunt: total `invoke_from_event_loop` pushes (INVK n=..). pub static INVK_PUSH_COUNT: AtomicU64 = AtomicU64::new(0); /// Jank-hunt: serial counter for slow `Event::Other` closures drained in /// `process_event` (OTHR n=..). pub static OTHER_CLOSE_COUNT: AtomicU64 = AtomicU64::new(0); /// Log a raw perf line through the facade (ordering-preserving). pub fn log_line(line: &str) { log::info!("{line}"); } // Per-iteration logging: records EVERY loop iteration (render or not) // so the time inside an FR gap can be accounted for iteration by // iteration. Off by default; the app enables it for jank hunts. pub static IT_ON: AtomicBool = AtomicBool::new(false); /// Jank-hunt / 120Hz: when set, the event loop renders on every vsync wake /// while animating (vsync-locked present phase). OFF by default; sltest (and /// the A/B test) opt in. pub static VSYNC_LOCKED_RENDER: AtomicBool = AtomicBool::new(false); /// ListView probes: (virtualized-pass-ms, rows-created-in-it, eager-last-count, /// eager-max-count, eager-calls). pub fn lv_snapshot() -> (u64, u32, u32, u32, u64, u64, u64) { use i_slint_core::model::repeater as r; ( r::LV_LAST_PASS_MS.load(Ordering::Relaxed), r::LV_CREATED_ROWS.load(Ordering::Relaxed), r::LV_EAGER_LAST_COUNT.load(Ordering::Relaxed), r::LV_EAGER_MAX_COUNT.load(Ordering::Relaxed), r::LV_EAGER_CALLS.load(Ordering::Relaxed), r::LV_LISTVIEW_ENTRY_CALLS.load(Ordering::Relaxed), r::LV_EAGER_ENTRY_CALLS.load(Ordering::Relaxed), ) } /// Flickable pointer-handling probes: (handle_mouse_max_us, /// content_y_set_max_us). These are running maxima since process start, /// used to confirm whether the 16 ms event spikes are inside /// Flickable::handle_mouse and, within it, the content_x/y `.set()` call. pub fn flick_snapshot() -> (u64, u64) { use i_slint_core::items::flickable as f; ( f::FLICK_MOUSE_MAX_US.load(Ordering::Relaxed), f::FLICK_CONTENTSET_MAX_US.load(Ordering::Relaxed), ) } /// Log one event-loop iteration. `start_us` is the iteration start time /// (stg clock); all other costs are microseconds. `lv1` = last ListView /// pass at poll-end (input-triggered), `lv2` = at iteration end /// (render-triggered): (pass-ms, rows-created). pub fn log_iter( start_us: u64, poll_us: u64, nev: u64, first_ev_us: u64, timer_us: u64, event_us: u64, render_us: u64, animating: bool, timeout_ms: u64, lv1: (u64, u32, u32, u32, u64, u64, u64), lv2: (u64, u32, u32, u32, u64, u64, u64), flk: (u64, u64), inp: (u32, u64, u64), tocls: (u64, u64, u64), wk: (u64, u64, u64), ) { // Jank-hunt fix: emit through the log facade (nblog) instead of a private // BufWriter — two independent buffers appending to the same file made IT // lines land out of order (flush-blocked), breaking per-gap correlation. // nblog is non-blocking (the drain thread does the I/O). 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={}", 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 ); } /// One iteration of the event loop: start, first-event, poll-end, /// render-start, render-end, timer-us, event-us, n-events. pub fn snapshot() -> (u64, u64, u64, u64, u64, u64, u64, u64) { ( S_START.load(Ordering::Relaxed), S_FIRST_EV.load(Ordering::Relaxed), S_POLL_END.load(Ordering::Relaxed), S_RENDER_START.load(Ordering::Relaxed), S_RENDER_END.load(Ordering::Relaxed), S_TIMER_US.load(Ordering::Relaxed), S_EVENT_US.load(Ordering::Relaxed), S_N_EVENTS.load(Ordering::Relaxed), ) }