Skip to main content

cranpose_app_shell/
fps_monitor.rs

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