Skip to main content

cranpose_app_shell/
fps_monitor.rs

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