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;
7const EVENT_DRIVEN_IDLE_GAP_MS: f32 = 50.0;
8
9#[derive(Debug)]
10pub(crate) struct FpsMonitor {
11    tracker: FpsTracker,
12    recomposition_count: u64,
13    recomposition_reset_baseline: u64,
14}
15
16impl FpsMonitor {
17    pub(crate) fn new() -> Self {
18        Self {
19            tracker: FpsTracker::new(),
20            recomposition_count: 0,
21            recomposition_reset_baseline: 0,
22        }
23    }
24
25    #[cfg(test)]
26    pub(crate) fn record_frame(&mut self) {
27        self.tracker.record_frame(self.recomposition_count);
28    }
29
30    pub(crate) fn record_frame_work(
31        &mut self,
32        frame_started_at: Instant,
33        frame_finished_at: Instant,
34    ) {
35        self.tracker.record_frame_work(
36            frame_started_at,
37            frame_finished_at,
38            self.recomposition_count,
39        );
40    }
41
42    pub(crate) fn record_recomposition(&mut self) {
43        self.recomposition_count = self.recomposition_count.saturating_add(1);
44    }
45
46    pub(crate) fn reset_stats(&mut self) {
47        self.tracker.reset(self.recomposition_count);
48        self.recomposition_reset_baseline = self.recomposition_count;
49    }
50
51    pub(crate) fn current_fps(&self) -> f32 {
52        self.tracker.last_fps
53    }
54
55    pub(crate) fn stats(&self) -> FpsStats {
56        self.tracker.stats(
57            self.recomposition_count
58                .saturating_sub(self.recomposition_reset_baseline),
59        )
60    }
61}
62
63impl Default for FpsMonitor {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69#[derive(Debug)]
70struct FpsTracker {
71    frame_times: VecDeque<Instant>,
72    frame_intervals_ms: VecDeque<f32>,
73    frame_work_ms: VecDeque<f32>,
74    last_fps: f32,
75    frame_count: u64,
76    intervals: FrameIntervalStats,
77    work: FrameIntervalStats,
78    last_recomp_count: u64,
79    recomps_per_second: u64,
80    last_recomp_calc: Instant,
81}
82
83impl FpsTracker {
84    fn new() -> Self {
85        Self {
86            frame_times: VecDeque::with_capacity(FRAME_HISTORY_SIZE + 1),
87            frame_intervals_ms: VecDeque::with_capacity(FRAME_HISTORY_SIZE),
88            frame_work_ms: VecDeque::with_capacity(FRAME_HISTORY_SIZE),
89            last_fps: 0.0,
90            frame_count: 0,
91            intervals: FrameIntervalStats::default(),
92            work: FrameIntervalStats::default(),
93            last_recomp_count: 0,
94            recomps_per_second: 0,
95            last_recomp_calc: Instant::now(),
96        }
97    }
98
99    #[cfg(test)]
100    fn record_frame(&mut self, recomposition_count: u64) {
101        let now = Instant::now();
102        self.record_frame_work(now, now, recomposition_count);
103    }
104
105    #[cfg(test)]
106    fn record_frame_at(&mut self, now: Instant, recomposition_count: u64) {
107        self.record_frame_work(now, now, recomposition_count);
108    }
109
110    fn reset(&mut self, recomposition_count: u64) {
111        self.frame_times.clear();
112        self.frame_intervals_ms.clear();
113        self.frame_work_ms.clear();
114        self.last_fps = 0.0;
115        self.frame_count = 0;
116        self.intervals = FrameIntervalStats::default();
117        self.work = FrameIntervalStats::default();
118        self.last_recomp_count = recomposition_count;
119        self.recomps_per_second = 0;
120        self.last_recomp_calc = Instant::now();
121    }
122
123    fn record_frame_work(
124        &mut self,
125        frame_started_at: Instant,
126        frame_finished_at: Instant,
127        recomposition_count: u64,
128    ) {
129        if let Some(previous) = self.frame_times.back() {
130            let interval_ms = frame_started_at.duration_since(*previous).as_secs_f32() * 1000.0;
131            if interval_ms <= EVENT_DRIVEN_IDLE_GAP_MS {
132                self.frame_intervals_ms.push_back(interval_ms);
133                while self.frame_intervals_ms.len() > FRAME_HISTORY_SIZE {
134                    self.frame_intervals_ms.pop_front();
135                }
136                self.intervals = FrameIntervalStats::from_samples(&self.frame_intervals_ms);
137                self.last_fps = fps_from_avg_ms(self.intervals.avg_ms);
138            }
139        }
140
141        let work_ms = frame_finished_at
142            .duration_since(frame_started_at)
143            .as_secs_f32()
144            * 1000.0;
145        self.frame_work_ms.push_back(work_ms);
146        while self.frame_work_ms.len() > FRAME_HISTORY_SIZE {
147            self.frame_work_ms.pop_front();
148        }
149        self.work = FrameIntervalStats::from_samples(&self.frame_work_ms);
150
151        self.frame_times.push_back(frame_started_at);
152        self.frame_count += 1;
153
154        while self.frame_times.len() > FRAME_HISTORY_SIZE + 1 {
155            self.frame_times.pop_front();
156        }
157
158        let elapsed = frame_finished_at
159            .duration_since(self.last_recomp_calc)
160            .as_secs_f32();
161        if elapsed >= 1.0 {
162            self.recomps_per_second = recomposition_count.saturating_sub(self.last_recomp_count);
163            self.last_recomp_count = recomposition_count;
164            self.last_recomp_calc = frame_finished_at;
165        }
166    }
167
168    fn stats(&self, recomposition_count: u64) -> FpsStats {
169        FpsStats {
170            fps: self.last_fps,
171            avg_ms: self.intervals.avg_ms,
172            latest_ms: self.intervals.latest_ms,
173            min_ms: self.intervals.min_ms,
174            max_ms: self.intervals.max_ms,
175            p95_ms: self.intervals.p95_ms,
176            p99_ms: self.intervals.p99_ms,
177            work_fps: fps_from_avg_ms(self.work.avg_ms),
178            work_avg_ms: self.work.avg_ms,
179            work_p95_ms: self.work.p95_ms,
180            work_max_ms: self.work.max_ms,
181            work_missed_120hz_budget: self.work.missed_120hz_budget,
182            work_missed_60hz_budget: self.work.missed_60hz_budget,
183            work_stalled_50ms_frames: self.work.stalled_50ms_frames,
184            interval_count: self.intervals.count,
185            missed_120hz_budget: self.intervals.missed_120hz_budget,
186            missed_60hz_budget: self.intervals.missed_60hz_budget,
187            stalled_50ms_frames: self.intervals.stalled_50ms_frames,
188            frame_count: self.frame_count,
189            recompositions: recomposition_count,
190            recomps_per_second: self.recomps_per_second,
191        }
192    }
193}
194
195#[derive(Clone, Copy, Debug, Default)]
196struct FrameIntervalStats {
197    count: u32,
198    latest_ms: f32,
199    avg_ms: f32,
200    min_ms: f32,
201    max_ms: f32,
202    p95_ms: f32,
203    p99_ms: f32,
204    missed_120hz_budget: u32,
205    missed_60hz_budget: u32,
206    stalled_50ms_frames: u32,
207}
208
209impl FrameIntervalStats {
210    const FRAME_120HZ_MS: f32 = 1000.0 / 120.0;
211    const FRAME_60HZ_MS: f32 = 1000.0 / 60.0;
212    const STALL_MS: f32 = 50.0;
213
214    fn from_samples(samples: &VecDeque<f32>) -> Self {
215        let count = samples.len();
216        if count == 0 {
217            return Self::default();
218        }
219
220        let mut sorted = [0.0f32; FRAME_HISTORY_SIZE];
221        let mut sum = 0.0f32;
222        let mut min_ms = f32::INFINITY;
223        let mut max_ms = 0.0f32;
224        let mut missed_120hz_budget = 0u32;
225        let mut missed_60hz_budget = 0u32;
226        let mut stalled_50ms_frames = 0u32;
227
228        for (index, interval_ms) in samples.iter().copied().enumerate() {
229            sorted[index] = interval_ms;
230            sum += interval_ms;
231            min_ms = min_ms.min(interval_ms);
232            max_ms = max_ms.max(interval_ms);
233            if interval_ms > Self::FRAME_120HZ_MS {
234                missed_120hz_budget = missed_120hz_budget.saturating_add(1);
235            }
236            if interval_ms > Self::FRAME_60HZ_MS {
237                missed_60hz_budget = missed_60hz_budget.saturating_add(1);
238            }
239            if interval_ms > Self::STALL_MS {
240                stalled_50ms_frames = stalled_50ms_frames.saturating_add(1);
241            }
242        }
243
244        let sorted = &mut sorted[..count];
245        sorted.sort_by(|a, b| a.total_cmp(b));
246
247        Self {
248            count: count as u32,
249            latest_ms: samples.back().copied().unwrap_or_default(),
250            avg_ms: sum / count as f32,
251            min_ms,
252            max_ms,
253            p95_ms: nearest_rank_percentile(sorted, 95),
254            p99_ms: nearest_rank_percentile(sorted, 99),
255            missed_120hz_budget,
256            missed_60hz_budget,
257            stalled_50ms_frames,
258        }
259    }
260}
261
262fn fps_from_avg_ms(avg_ms: f32) -> f32 {
263    if avg_ms > 0.0 {
264        1000.0 / avg_ms
265    } else {
266        0.0
267    }
268}
269
270fn nearest_rank_percentile(sorted_samples: &[f32], percentile: usize) -> f32 {
271    if sorted_samples.is_empty() {
272        return 0.0;
273    }
274    let rank = sorted_samples
275        .len()
276        .saturating_mul(percentile)
277        .div_ceil(100)
278        .saturating_sub(1);
279    sorted_samples[rank.min(sorted_samples.len() - 1)]
280}
281
282/// Frame statistics snapshot.
283#[derive(Clone, Copy, Debug, Default)]
284pub struct FpsStats {
285    /// Current presented-frame cadence in frames per second.
286    pub fps: f32,
287    /// Average presented-frame interval in milliseconds.
288    pub avg_ms: f32,
289    /// Last recorded frame interval in milliseconds.
290    pub latest_ms: f32,
291    /// Minimum frame interval in the rolling history.
292    pub min_ms: f32,
293    /// Maximum frame interval in the rolling history.
294    pub max_ms: f32,
295    /// 95th percentile frame interval in the rolling history.
296    pub p95_ms: f32,
297    /// 99th percentile frame interval in the rolling history.
298    pub p99_ms: f32,
299    /// AppShell work capacity in frames per second.
300    pub work_fps: f32,
301    /// Average measured AppShell frame work in milliseconds.
302    pub work_avg_ms: f32,
303    /// 95th percentile measured AppShell frame work in milliseconds.
304    pub work_p95_ms: f32,
305    /// Maximum measured AppShell frame work in milliseconds.
306    pub work_max_ms: f32,
307    /// Rolling count of measured AppShell work samples above the 120 Hz frame budget.
308    pub work_missed_120hz_budget: u32,
309    /// Rolling count of measured AppShell work samples above the 60 Hz frame budget.
310    pub work_missed_60hz_budget: u32,
311    /// Rolling count of measured AppShell work samples above 50 ms.
312    pub work_stalled_50ms_frames: u32,
313    /// Number of frame intervals in the rolling history.
314    pub interval_count: u32,
315    /// Rolling count of intervals above the 120 Hz frame budget.
316    pub missed_120hz_budget: u32,
317    /// Rolling count of intervals above the 60 Hz frame budget.
318    pub missed_60hz_budget: u32,
319    /// Rolling count of intervals above 50 ms.
320    pub stalled_50ms_frames: u32,
321    /// Total frame count since monitor creation.
322    pub frame_count: u64,
323    /// Recomposition count since the last stats reset.
324    pub recompositions: u64,
325    /// Recompositions in the last second.
326    pub recomps_per_second: u64,
327}
328
329#[cfg(test)]
330mod tests {
331    use super::{nearest_rank_percentile, FpsMonitor, FpsTracker};
332    use std::time::Duration;
333
334    #[test]
335    fn monitors_do_not_share_recomposition_or_frame_counts() {
336        let mut first = FpsMonitor::new();
337        let mut second = FpsMonitor::new();
338
339        first.record_recomposition();
340        first.record_recomposition();
341        first.record_frame();
342        second.record_frame();
343
344        let first_stats = first.stats();
345        let second_stats = second.stats();
346
347        assert_eq!(first_stats.recompositions, 2);
348        assert_eq!(second_stats.recompositions, 0);
349        assert_eq!(first_stats.frame_count, 1);
350        assert_eq!(second_stats.frame_count, 1);
351    }
352
353    #[test]
354    fn reset_stats_reports_recompositions_since_reset() {
355        let mut monitor = FpsMonitor::new();
356        monitor.record_recomposition();
357        monitor.record_recomposition();
358
359        monitor.reset_stats();
360        assert_eq!(monitor.stats().recompositions, 0);
361
362        monitor.record_recomposition();
363        assert_eq!(monitor.stats().recompositions, 1);
364    }
365
366    #[test]
367    fn nearest_rank_percentile_reports_tail_samples() {
368        let samples = [1.0, 2.0, 3.0, 40.0];
369
370        assert_eq!(nearest_rank_percentile(&samples, 50), 2.0);
371        assert_eq!(nearest_rank_percentile(&samples, 95), 40.0);
372        assert_eq!(nearest_rank_percentile(&samples, 99), 40.0);
373    }
374
375    #[test]
376    fn frame_stats_report_pacing_jank_not_just_average_fps() {
377        let mut tracker = FpsTracker::new();
378        let start = web_time::Instant::now();
379        let offsets = [0u64, 8, 16, 24, 64, 72];
380
381        for offset in offsets {
382            tracker.record_frame_at(start + Duration::from_millis(offset), 0);
383        }
384
385        let stats = tracker.stats(0);
386
387        assert_eq!(stats.interval_count, 5);
388        assert_eq!(stats.frame_count, offsets.len() as u64);
389        assert!((stats.latest_ms - 8.0).abs() < 0.1);
390        assert!((stats.max_ms - 40.0).abs() < 0.1);
391        assert!((stats.p95_ms - 40.0).abs() < 0.1);
392        assert_eq!(stats.missed_120hz_budget, 1);
393        assert_eq!(stats.missed_60hz_budget, 1);
394        assert_eq!(stats.stalled_50ms_frames, 0);
395        assert!(
396            stats.fps > 60.0,
397            "average FPS can stay plausible while the p95 frame is bad"
398        );
399    }
400
401    #[test]
402    fn frame_stats_report_frame_work_separately_from_pacing_gaps() {
403        let mut tracker = FpsTracker::new();
404        let start = web_time::Instant::now();
405        let starts = [0u64, 40, 80];
406        let work = [2u64, 3, 4];
407
408        for (start_offset, work_ms) in starts.into_iter().zip(work) {
409            let frame_start = start + Duration::from_millis(start_offset);
410            let frame_end = frame_start + Duration::from_millis(work_ms);
411            tracker.record_frame_work(frame_start, frame_end, 0);
412        }
413
414        let stats = tracker.stats(0);
415
416        assert!((stats.p95_ms - 40.0).abs() < 0.1);
417        assert!((stats.work_avg_ms - 3.0).abs() < 0.1);
418        assert!((stats.work_p95_ms - 4.0).abs() < 0.1);
419        assert!((stats.work_max_ms - 4.0).abs() < 0.1);
420        assert_eq!(stats.missed_120hz_budget, 2);
421        assert_eq!(stats.work_missed_120hz_budget, 0);
422        assert!(
423            stats.work_fps > 300.0,
424            "work FPS must measure renderer capacity, not input cadence: {stats:?}"
425        );
426    }
427
428    #[test]
429    fn reset_stats_drops_active_history_before_measurement_window() {
430        let mut tracker = FpsTracker::new();
431        let start = web_time::Instant::now();
432
433        tracker.record_frame_at(start, 3);
434        tracker.record_frame_at(start + Duration::from_millis(8), 3);
435        tracker.record_frame_at(start + Duration::from_secs(4), 3);
436        let before_reset = tracker.stats(3);
437        assert_eq!(before_reset.interval_count, 1);
438        assert!((before_reset.max_ms - 8.0).abs() < 0.1);
439
440        tracker.reset(3);
441        tracker.record_frame_at(start + Duration::from_secs(4) + Duration::from_millis(8), 3);
442        tracker.record_frame_at(
443            start + Duration::from_secs(4) + Duration::from_millis(16),
444            3,
445        );
446
447        let stats = tracker.stats(3);
448        assert_eq!(stats.frame_count, 2);
449        assert_eq!(stats.interval_count, 1);
450        assert!((stats.max_ms - 8.0).abs() < 0.1);
451        assert_eq!(stats.recomps_per_second, 0);
452    }
453
454    #[test]
455    fn frame_stats_ignore_idle_gap_between_event_driven_frames() {
456        let mut tracker = FpsTracker::new();
457        let start = web_time::Instant::now();
458
459        tracker.record_frame_work(start, start + Duration::from_millis(2), 0);
460        tracker.record_frame_work(
461            start + Duration::from_millis(8),
462            start + Duration::from_millis(10),
463            0,
464        );
465        tracker.record_frame_work(
466            start + Duration::from_secs(4),
467            start + Duration::from_secs(4) + Duration::from_millis(1),
468            0,
469        );
470        tracker.record_frame_work(
471            start + Duration::from_secs(4) + Duration::from_millis(8),
472            start + Duration::from_secs(4) + Duration::from_millis(9),
473            0,
474        );
475
476        let stats = tracker.stats(0);
477
478        assert_eq!(stats.interval_count, 2);
479        assert!(
480            stats.max_ms < 10.0,
481            "idle wait must not be reported as active frame pacing: {stats:?}"
482        );
483        assert!(
484            stats.fps > 120.0,
485            "cheap event-driven frames should report active rendering capacity: {stats:?}"
486        );
487        assert!(
488            stats.work_fps > 500.0,
489            "cheap event-driven work should keep separate capacity stats: {stats:?}"
490        );
491        assert!((stats.work_max_ms - 2.0).abs() < 0.1);
492        assert_eq!(stats.work_missed_120hz_budget, 0);
493    }
494
495    #[test]
496    fn frame_stats_ignore_post_interaction_idle_gap_before_next_redraw() {
497        let mut tracker = FpsTracker::new();
498        let start = web_time::Instant::now();
499
500        tracker.record_frame_work(start, start + Duration::from_millis(3), 0);
501        tracker.record_frame_work(
502            start + Duration::from_millis(8),
503            start + Duration::from_millis(11),
504            0,
505        );
506        tracker.record_frame_work(
507            start + Duration::from_millis(16),
508            start + Duration::from_millis(19),
509            0,
510        );
511        tracker.record_frame_work(
512            start + Duration::from_millis(165),
513            start + Duration::from_millis(168),
514            0,
515        );
516
517        let stats = tracker.stats(0);
518
519        assert_eq!(
520            stats.interval_count, 2,
521            "post-interaction idle gaps must not dilute active redraw cadence: {stats:?}"
522        );
523        assert!((stats.max_ms - 8.0).abs() < 0.1);
524        assert_eq!(stats.stalled_50ms_frames, 0);
525        assert_eq!(stats.work_stalled_50ms_frames, 0);
526    }
527}