Skip to main content

cranpose_app_shell/
fps_monitor.rs

1//! FPS monitoring for performance tracking.
2
3use std::collections::VecDeque;
4use web_time::Instant;
5
6const FRAME_HISTORY_SIZE: usize = 60;
7
8#[derive(Debug)]
9pub(crate) struct FpsMonitor {
10    tracker: FpsTracker,
11    recomposition_count: u64,
12}
13
14impl FpsMonitor {
15    pub(crate) fn new() -> Self {
16        Self {
17            tracker: FpsTracker::new(),
18            recomposition_count: 0,
19        }
20    }
21
22    #[cfg(test)]
23    pub(crate) fn record_frame(&mut self) {
24        self.tracker.record_frame(self.recomposition_count);
25    }
26
27    pub(crate) fn record_frame_work(
28        &mut self,
29        frame_started_at: Instant,
30        frame_finished_at: Instant,
31    ) {
32        self.tracker.record_frame_work(
33            frame_started_at,
34            frame_finished_at,
35            self.recomposition_count,
36        );
37    }
38
39    pub(crate) fn record_recomposition(&mut self) {
40        self.recomposition_count = self.recomposition_count.saturating_add(1);
41    }
42
43    pub(crate) fn reset_stats(&mut self) {
44        self.tracker.reset(self.recomposition_count);
45    }
46
47    pub(crate) fn current_fps(&self) -> f32 {
48        self.tracker.last_fps
49    }
50
51    pub(crate) fn stats(&self) -> FpsStats {
52        self.tracker.stats(self.recomposition_count)
53    }
54}
55
56impl Default for FpsMonitor {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62#[derive(Debug)]
63struct FpsTracker {
64    frame_times: VecDeque<Instant>,
65    frame_intervals_ms: VecDeque<f32>,
66    frame_work_ms: VecDeque<f32>,
67    last_fps: f32,
68    frame_count: u64,
69    intervals: FrameIntervalStats,
70    work: FrameIntervalStats,
71    last_recomp_count: u64,
72    recomps_per_second: u64,
73    last_recomp_calc: Instant,
74}
75
76impl FpsTracker {
77    fn new() -> Self {
78        Self {
79            frame_times: VecDeque::with_capacity(FRAME_HISTORY_SIZE + 1),
80            frame_intervals_ms: VecDeque::with_capacity(FRAME_HISTORY_SIZE),
81            frame_work_ms: VecDeque::with_capacity(FRAME_HISTORY_SIZE),
82            last_fps: 0.0,
83            frame_count: 0,
84            intervals: FrameIntervalStats::default(),
85            work: FrameIntervalStats::default(),
86            last_recomp_count: 0,
87            recomps_per_second: 0,
88            last_recomp_calc: Instant::now(),
89        }
90    }
91
92    #[cfg(test)]
93    fn record_frame(&mut self, recomposition_count: u64) {
94        let now = Instant::now();
95        self.record_frame_work(now, now, recomposition_count);
96    }
97
98    #[cfg(test)]
99    fn record_frame_at(&mut self, now: Instant, recomposition_count: u64) {
100        self.record_frame_work(now, now, recomposition_count);
101    }
102
103    fn reset(&mut self, recomposition_count: u64) {
104        self.frame_times.clear();
105        self.frame_intervals_ms.clear();
106        self.frame_work_ms.clear();
107        self.last_fps = 0.0;
108        self.frame_count = 0;
109        self.intervals = FrameIntervalStats::default();
110        self.work = FrameIntervalStats::default();
111        self.last_recomp_count = recomposition_count;
112        self.recomps_per_second = 0;
113        self.last_recomp_calc = Instant::now();
114    }
115
116    fn record_frame_work(
117        &mut self,
118        frame_started_at: Instant,
119        frame_finished_at: Instant,
120        recomposition_count: u64,
121    ) {
122        if let Some(previous) = self.frame_times.back() {
123            let interval_ms = frame_started_at.duration_since(*previous).as_secs_f32() * 1000.0;
124            self.frame_intervals_ms.push_back(interval_ms);
125            while self.frame_intervals_ms.len() > FRAME_HISTORY_SIZE {
126                self.frame_intervals_ms.pop_front();
127            }
128            self.intervals = FrameIntervalStats::from_samples(&self.frame_intervals_ms);
129            self.last_fps = if self.intervals.avg_ms > 0.0 {
130                1000.0 / self.intervals.avg_ms
131            } else {
132                0.0
133            };
134        }
135
136        let work_ms = frame_finished_at
137            .duration_since(frame_started_at)
138            .as_secs_f32()
139            * 1000.0;
140        self.frame_work_ms.push_back(work_ms);
141        while self.frame_work_ms.len() > FRAME_HISTORY_SIZE {
142            self.frame_work_ms.pop_front();
143        }
144        self.work = FrameIntervalStats::from_samples(&self.frame_work_ms);
145
146        self.frame_times.push_back(frame_started_at);
147        self.frame_count += 1;
148
149        while self.frame_times.len() > FRAME_HISTORY_SIZE + 1 {
150            self.frame_times.pop_front();
151        }
152
153        let elapsed = frame_finished_at
154            .duration_since(self.last_recomp_calc)
155            .as_secs_f32();
156        if elapsed >= 1.0 {
157            self.recomps_per_second = recomposition_count.saturating_sub(self.last_recomp_count);
158            self.last_recomp_count = recomposition_count;
159            self.last_recomp_calc = frame_finished_at;
160        }
161    }
162
163    fn stats(&self, recomposition_count: u64) -> FpsStats {
164        FpsStats {
165            fps: self.last_fps,
166            avg_ms: self.intervals.avg_ms,
167            latest_ms: self.intervals.latest_ms,
168            min_ms: self.intervals.min_ms,
169            max_ms: self.intervals.max_ms,
170            p95_ms: self.intervals.p95_ms,
171            p99_ms: self.intervals.p99_ms,
172            work_avg_ms: self.work.avg_ms,
173            work_p95_ms: self.work.p95_ms,
174            work_max_ms: self.work.max_ms,
175            interval_count: self.intervals.count,
176            missed_120hz_budget: self.intervals.missed_120hz_budget,
177            missed_60hz_budget: self.intervals.missed_60hz_budget,
178            stalled_50ms_frames: self.intervals.stalled_50ms_frames,
179            frame_count: self.frame_count,
180            recompositions: recomposition_count,
181            recomps_per_second: self.recomps_per_second,
182        }
183    }
184}
185
186#[derive(Clone, Copy, Debug, Default)]
187struct FrameIntervalStats {
188    count: u32,
189    latest_ms: f32,
190    avg_ms: f32,
191    min_ms: f32,
192    max_ms: f32,
193    p95_ms: f32,
194    p99_ms: f32,
195    missed_120hz_budget: u32,
196    missed_60hz_budget: u32,
197    stalled_50ms_frames: u32,
198}
199
200impl FrameIntervalStats {
201    const FRAME_120HZ_MS: f32 = 1000.0 / 120.0;
202    const FRAME_60HZ_MS: f32 = 1000.0 / 60.0;
203    const STALL_MS: f32 = 50.0;
204
205    fn from_samples(samples: &VecDeque<f32>) -> Self {
206        let count = samples.len();
207        if count == 0 {
208            return Self::default();
209        }
210
211        let mut sorted = [0.0f32; FRAME_HISTORY_SIZE];
212        let mut sum = 0.0f32;
213        let mut min_ms = f32::INFINITY;
214        let mut max_ms = 0.0f32;
215        let mut missed_120hz_budget = 0u32;
216        let mut missed_60hz_budget = 0u32;
217        let mut stalled_50ms_frames = 0u32;
218
219        for (index, interval_ms) in samples.iter().copied().enumerate() {
220            sorted[index] = interval_ms;
221            sum += interval_ms;
222            min_ms = min_ms.min(interval_ms);
223            max_ms = max_ms.max(interval_ms);
224            if interval_ms > Self::FRAME_120HZ_MS {
225                missed_120hz_budget = missed_120hz_budget.saturating_add(1);
226            }
227            if interval_ms > Self::FRAME_60HZ_MS {
228                missed_60hz_budget = missed_60hz_budget.saturating_add(1);
229            }
230            if interval_ms > Self::STALL_MS {
231                stalled_50ms_frames = stalled_50ms_frames.saturating_add(1);
232            }
233        }
234
235        let sorted = &mut sorted[..count];
236        sorted.sort_by(|a, b| a.total_cmp(b));
237
238        Self {
239            count: count as u32,
240            latest_ms: samples.back().copied().unwrap_or_default(),
241            avg_ms: sum / count as f32,
242            min_ms,
243            max_ms,
244            p95_ms: nearest_rank_percentile(sorted, 95),
245            p99_ms: nearest_rank_percentile(sorted, 99),
246            missed_120hz_budget,
247            missed_60hz_budget,
248            stalled_50ms_frames,
249        }
250    }
251}
252
253fn nearest_rank_percentile(sorted_samples: &[f32], percentile: usize) -> f32 {
254    if sorted_samples.is_empty() {
255        return 0.0;
256    }
257    let rank = sorted_samples
258        .len()
259        .saturating_mul(percentile)
260        .div_ceil(100)
261        .saturating_sub(1);
262    sorted_samples[rank.min(sorted_samples.len() - 1)]
263}
264
265/// Frame statistics snapshot.
266#[derive(Clone, Copy, Debug, Default)]
267pub struct FpsStats {
268    /// Current FPS in frames per second.
269    pub fps: f32,
270    /// Average frame time in milliseconds.
271    pub avg_ms: f32,
272    /// Last recorded frame interval in milliseconds.
273    pub latest_ms: f32,
274    /// Minimum frame interval in the rolling history.
275    pub min_ms: f32,
276    /// Maximum frame interval in the rolling history.
277    pub max_ms: f32,
278    /// 95th percentile frame interval in the rolling history.
279    pub p95_ms: f32,
280    /// 99th percentile frame interval in the rolling history.
281    pub p99_ms: f32,
282    /// Average measured AppShell frame work in milliseconds.
283    pub work_avg_ms: f32,
284    /// 95th percentile measured AppShell frame work in milliseconds.
285    pub work_p95_ms: f32,
286    /// Maximum measured AppShell frame work in milliseconds.
287    pub work_max_ms: f32,
288    /// Number of frame intervals in the rolling history.
289    pub interval_count: u32,
290    /// Rolling count of intervals above the 120 Hz frame budget.
291    pub missed_120hz_budget: u32,
292    /// Rolling count of intervals above the 60 Hz frame budget.
293    pub missed_60hz_budget: u32,
294    /// Rolling count of intervals above 50 ms.
295    pub stalled_50ms_frames: u32,
296    /// Total frame count since monitor creation.
297    pub frame_count: u64,
298    /// Total recomposition count since monitor creation.
299    pub recompositions: u64,
300    /// Recompositions in the last second.
301    pub recomps_per_second: u64,
302}
303
304#[cfg(test)]
305mod tests {
306    use super::{nearest_rank_percentile, FpsMonitor, FpsTracker};
307    use std::time::Duration;
308
309    #[test]
310    fn monitors_do_not_share_recomposition_or_frame_counts() {
311        let mut first = FpsMonitor::new();
312        let mut second = FpsMonitor::new();
313
314        first.record_recomposition();
315        first.record_recomposition();
316        first.record_frame();
317        second.record_frame();
318
319        let first_stats = first.stats();
320        let second_stats = second.stats();
321
322        assert_eq!(first_stats.recompositions, 2);
323        assert_eq!(second_stats.recompositions, 0);
324        assert_eq!(first_stats.frame_count, 1);
325        assert_eq!(second_stats.frame_count, 1);
326    }
327
328    #[test]
329    fn nearest_rank_percentile_reports_tail_samples() {
330        let samples = [1.0, 2.0, 3.0, 40.0];
331
332        assert_eq!(nearest_rank_percentile(&samples, 50), 2.0);
333        assert_eq!(nearest_rank_percentile(&samples, 95), 40.0);
334        assert_eq!(nearest_rank_percentile(&samples, 99), 40.0);
335    }
336
337    #[test]
338    fn frame_stats_report_pacing_jank_not_just_average_fps() {
339        let mut tracker = FpsTracker::new();
340        let start = web_time::Instant::now();
341        let offsets = [0u64, 8, 16, 24, 64, 72];
342
343        for offset in offsets {
344            tracker.record_frame_at(start + Duration::from_millis(offset), 0);
345        }
346
347        let stats = tracker.stats(0);
348
349        assert_eq!(stats.interval_count, 5);
350        assert_eq!(stats.frame_count, offsets.len() as u64);
351        assert!((stats.latest_ms - 8.0).abs() < 0.1);
352        assert!((stats.max_ms - 40.0).abs() < 0.1);
353        assert!((stats.p95_ms - 40.0).abs() < 0.1);
354        assert_eq!(stats.missed_120hz_budget, 1);
355        assert_eq!(stats.missed_60hz_budget, 1);
356        assert_eq!(stats.stalled_50ms_frames, 0);
357        assert!(
358            stats.fps > 60.0,
359            "average FPS can stay plausible while the p95 frame is bad"
360        );
361    }
362
363    #[test]
364    fn frame_stats_report_frame_work_separately_from_pacing_gaps() {
365        let mut tracker = FpsTracker::new();
366        let start = web_time::Instant::now();
367        let starts = [0u64, 40, 80];
368        let work = [2u64, 3, 4];
369
370        for (start_offset, work_ms) in starts.into_iter().zip(work) {
371            let frame_start = start + Duration::from_millis(start_offset);
372            let frame_end = frame_start + Duration::from_millis(work_ms);
373            tracker.record_frame_work(frame_start, frame_end, 0);
374        }
375
376        let stats = tracker.stats(0);
377
378        assert!((stats.p95_ms - 40.0).abs() < 0.1);
379        assert!((stats.work_avg_ms - 3.0).abs() < 0.1);
380        assert!((stats.work_p95_ms - 4.0).abs() < 0.1);
381        assert!((stats.work_max_ms - 4.0).abs() < 0.1);
382    }
383
384    #[test]
385    fn reset_stats_drops_idle_gap_before_measurement_window() {
386        let mut tracker = FpsTracker::new();
387        let start = web_time::Instant::now();
388
389        tracker.record_frame_at(start, 3);
390        tracker.record_frame_at(start + Duration::from_secs(4), 3);
391        assert!(tracker.stats(3).max_ms > 3000.0);
392
393        tracker.reset(3);
394        tracker.record_frame_at(start + Duration::from_secs(4) + Duration::from_millis(8), 3);
395        tracker.record_frame_at(
396            start + Duration::from_secs(4) + Duration::from_millis(16),
397            3,
398        );
399
400        let stats = tracker.stats(3);
401        assert_eq!(stats.frame_count, 2);
402        assert_eq!(stats.interval_count, 1);
403        assert!((stats.max_ms - 8.0).abs() < 0.1);
404        assert_eq!(stats.recomps_per_second, 0);
405    }
406}