1use std::collections::VecDeque;
20use std::sync::{LazyLock, Mutex};
21use std::time::Duration;
22use web_time::Instant;
23
24const WINDOW_CAPACITY: usize = 256;
28
29const MAX_ACTIVE_INTERVAL: Duration = Duration::from_millis(100);
34
35const MISSED_REFRESH_FACTOR: f64 = 1.5;
38
39#[derive(Clone, Copy)]
40struct FrameRecord {
41 started: Instant,
42 resolve: Duration,
43 paint: Duration,
44 renderer: Duration,
45}
46
47#[derive(Default)]
48struct FrameLog {
49 frames: VecDeque<FrameRecord>,
50 total: u64,
51 refresh_millihertz: Option<u32>,
52}
53
54static FRAME_LOG: LazyLock<Mutex<FrameLog>> = LazyLock::new(|| Mutex::new(FrameLog::default()));
55
56#[derive(Debug, Clone, Copy, Default, PartialEq)]
62pub struct TimingStats {
63 pub mean_ms: f64,
64 pub p95_ms: f64,
65 pub max_ms: f64,
66}
67
68#[derive(Debug, Clone, Copy, Default, PartialEq)]
70pub struct FrameTimings {
71 pub resolve_ms: f64,
74 pub paint_ms: f64,
77 pub renderer_ms: f64,
80 pub total_ms: f64,
83 pub age_ms: f64,
86}
87
88#[derive(Debug, Clone, PartialEq)]
90pub struct FrameStatsSnapshot {
91 pub frames_total: u64,
93 pub window_frames: u64,
95 pub latest: FrameTimings,
97 pub resolve: TimingStats,
98 pub paint: TimingStats,
99 pub renderer: TimingStats,
100 pub frame_total: TimingStats,
102 pub interval: TimingStats,
104 pub active_fps: f64,
107 pub missed_refreshes: u64,
110 pub display_refresh_hz: Option<f64>,
113}
114
115pub(crate) fn set_display_refresh_millihertz(rate: Option<u32>) {
118 if let Ok(mut log) = FRAME_LOG.lock() {
119 if rate.is_some() {
122 log.refresh_millihertz = rate;
123 }
124 }
125}
126
127pub fn clear_frame_stats() {
129 if let Ok(mut log) = FRAME_LOG.lock() {
130 let refresh_millihertz = log.refresh_millihertz;
131 *log = FrameLog {
132 refresh_millihertz,
133 ..FrameLog::default()
134 };
135 }
136}
137
138pub(crate) fn display_refresh_millihertz() -> Option<u32> {
144 FRAME_LOG.lock().ok().and_then(|log| log.refresh_millihertz)
145}
146
147pub(crate) fn record_frame(
149 started: Instant,
150 resolve: Duration,
151 paint: Duration,
152 renderer: Duration,
153) {
154 let Ok(mut log) = FRAME_LOG.lock() else {
157 return;
158 };
159 if log.frames.len() == WINDOW_CAPACITY {
160 log.frames.pop_front();
161 }
162 log.frames.push_back(FrameRecord {
163 started,
164 resolve,
165 paint,
166 renderer,
167 });
168 log.total = log.total.saturating_add(1);
169}
170
171pub fn latest_frame_stats() -> Option<FrameStatsSnapshot> {
189 if !blitz_traits::profiling::deep_profiling_permitted() {
190 return None;
191 }
192 let log = FRAME_LOG.lock().ok()?;
193 summarise(
194 &log.frames,
195 log.total,
196 log.refresh_millihertz,
197 Instant::now(),
198 )
199}
200
201fn summarise(
202 frames: &VecDeque<FrameRecord>,
203 frames_total: u64,
204 refresh_millihertz: Option<u32>,
205 now: Instant,
206) -> Option<FrameStatsSnapshot> {
207 let newest = frames.back()?;
208
209 let mut resolve = Vec::with_capacity(frames.len());
210 let mut paint = Vec::with_capacity(frames.len());
211 let mut renderer = Vec::with_capacity(frames.len());
212 let mut frame_total = Vec::with_capacity(frames.len());
213 let mut intervals = Vec::with_capacity(frames.len());
214 let mut interval_sum = Duration::ZERO;
215 let mut missed_refreshes = 0u64;
216
217 let target = refresh_millihertz
218 .filter(|rate| *rate > 0)
219 .map(|rate| Duration::from_secs_f64(1000.0 / f64::from(rate)));
220
221 let mut previous: Option<Instant> = None;
222 for frame in frames {
223 resolve.push(to_ms(frame.resolve));
224 paint.push(to_ms(frame.paint));
225 renderer.push(to_ms(frame.renderer));
226 frame_total.push(to_ms(frame.resolve + frame.paint + frame.renderer));
227
228 if let Some(previous) = previous.replace(frame.started) {
229 let interval = frame.started.saturating_duration_since(previous);
230 if interval <= MAX_ACTIVE_INTERVAL {
231 intervals.push(to_ms(interval));
232 interval_sum += interval;
233 if target.is_some_and(|target| interval > target.mul_f64(MISSED_REFRESH_FACTOR)) {
234 missed_refreshes += 1;
235 }
236 }
237 }
238 }
239
240 let active_fps = if interval_sum.is_zero() {
241 0.0
242 } else {
243 intervals.len() as f64 / interval_sum.as_secs_f64()
244 };
245
246 Some(FrameStatsSnapshot {
247 frames_total,
248 window_frames: frames.len() as u64,
249 latest: FrameTimings {
250 resolve_ms: to_ms(newest.resolve),
251 paint_ms: to_ms(newest.paint),
252 renderer_ms: to_ms(newest.renderer),
253 total_ms: to_ms(newest.resolve + newest.paint + newest.renderer),
254 age_ms: to_ms(now.saturating_duration_since(newest.started)),
255 },
256 resolve: TimingStats::from_samples(&mut resolve),
257 paint: TimingStats::from_samples(&mut paint),
258 renderer: TimingStats::from_samples(&mut renderer),
259 frame_total: TimingStats::from_samples(&mut frame_total),
260 interval: TimingStats::from_samples(&mut intervals),
261 active_fps,
262 missed_refreshes,
263 display_refresh_hz: refresh_millihertz.map(|rate| f64::from(rate) / 1000.0),
264 })
265}
266
267fn to_ms(duration: Duration) -> f64 {
268 duration.as_secs_f64() * 1000.0
269}
270
271impl TimingStats {
272 fn from_samples(samples: &mut [f64]) -> Self {
278 if samples.is_empty() {
279 return Self::default();
280 }
281 samples.sort_by(f64::total_cmp);
282 let count = samples.len();
283 let sum: f64 = samples.iter().sum();
284 let rank = ((count as f64) * 0.95).ceil() as usize;
285 let index = rank.clamp(1, count) - 1;
286 Self {
287 mean_ms: sum / count as f64,
288 p95_ms: samples[index],
289 max_ms: samples[count - 1],
290 }
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 fn log(frames: &[(u64, u64, u64, u64)]) -> VecDeque<FrameRecord> {
299 let origin = Instant::now();
300 frames
301 .iter()
302 .map(|(offset_ms, resolve, paint, renderer)| FrameRecord {
303 started: origin + Duration::from_millis(*offset_ms),
304 resolve: Duration::from_millis(*resolve),
305 paint: Duration::from_millis(*paint),
306 renderer: Duration::from_millis(*renderer),
307 })
308 .collect()
309 }
310
311 #[test]
312 fn empty_log_reports_nothing_rather_than_zeroes() {
313 assert!(summarise(&VecDeque::new(), 0, None, Instant::now()).is_none());
314 }
315
316 #[test]
317 fn latest_frame_is_the_newest_record() {
318 let frames = log(&[(0, 1, 2, 3), (16, 4, 5, 6)]);
319 let stats = summarise(&frames, 2, None, Instant::now()).unwrap();
320 assert_eq!(stats.latest.resolve_ms, 4.0);
321 assert_eq!(stats.latest.paint_ms, 5.0);
322 assert_eq!(stats.latest.renderer_ms, 6.0);
323 assert_eq!(stats.latest.total_ms, 15.0);
324 assert_eq!(stats.frames_total, 2);
325 assert_eq!(stats.window_frames, 2);
326 }
327
328 #[test]
329 fn worst_frame_survives_the_mean() {
330 let mut frames: Vec<(u64, u64, u64, u64)> = (0..40).map(|i| (i * 16, 1, 1, 1)).collect();
331 frames.push((40 * 16, 30, 1, 1));
332 let stats = summarise(&log(&frames), 41, None, Instant::now()).unwrap();
333 assert!(stats.resolve.mean_ms < 2.0);
334 assert_eq!(stats.resolve.max_ms, 30.0);
335 assert_eq!(stats.resolve.p95_ms, 1.0);
336 assert_eq!(stats.frame_total.max_ms, 32.0);
337 }
338
339 #[test]
340 fn idle_gaps_do_not_count_as_slow_frames() {
341 let frames = log(&[(0, 1, 1, 1), (16, 1, 1, 1), (5016, 1, 1, 1)]);
343 let stats = summarise(&frames, 3, Some(60_000), Instant::now()).unwrap();
344 assert_eq!(stats.interval.max_ms, 16.0);
345 assert!((stats.active_fps - 62.5).abs() < 0.01);
346 assert_eq!(stats.missed_refreshes, 0);
347 }
348
349 #[test]
350 fn a_late_frame_counts_as_a_missed_refresh() {
351 let frames = log(&[(0, 1, 1, 1), (40, 1, 1, 1)]);
353 let stats = summarise(&frames, 2, Some(60_000), Instant::now()).unwrap();
354 assert_eq!(stats.missed_refreshes, 1);
355 assert_eq!(stats.display_refresh_hz, Some(60.0));
356 }
357
358 #[test]
359 fn missed_refreshes_stay_zero_without_a_known_refresh_rate() {
360 let frames = log(&[(0, 1, 1, 1), (90, 1, 1, 1)]);
361 let stats = summarise(&frames, 2, None, Instant::now()).unwrap();
362 assert_eq!(stats.missed_refreshes, 0);
363 assert_eq!(stats.display_refresh_hz, None);
364 }
365
366 #[test]
367 fn p95_picks_the_nearest_rank() {
368 let mut samples: Vec<f64> = (1..=20).map(f64::from).collect();
369 let stats = TimingStats::from_samples(&mut samples);
370 assert_eq!(stats.p95_ms, 19.0);
371 assert_eq!(stats.max_ms, 20.0);
372 assert_eq!(stats.mean_ms, 10.5);
373 }
374
375 #[test]
376 fn recording_publishes_to_the_process_global_log() {
377 let _serial = crate::exclusive_profiling_state();
380 blitz_traits::profiling::set_deep_profiling_permitted(true);
384 let session = blitz_traits::profiling::begin_deep_profiling().expect("permitted");
385 clear_frame_stats();
386 record_frame(
387 Instant::now(),
388 Duration::from_millis(2),
389 Duration::from_millis(3),
390 Duration::from_millis(4),
391 );
392 let stats = latest_frame_stats().expect("a frame was just recorded");
393 assert!(stats.frames_total >= 1);
394 assert!(stats.latest.total_ms >= 9.0);
395 drop(session);
396 blitz_traits::profiling::set_deep_profiling_permitted(false);
397 clear_frame_stats();
398 }
399}