use std::collections::VecDeque;
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use web_time::Instant;
const WINDOW_CAPACITY: usize = 256;
const MAX_ACTIVE_INTERVAL: Duration = Duration::from_millis(100);
const MISSED_REFRESH_FACTOR: f64 = 1.5;
#[derive(Clone, Copy)]
struct FrameRecord {
started: Instant,
resolve: Duration,
paint: Duration,
renderer: Duration,
}
#[derive(Default)]
struct FrameLog {
frames: VecDeque<FrameRecord>,
total: u64,
refresh_millihertz: Option<u32>,
}
static FRAME_LOG: LazyLock<Mutex<FrameLog>> = LazyLock::new(|| Mutex::new(FrameLog::default()));
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct TimingStats {
pub mean_ms: f64,
pub p95_ms: f64,
pub max_ms: f64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct FrameTimings {
pub resolve_ms: f64,
pub paint_ms: f64,
pub renderer_ms: f64,
pub total_ms: f64,
pub age_ms: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FrameStatsSnapshot {
pub frames_total: u64,
pub window_frames: u64,
pub latest: FrameTimings,
pub resolve: TimingStats,
pub paint: TimingStats,
pub renderer: TimingStats,
pub frame_total: TimingStats,
pub interval: TimingStats,
pub active_fps: f64,
pub missed_refreshes: u64,
pub display_refresh_hz: Option<f64>,
}
pub(crate) fn set_display_refresh_millihertz(rate: Option<u32>) {
if let Ok(mut log) = FRAME_LOG.lock() {
if rate.is_some() {
log.refresh_millihertz = rate;
}
}
}
pub fn clear_frame_stats() {
if let Ok(mut log) = FRAME_LOG.lock() {
let refresh_millihertz = log.refresh_millihertz;
*log = FrameLog {
refresh_millihertz,
..FrameLog::default()
};
}
}
pub(crate) fn display_refresh_millihertz() -> Option<u32> {
FRAME_LOG.lock().ok().and_then(|log| log.refresh_millihertz)
}
pub(crate) fn record_frame(
started: Instant,
resolve: Duration,
paint: Duration,
renderer: Duration,
) {
let Ok(mut log) = FRAME_LOG.lock() else {
return;
};
if log.frames.len() == WINDOW_CAPACITY {
log.frames.pop_front();
}
log.frames.push_back(FrameRecord {
started,
resolve,
paint,
renderer,
});
log.total = log.total.saturating_add(1);
}
pub fn latest_frame_stats() -> Option<FrameStatsSnapshot> {
if !blitz_traits::profiling::deep_profiling_permitted() {
return None;
}
let log = FRAME_LOG.lock().ok()?;
summarise(
&log.frames,
log.total,
log.refresh_millihertz,
Instant::now(),
)
}
fn summarise(
frames: &VecDeque<FrameRecord>,
frames_total: u64,
refresh_millihertz: Option<u32>,
now: Instant,
) -> Option<FrameStatsSnapshot> {
let newest = frames.back()?;
let mut resolve = Vec::with_capacity(frames.len());
let mut paint = Vec::with_capacity(frames.len());
let mut renderer = Vec::with_capacity(frames.len());
let mut frame_total = Vec::with_capacity(frames.len());
let mut intervals = Vec::with_capacity(frames.len());
let mut interval_sum = Duration::ZERO;
let mut missed_refreshes = 0u64;
let target = refresh_millihertz
.filter(|rate| *rate > 0)
.map(|rate| Duration::from_secs_f64(1000.0 / f64::from(rate)));
let mut previous: Option<Instant> = None;
for frame in frames {
resolve.push(to_ms(frame.resolve));
paint.push(to_ms(frame.paint));
renderer.push(to_ms(frame.renderer));
frame_total.push(to_ms(frame.resolve + frame.paint + frame.renderer));
if let Some(previous) = previous.replace(frame.started) {
let interval = frame.started.saturating_duration_since(previous);
if interval <= MAX_ACTIVE_INTERVAL {
intervals.push(to_ms(interval));
interval_sum += interval;
if target.is_some_and(|target| interval > target.mul_f64(MISSED_REFRESH_FACTOR)) {
missed_refreshes += 1;
}
}
}
}
let active_fps = if interval_sum.is_zero() {
0.0
} else {
intervals.len() as f64 / interval_sum.as_secs_f64()
};
Some(FrameStatsSnapshot {
frames_total,
window_frames: frames.len() as u64,
latest: FrameTimings {
resolve_ms: to_ms(newest.resolve),
paint_ms: to_ms(newest.paint),
renderer_ms: to_ms(newest.renderer),
total_ms: to_ms(newest.resolve + newest.paint + newest.renderer),
age_ms: to_ms(now.saturating_duration_since(newest.started)),
},
resolve: TimingStats::from_samples(&mut resolve),
paint: TimingStats::from_samples(&mut paint),
renderer: TimingStats::from_samples(&mut renderer),
frame_total: TimingStats::from_samples(&mut frame_total),
interval: TimingStats::from_samples(&mut intervals),
active_fps,
missed_refreshes,
display_refresh_hz: refresh_millihertz.map(|rate| f64::from(rate) / 1000.0),
})
}
fn to_ms(duration: Duration) -> f64 {
duration.as_secs_f64() * 1000.0
}
impl TimingStats {
fn from_samples(samples: &mut [f64]) -> Self {
if samples.is_empty() {
return Self::default();
}
samples.sort_by(f64::total_cmp);
let count = samples.len();
let sum: f64 = samples.iter().sum();
let rank = ((count as f64) * 0.95).ceil() as usize;
let index = rank.clamp(1, count) - 1;
Self {
mean_ms: sum / count as f64,
p95_ms: samples[index],
max_ms: samples[count - 1],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn log(frames: &[(u64, u64, u64, u64)]) -> VecDeque<FrameRecord> {
let origin = Instant::now();
frames
.iter()
.map(|(offset_ms, resolve, paint, renderer)| FrameRecord {
started: origin + Duration::from_millis(*offset_ms),
resolve: Duration::from_millis(*resolve),
paint: Duration::from_millis(*paint),
renderer: Duration::from_millis(*renderer),
})
.collect()
}
#[test]
fn empty_log_reports_nothing_rather_than_zeroes() {
assert!(summarise(&VecDeque::new(), 0, None, Instant::now()).is_none());
}
#[test]
fn latest_frame_is_the_newest_record() {
let frames = log(&[(0, 1, 2, 3), (16, 4, 5, 6)]);
let stats = summarise(&frames, 2, None, Instant::now()).unwrap();
assert_eq!(stats.latest.resolve_ms, 4.0);
assert_eq!(stats.latest.paint_ms, 5.0);
assert_eq!(stats.latest.renderer_ms, 6.0);
assert_eq!(stats.latest.total_ms, 15.0);
assert_eq!(stats.frames_total, 2);
assert_eq!(stats.window_frames, 2);
}
#[test]
fn worst_frame_survives_the_mean() {
let mut frames: Vec<(u64, u64, u64, u64)> = (0..40).map(|i| (i * 16, 1, 1, 1)).collect();
frames.push((40 * 16, 30, 1, 1));
let stats = summarise(&log(&frames), 41, None, Instant::now()).unwrap();
assert!(stats.resolve.mean_ms < 2.0);
assert_eq!(stats.resolve.max_ms, 30.0);
assert_eq!(stats.resolve.p95_ms, 1.0);
assert_eq!(stats.frame_total.max_ms, 32.0);
}
#[test]
fn idle_gaps_do_not_count_as_slow_frames() {
let frames = log(&[(0, 1, 1, 1), (16, 1, 1, 1), (5016, 1, 1, 1)]);
let stats = summarise(&frames, 3, Some(60_000), Instant::now()).unwrap();
assert_eq!(stats.interval.max_ms, 16.0);
assert!((stats.active_fps - 62.5).abs() < 0.01);
assert_eq!(stats.missed_refreshes, 0);
}
#[test]
fn a_late_frame_counts_as_a_missed_refresh() {
let frames = log(&[(0, 1, 1, 1), (40, 1, 1, 1)]);
let stats = summarise(&frames, 2, Some(60_000), Instant::now()).unwrap();
assert_eq!(stats.missed_refreshes, 1);
assert_eq!(stats.display_refresh_hz, Some(60.0));
}
#[test]
fn missed_refreshes_stay_zero_without_a_known_refresh_rate() {
let frames = log(&[(0, 1, 1, 1), (90, 1, 1, 1)]);
let stats = summarise(&frames, 2, None, Instant::now()).unwrap();
assert_eq!(stats.missed_refreshes, 0);
assert_eq!(stats.display_refresh_hz, None);
}
#[test]
fn p95_picks_the_nearest_rank() {
let mut samples: Vec<f64> = (1..=20).map(f64::from).collect();
let stats = TimingStats::from_samples(&mut samples);
assert_eq!(stats.p95_ms, 19.0);
assert_eq!(stats.max_ms, 20.0);
assert_eq!(stats.mean_ms, 10.5);
}
#[test]
fn recording_publishes_to_the_process_global_log() {
let _serial = crate::exclusive_profiling_state();
blitz_traits::profiling::set_deep_profiling_permitted(true);
let session = blitz_traits::profiling::begin_deep_profiling().expect("permitted");
clear_frame_stats();
record_frame(
Instant::now(),
Duration::from_millis(2),
Duration::from_millis(3),
Duration::from_millis(4),
);
let stats = latest_frame_stats().expect("a frame was just recorded");
assert!(stats.frames_total >= 1);
assert!(stats.latest.total_ms >= 9.0);
drop(session);
blitz_traits::profiling::set_deep_profiling_permitted(false);
clear_frame_stats();
}
}