Skip to main content

pfx_gpu/
stats.rs

1use std::collections::VecDeque;
2
3use crate::PassTiming;
4
5const DEFAULT_WINDOW: usize = 240;
6const MAX_PASSES: usize = 16;
7const MAX_PASS_NAME: usize = 24;
8
9#[derive(Clone, Copy, Debug, Default, PartialEq)]
10pub struct Percentiles {
11    pub p50: f64,
12    pub p95: f64,
13    pub p99: f64,
14    pub max: f64,
15}
16
17#[derive(Clone, Debug, PartialEq)]
18pub struct FrameStatsSnapshot {
19    pub fps: f64,
20    pub frame_ms: Percentiles,
21    pub gpu_ms: Option<Percentiles>,
22    pub interval_ms: Percentiles,
23    pub size: Option<[u32; 2]>,
24    pub renderer: Option<String>,
25    pub gpu_pass_ms: Vec<(String, f64)>,
26}
27
28struct FrameSample {
29    frame_ms: Option<f64>,
30    interval_ms: Option<f64>,
31    gpu_ms: Option<f64>,
32    passes: Vec<(String, f64)>,
33}
34
35pub struct FrameStats {
36    frames: VecDeque<FrameSample>,
37    window: usize,
38    size: Option<[u32; 2]>,
39    renderer: Option<String>,
40}
41
42impl Default for FrameStats {
43    fn default() -> Self {
44        Self::with_window(DEFAULT_WINDOW)
45    }
46}
47
48impl FrameStats {
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    pub fn with_window(window: usize) -> Self {
54        assert!(window > 0, "frame statistics window must be nonzero");
55        Self {
56            frames: VecDeque::with_capacity(window),
57            window,
58            size: None,
59            renderer: None,
60        }
61    }
62
63    pub fn set_size(&mut self, width: u32, height: u32) {
64        self.size = Some([width, height]);
65    }
66
67    pub fn set_renderer(&mut self, renderer: &str) {
68        self.renderer = match renderer {
69            "live" | "trace" => Some(renderer.to_owned()),
70            _ => None,
71        };
72    }
73
74    pub fn record_frame(
75        &mut self,
76        frame_ms: f64,
77        interval_ms: f64,
78        gpu_ms: impl Into<Option<f64>>,
79        passes: &[PassTiming],
80    ) {
81        let mut sample_passes: Vec<(String, f64)> = Vec::new();
82        for pass in passes {
83            let Some(milliseconds) = valid(pass.milliseconds) else {
84                continue;
85            };
86            let name: String = pass.label.chars().take(MAX_PASS_NAME).collect();
87            if name.is_empty() {
88                continue;
89            }
90            if let Some((_, total)) = sample_passes.iter_mut().find(|(label, _)| *label == name) {
91                if let Some(sum) = valid(*total + milliseconds) {
92                    *total = sum;
93                }
94            } else {
95                sample_passes.push((name, milliseconds));
96            }
97        }
98        if self.frames.len() == self.window {
99            self.frames.pop_front();
100        }
101        self.frames.push_back(FrameSample {
102            frame_ms: valid(frame_ms),
103            interval_ms: valid(interval_ms),
104            gpu_ms: gpu_ms.into().and_then(valid),
105            passes: sample_passes,
106        });
107    }
108
109    pub fn snapshot(&self) -> FrameStatsSnapshot {
110        let frame_ms = percentiles(self.frames.iter().filter_map(|frame| frame.frame_ms));
111        let interval_ms = percentiles(self.frames.iter().filter_map(|frame| frame.interval_ms));
112        let gpu_ms = {
113            let values: Vec<_> = self
114                .frames
115                .iter()
116                .filter_map(|frame| frame.gpu_ms)
117                .collect();
118            (!values.is_empty()).then(|| percentiles(values.into_iter()))
119        };
120        let mut interval_count = 0usize;
121        let mut interval_total = 0.0;
122        for interval in self
123            .frames
124            .iter()
125            .filter_map(|frame| frame.interval_ms)
126            .filter(|interval| *interval > 0.0)
127        {
128            interval_count += 1;
129            interval_total += interval;
130        }
131        let fps = if interval_total > 0.0 {
132            interval_count as f64 * 1000.0 / interval_total
133        } else {
134            0.0
135        };
136        let mut pass_means: Vec<(String, f64, usize)> = Vec::new();
137        for frame in &self.frames {
138            for (name, milliseconds) in &frame.passes {
139                if let Some((_, mean, count)) =
140                    pass_means.iter_mut().find(|(label, _, _)| label == name)
141                {
142                    *count += 1;
143                    *mean += (milliseconds - *mean) / *count as f64;
144                } else {
145                    pass_means.push((name.clone(), *milliseconds, 1));
146                }
147            }
148        }
149        pass_means.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
150        pass_means.truncate(MAX_PASSES);
151        FrameStatsSnapshot {
152            fps,
153            frame_ms,
154            gpu_ms,
155            interval_ms,
156            size: self.size,
157            renderer: self.renderer.clone(),
158            gpu_pass_ms: pass_means
159                .into_iter()
160                .map(|(name, mean, _)| (name, mean))
161                .collect(),
162        }
163    }
164}
165
166fn valid(value: f64) -> Option<f64> {
167    (value.is_finite() && value >= 0.0).then_some(value)
168}
169
170fn percentiles(values: impl Iterator<Item = f64>) -> Percentiles {
171    let mut values: Vec<_> = values.collect();
172    if values.is_empty() {
173        return Percentiles::default();
174    }
175    values.sort_by(f64::total_cmp);
176    let rank = |numerator: usize| (values.len() * numerator).div_ceil(100).saturating_sub(1);
177    Percentiles {
178        p50: values[rank(50)],
179        p95: values[rank(95)],
180        p99: values[rank(99)],
181        max: *values.last().unwrap(),
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn pass(label: &str, milliseconds: f64) -> PassTiming {
190        PassTiming {
191            label: label.to_owned(),
192            milliseconds,
193        }
194    }
195
196    #[test]
197    fn known_percentiles_and_fps() {
198        let mut stats = FrameStats::new();
199        stats.set_size(3840, 2160);
200        stats.set_renderer("live");
201        for number in 1..=100 {
202            stats.record_frame(number as f64, 10.0, Some((101 - number) as f64), &[]);
203        }
204        let snapshot = stats.snapshot();
205        let expected = Percentiles {
206            p50: 50.0,
207            p95: 95.0,
208            p99: 99.0,
209            max: 100.0,
210        };
211        assert_eq!(snapshot.frame_ms, expected);
212        assert_eq!(snapshot.gpu_ms, Some(expected));
213        assert_eq!(
214            snapshot.interval_ms,
215            Percentiles {
216                p50: 10.0,
217                p95: 10.0,
218                p99: 10.0,
219                max: 10.0,
220            }
221        );
222        assert_eq!(snapshot.fps, 100.0);
223        assert_eq!(snapshot.size, Some([3840, 2160]));
224        assert_eq!(snapshot.renderer.as_deref(), Some("live"));
225    }
226
227    #[test]
228    fn window_removes_old_samples_and_passes() {
229        let mut stats = FrameStats::with_window(2);
230        stats.record_frame(1.0, 10.0, 2.0, &[pass("old", 3.0)]);
231        stats.record_frame(2.0, 20.0, 4.0, &[pass("new", 5.0)]);
232        stats.record_frame(3.0, 30.0, 6.0, &[pass("new", 7.0)]);
233        let snapshot = stats.snapshot();
234        assert_eq!(snapshot.frame_ms.max, 3.0);
235        assert_eq!(snapshot.frame_ms.p50, 2.0);
236        assert_eq!(snapshot.interval_ms.p50, 20.0);
237        assert_eq!(snapshot.gpu_ms.unwrap().p50, 4.0);
238        assert_eq!(snapshot.fps, 40.0);
239        assert_eq!(snapshot.gpu_pass_ms, vec![("new".to_owned(), 6.0)]);
240    }
241
242    #[test]
243    fn passes_are_capped_truncated_and_averaged() {
244        let mut stats = FrameStats::new();
245        let long_name = "abcdefghijklmnopqrstuvwx-extra";
246        let mut passes = vec![pass(long_name, 2.0), pass(long_name, 3.0)];
247        passes.extend((0..16).map(|index| pass(&format!("pass{index}"), index as f64)));
248        stats.record_frame(1.0, 1.0, None, &passes);
249        stats.record_frame(1.0, 1.0, None, &[pass("abcdefghijklmnopqrstuvwx", 7.0)]);
250        let snapshot = stats.snapshot();
251        assert_eq!(snapshot.gpu_pass_ms.len(), 16);
252        assert_eq!(snapshot.gpu_pass_ms[0], ("pass15".to_owned(), 15.0));
253        assert_eq!(snapshot.gpu_pass_ms[15], ("pass1".to_owned(), 1.0));
254        assert!(
255            snapshot
256                .gpu_pass_ms
257                .contains(&("abcdefghijklmnopqrstuvwx".to_owned(), 6.0))
258        );
259        assert_eq!(snapshot.gpu_ms, None);
260    }
261
262    #[test]
263    fn invalid_values_are_dropped_independently() {
264        let mut stats = FrameStats::new();
265        stats.record_frame(
266            f64::NAN,
267            f64::INFINITY,
268            Some(-1.0),
269            &[pass("bad", f64::NAN)],
270        );
271        stats.record_frame(4.0, 0.0, Some(5.0), &[pass("ok", 2.0)]);
272        stats.record_frame(-1.0, 20.0, Some(f64::INFINITY), &[pass("ok", -1.0)]);
273        let snapshot = stats.snapshot();
274        assert_eq!(snapshot.frame_ms.max, 4.0);
275        assert_eq!(snapshot.gpu_ms.unwrap().max, 5.0);
276        assert_eq!(snapshot.interval_ms.max, 20.0);
277        assert_eq!(snapshot.fps, 50.0);
278        assert_eq!(snapshot.gpu_pass_ms, vec![("ok".to_owned(), 2.0)]);
279    }
280
281    #[test]
282    fn fps_uses_total_interval_not_average_instant_fps() {
283        let mut stats = FrameStats::new();
284        stats.record_frame(1.0, 10.0, None, &[]);
285        stats.record_frame(1.0, 30.0, None, &[]);
286        assert_eq!(stats.snapshot().fps, 50.0);
287    }
288}