Skip to main content

blitz_shell/
frame_stats.rs

1//! Process-global publication of real per-frame timings.
2//!
3//! [`View::redraw`](crate::View::redraw) already measures every frame it
4//! presents: how long `resolve` (style plus layout) took, how long `paint_scene`
5//! took, and how long the renderer took to submit and present. Those numbers used
6//! to exist only inside a once-per-second `[blitz-frame]` log line, so anything
7//! that wanted to report renderer performance had no way to read them. The MCP
8//! diagnostics endpoint in `tauri-runtime-blitz` is the case that motivated this
9//! module: with no accessor it timed its own snapshot collection and reported that
10//! as frame cost, which measures the observer rather than the application.
11//!
12//! Recording is selected once at the frame boundary by the process-wide deep
13//! profiling mode. A normal frame does not enter this module at all.
14//!
15//! All windows in the process feed the same log. A multi-window app therefore sees
16//! its windows interleaved; the aggregate still describes real presented frames,
17//! it just does not attribute them per window.
18
19use std::collections::VecDeque;
20use std::sync::{LazyLock, Mutex};
21use std::time::Duration;
22use web_time::Instant;
23
24/// Frames retained for the aggregate statistics. At 120 Hz this is a little over
25/// two seconds of history, which is enough for a stable p95 while keeping the
26/// buffer small enough to sort on every read.
27const WINDOW_CAPACITY: usize = 256;
28
29/// Frame intervals longer than this are idle gaps, not slow frames. Blitz is
30/// deliberately zero-FPS when nothing changes, so counting the wait between two
31/// interaction bursts would wreck both the fps figure and the interval p95. This
32/// matches the threshold the `[blitz-frame]` log line has always used.
33const MAX_ACTIVE_INTERVAL: Duration = Duration::from_millis(100);
34
35/// A frame is considered to have missed a refresh when it arrives later than this
36/// multiple of the display's refresh period.
37const MISSED_REFRESH_FACTOR: f64 = 1.5;
38
39#[derive(Clone, Copy)]
40struct FrameRecord {
41    started: Instant,
42    resolve: Duration,
43    paint: Duration,
44    renderer: Duration,
45}
46
47#[derive(Default)]
48struct FrameLog {
49    frames: VecDeque<FrameRecord>,
50    total: u64,
51    refresh_millihertz: Option<u32>,
52}
53
54static FRAME_LOG: LazyLock<Mutex<FrameLog>> = LazyLock::new(|| Mutex::new(FrameLog::default()));
55
56/// Mean, 95th percentile and worst case for one timing series, in milliseconds.
57///
58/// The percentile and the maximum are carried alongside the mean on purpose: a
59/// one-second average hides the single 40 ms frame that is the only thing the
60/// user actually perceives.
61#[derive(Debug, Clone, Copy, Default, PartialEq)]
62pub struct TimingStats {
63    pub mean_ms: f64,
64    pub p95_ms: f64,
65    pub max_ms: f64,
66}
67
68/// Timings of one presented frame, in milliseconds.
69#[derive(Debug, Clone, Copy, Default, PartialEq)]
70pub struct FrameTimings {
71    /// Style recalculation and layout. Blitz runs both inside a single `resolve`
72    /// pass and does not time them separately, so this is their combined cost.
73    pub resolve_ms: f64,
74    /// Scene building, i.e. the `paint_scene` call that turns the resolved
75    /// document into renderer commands.
76    pub paint_ms: f64,
77    /// Everything the renderer did around scene building: encoding, GPU submit
78    /// and present. The renderer reports this as one figure.
79    pub renderer_ms: f64,
80    /// `resolve_ms + paint_ms + renderer_ms`. This is CPU time spent inside
81    /// `redraw`, not the wall time from input to pixels on screen.
82    pub total_ms: f64,
83    /// How long ago this frame started, measured when the snapshot was taken.
84    /// A large value means the app has been idle and the numbers are stale.
85    pub age_ms: f64,
86}
87
88/// Aggregate view of the recently presented frames.
89#[derive(Debug, Clone, PartialEq)]
90pub struct FrameStatsSnapshot {
91    /// Frames presented since process start.
92    pub frames_total: u64,
93    /// Frames the aggregate statistics below were computed over.
94    pub window_frames: u64,
95    /// The most recently presented frame.
96    pub latest: FrameTimings,
97    pub resolve: TimingStats,
98    pub paint: TimingStats,
99    pub renderer: TimingStats,
100    /// `resolve + paint + renderer` per frame.
101    pub frame_total: TimingStats,
102    /// Gap between the starts of consecutive frames, with idle gaps excluded.
103    pub interval: TimingStats,
104    /// Frames per second across the active intervals only. Zero when the window
105    /// holds fewer than two frames, or when every gap in it was an idle gap.
106    pub active_fps: f64,
107    /// Active intervals longer than 1.5 display refresh periods. Always zero when
108    /// `display_refresh_hz` is unknown, because there is nothing to compare to.
109    pub missed_refreshes: u64,
110    /// Refresh rate reported by the monitor the window was created on, when the
111    /// platform exposes it.
112    pub display_refresh_hz: Option<f64>,
113}
114
115/// Publish the display refresh rate so [`FrameStatsSnapshot::missed_refreshes`]
116/// has something to compare frame intervals against.
117pub(crate) fn set_display_refresh_millihertz(rate: Option<u32>) {
118    if let Ok(mut log) = FRAME_LOG.lock() {
119        // Keep the first rate we learn rather than letting a second window with no
120        // reported rate erase it.
121        if rate.is_some() {
122            log.refresh_millihertz = rate;
123        }
124    }
125}
126
127/// Discard every retained frame sample.
128pub fn clear_frame_stats() {
129    if let Ok(mut log) = FRAME_LOG.lock() {
130        let refresh_millihertz = log.refresh_millihertz;
131        *log = FrameLog {
132            refresh_millihertz,
133            ..FrameLog::default()
134        };
135    }
136}
137
138/// The display's refresh rate, when the platform reported one.
139///
140/// Read by the animation pacing in `window.rs`, so that the gap between
141/// animation-only frames is a whole number of the display's own refresh
142/// intervals rather than a wall-clock constant it cannot land on.
143pub(crate) fn display_refresh_millihertz() -> Option<u32> {
144    FRAME_LOG.lock().ok().and_then(|log| log.refresh_millihertz)
145}
146
147/// Record one presented frame. Called from `View::redraw` for every frame.
148pub(crate) fn record_frame(
149    started: Instant,
150    resolve: Duration,
151    paint: Duration,
152    renderer: Duration,
153) {
154    // A poisoned lock means some other thread panicked mid-update. Performance
155    // bookkeeping is not worth propagating that panic into the render loop.
156    let Ok(mut log) = FRAME_LOG.lock() else {
157        return;
158    };
159    if log.frames.len() == WINDOW_CAPACITY {
160        log.frames.pop_front();
161    }
162    log.frames.push_back(FrameRecord {
163        started,
164        resolve,
165        paint,
166        renderer,
167    });
168    log.total = log.total.saturating_add(1);
169}
170
171/// Read the most recent frame timings.
172///
173/// Returns `None` until the first frame has been presented, so that callers can
174/// report "no data yet" instead of reporting zeros as if they were measurements.
175///
176/// Gated on *permission*, not on an attached consumer. `deep_profiling_enabled`
177/// requires both, which is right for the intrusive collectors it was written
178/// for: they cost something per section and nobody should pay that for a reader
179/// who is not there. This is different. Recording a frame's timings is four
180/// durations pushed into a bounded ring that `record_frame` fills whether
181/// anything reads it or not, and one of the readers is the `[blitz-frame]` log
182/// line, which writes to a local file and has no consumer to attach.
183///
184/// Requiring a consumer here meant the owner's toggle appeared to do nothing:
185/// the setting went on, the ring kept filling, and every read returned `None`,
186/// so the log file stayed empty and the diagnostics endpoint reported no data
187/// from an application that was rendering normally.
188pub fn latest_frame_stats() -> Option<FrameStatsSnapshot> {
189    if !blitz_traits::profiling::deep_profiling_permitted() {
190        return None;
191    }
192    let log = FRAME_LOG.lock().ok()?;
193    summarise(
194        &log.frames,
195        log.total,
196        log.refresh_millihertz,
197        Instant::now(),
198    )
199}
200
201fn summarise(
202    frames: &VecDeque<FrameRecord>,
203    frames_total: u64,
204    refresh_millihertz: Option<u32>,
205    now: Instant,
206) -> Option<FrameStatsSnapshot> {
207    let newest = frames.back()?;
208
209    let mut resolve = Vec::with_capacity(frames.len());
210    let mut paint = Vec::with_capacity(frames.len());
211    let mut renderer = Vec::with_capacity(frames.len());
212    let mut frame_total = Vec::with_capacity(frames.len());
213    let mut intervals = Vec::with_capacity(frames.len());
214    let mut interval_sum = Duration::ZERO;
215    let mut missed_refreshes = 0u64;
216
217    let target = refresh_millihertz
218        .filter(|rate| *rate > 0)
219        .map(|rate| Duration::from_secs_f64(1000.0 / f64::from(rate)));
220
221    let mut previous: Option<Instant> = None;
222    for frame in frames {
223        resolve.push(to_ms(frame.resolve));
224        paint.push(to_ms(frame.paint));
225        renderer.push(to_ms(frame.renderer));
226        frame_total.push(to_ms(frame.resolve + frame.paint + frame.renderer));
227
228        if let Some(previous) = previous.replace(frame.started) {
229            let interval = frame.started.saturating_duration_since(previous);
230            if interval <= MAX_ACTIVE_INTERVAL {
231                intervals.push(to_ms(interval));
232                interval_sum += interval;
233                if target.is_some_and(|target| interval > target.mul_f64(MISSED_REFRESH_FACTOR)) {
234                    missed_refreshes += 1;
235                }
236            }
237        }
238    }
239
240    let active_fps = if interval_sum.is_zero() {
241        0.0
242    } else {
243        intervals.len() as f64 / interval_sum.as_secs_f64()
244    };
245
246    Some(FrameStatsSnapshot {
247        frames_total,
248        window_frames: frames.len() as u64,
249        latest: FrameTimings {
250            resolve_ms: to_ms(newest.resolve),
251            paint_ms: to_ms(newest.paint),
252            renderer_ms: to_ms(newest.renderer),
253            total_ms: to_ms(newest.resolve + newest.paint + newest.renderer),
254            age_ms: to_ms(now.saturating_duration_since(newest.started)),
255        },
256        resolve: TimingStats::from_samples(&mut resolve),
257        paint: TimingStats::from_samples(&mut paint),
258        renderer: TimingStats::from_samples(&mut renderer),
259        frame_total: TimingStats::from_samples(&mut frame_total),
260        interval: TimingStats::from_samples(&mut intervals),
261        active_fps,
262        missed_refreshes,
263        display_refresh_hz: refresh_millihertz.map(|rate| f64::from(rate) / 1000.0),
264    })
265}
266
267fn to_ms(duration: Duration) -> f64 {
268    duration.as_secs_f64() * 1000.0
269}
270
271impl TimingStats {
272    /// Sorts `samples` in place and reduces them to mean, p95 and max.
273    ///
274    /// p95 uses the nearest-rank definition, so with fewer than 20 samples it
275    /// simply reports the worst one. That is the honest answer for a short
276    /// window: there is no 95th percentile to interpolate towards.
277    fn from_samples(samples: &mut [f64]) -> Self {
278        if samples.is_empty() {
279            return Self::default();
280        }
281        samples.sort_by(f64::total_cmp);
282        let count = samples.len();
283        let sum: f64 = samples.iter().sum();
284        let rank = ((count as f64) * 0.95).ceil() as usize;
285        let index = rank.clamp(1, count) - 1;
286        Self {
287            mean_ms: sum / count as f64,
288            p95_ms: samples[index],
289            max_ms: samples[count - 1],
290        }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn log(frames: &[(u64, u64, u64, u64)]) -> VecDeque<FrameRecord> {
299        let origin = Instant::now();
300        frames
301            .iter()
302            .map(|(offset_ms, resolve, paint, renderer)| FrameRecord {
303                started: origin + Duration::from_millis(*offset_ms),
304                resolve: Duration::from_millis(*resolve),
305                paint: Duration::from_millis(*paint),
306                renderer: Duration::from_millis(*renderer),
307            })
308            .collect()
309    }
310
311    #[test]
312    fn empty_log_reports_nothing_rather_than_zeroes() {
313        assert!(summarise(&VecDeque::new(), 0, None, Instant::now()).is_none());
314    }
315
316    #[test]
317    fn latest_frame_is_the_newest_record() {
318        let frames = log(&[(0, 1, 2, 3), (16, 4, 5, 6)]);
319        let stats = summarise(&frames, 2, None, Instant::now()).unwrap();
320        assert_eq!(stats.latest.resolve_ms, 4.0);
321        assert_eq!(stats.latest.paint_ms, 5.0);
322        assert_eq!(stats.latest.renderer_ms, 6.0);
323        assert_eq!(stats.latest.total_ms, 15.0);
324        assert_eq!(stats.frames_total, 2);
325        assert_eq!(stats.window_frames, 2);
326    }
327
328    #[test]
329    fn worst_frame_survives_the_mean() {
330        let mut frames: Vec<(u64, u64, u64, u64)> = (0..40).map(|i| (i * 16, 1, 1, 1)).collect();
331        frames.push((40 * 16, 30, 1, 1));
332        let stats = summarise(&log(&frames), 41, None, Instant::now()).unwrap();
333        assert!(stats.resolve.mean_ms < 2.0);
334        assert_eq!(stats.resolve.max_ms, 30.0);
335        assert_eq!(stats.resolve.p95_ms, 1.0);
336        assert_eq!(stats.frame_total.max_ms, 32.0);
337    }
338
339    #[test]
340    fn idle_gaps_do_not_count_as_slow_frames() {
341        // Two 16 ms frames, then a five second idle gap, then another frame.
342        let frames = log(&[(0, 1, 1, 1), (16, 1, 1, 1), (5016, 1, 1, 1)]);
343        let stats = summarise(&frames, 3, Some(60_000), Instant::now()).unwrap();
344        assert_eq!(stats.interval.max_ms, 16.0);
345        assert!((stats.active_fps - 62.5).abs() < 0.01);
346        assert_eq!(stats.missed_refreshes, 0);
347    }
348
349    #[test]
350    fn a_late_frame_counts_as_a_missed_refresh() {
351        // 60 Hz means a 16.67 ms period; 40 ms is well past the 1.5x threshold.
352        let frames = log(&[(0, 1, 1, 1), (40, 1, 1, 1)]);
353        let stats = summarise(&frames, 2, Some(60_000), Instant::now()).unwrap();
354        assert_eq!(stats.missed_refreshes, 1);
355        assert_eq!(stats.display_refresh_hz, Some(60.0));
356    }
357
358    #[test]
359    fn missed_refreshes_stay_zero_without_a_known_refresh_rate() {
360        let frames = log(&[(0, 1, 1, 1), (90, 1, 1, 1)]);
361        let stats = summarise(&frames, 2, None, Instant::now()).unwrap();
362        assert_eq!(stats.missed_refreshes, 0);
363        assert_eq!(stats.display_refresh_hz, None);
364    }
365
366    #[test]
367    fn p95_picks_the_nearest_rank() {
368        let mut samples: Vec<f64> = (1..=20).map(f64::from).collect();
369        let stats = TimingStats::from_samples(&mut samples);
370        assert_eq!(stats.p95_ms, 19.0);
371        assert_eq!(stats.max_ms, 20.0);
372        assert_eq!(stats.mean_ms, 10.5);
373    }
374
375    #[test]
376    fn recording_publishes_to_the_process_global_log() {
377        // Shared with the lifecycle tests in `lib.rs`: both move the same
378        // process-wide permission, consumer count and sample stores.
379        let _serial = crate::exclusive_profiling_state();
380        // Permission and a consumer: recording now requires both, so a test
381        // that only set the flag would record nothing and read as a broken
382        // collector.
383        blitz_traits::profiling::set_deep_profiling_permitted(true);
384        let session = blitz_traits::profiling::begin_deep_profiling().expect("permitted");
385        clear_frame_stats();
386        record_frame(
387            Instant::now(),
388            Duration::from_millis(2),
389            Duration::from_millis(3),
390            Duration::from_millis(4),
391        );
392        let stats = latest_frame_stats().expect("a frame was just recorded");
393        assert!(stats.frames_total >= 1);
394        assert!(stats.latest.total_ms >= 9.0);
395        drop(session);
396        blitz_traits::profiling::set_deep_profiling_permitted(false);
397        clear_frame_stats();
398    }
399}