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