Skip to main content

ashlar_app/
stats.rs

1//! Frame-time statistics: how long producing each frame took (logic + UI, not the time between
2//! frames, which is unbounded for an app that repaints on demand).
3
4use std::collections::VecDeque;
5use std::time::Duration;
6
7use serde::Serialize;
8
9/// Frames kept for the percentiles.
10pub const WINDOW: usize = 1024;
11
12/// Recent frame times.
13#[derive(Debug, Default)]
14pub struct FrameStats {
15    recent: VecDeque<Duration>,
16    frames: u64,
17    first_frame: Option<Duration>,
18    max_ever: Duration,
19}
20
21/// A summary for telemetry and `app.stats`. Times in milliseconds.
22#[derive(Clone, Debug, Default, PartialEq, Serialize)]
23pub struct StatsSummary {
24    /// Frames produced since start.
25    pub frames: u64,
26    /// Frames in the percentile window.
27    pub window: usize,
28    pub p50_ms: f64,
29    pub p95_ms: f64,
30    pub max_ms: f64,
31    /// Slowest frame since start.
32    pub max_ever_ms: f64,
33    /// Time from runtime creation to the end of the first frame.
34    pub first_frame_ms: Option<f64>,
35}
36
37fn ms(d: Duration) -> f64 {
38    d.as_secs_f64() * 1000.0
39}
40
41impl FrameStats {
42    pub fn record(&mut self, d: Duration) {
43        if self.recent.len() >= WINDOW {
44            self.recent.pop_front();
45        }
46        self.recent.push_back(d);
47        self.frames = self.frames.saturating_add(1);
48        self.max_ever = self.max_ever.max(d);
49    }
50
51    pub fn set_first_frame(&mut self, d: Duration) {
52        self.first_frame.get_or_insert(d);
53    }
54
55    pub fn frames(&self) -> u64 {
56        self.frames
57    }
58
59    /// Forget the percentile window (start a measurement).
60    pub fn reset_window(&mut self) {
61        self.recent.clear();
62    }
63
64    pub fn summary(&self) -> StatsSummary {
65        let mut v: Vec<Duration> = self.recent.iter().copied().collect();
66        v.sort_unstable();
67        let pick = |q: f64| -> f64 {
68            if v.is_empty() {
69                return 0.0;
70            }
71            let i = ((v.len() as f64 - 1.0) * q).round() as usize;
72            v.get(i.min(v.len() - 1)).copied().map_or(0.0, ms)
73        };
74        StatsSummary {
75            frames: self.frames,
76            window: v.len(),
77            p50_ms: pick(0.5),
78            p95_ms: pick(0.95),
79            max_ms: v.last().copied().map_or(0.0, ms),
80            max_ever_ms: ms(self.max_ever),
81            first_frame_ms: self.first_frame.map(ms),
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn percentiles() {
92        let mut s = FrameStats::default();
93        for i in 1..=100u64 {
94            s.record(Duration::from_millis(i));
95        }
96        let sum = s.summary();
97        assert_eq!(sum.frames, 100);
98        assert!((sum.p50_ms - 50.0).abs() <= 1.0);
99        assert!((sum.p95_ms - 95.0).abs() <= 1.0);
100        assert!((sum.max_ms - 100.0).abs() < 1e-9);
101        assert_eq!(FrameStats::default().summary().p95_ms, 0.0);
102    }
103
104    #[test]
105    fn the_window_is_bounded() {
106        let mut s = FrameStats::default();
107        for _ in 0..(WINDOW * 3) {
108            s.record(Duration::from_micros(10));
109        }
110        assert_eq!(s.summary().window, WINDOW);
111        assert_eq!(s.frames() as usize, WINDOW * 3);
112    }
113}