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