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}