#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FrameStats {
pub count: usize,
pub fps: f64,
pub avg_ms: f64,
pub min_ms: f64,
pub max_ms: f64,
pub p50_ms: f64,
pub p95_ms: f64,
pub p99_ms: f64,
}
impl FrameStats {
pub(crate) fn compute<const N: usize>(
samples: &[u64; N],
head: usize,
count: usize,
) -> Option<Self> {
if count == 0 {
return None;
}
let start = if count < N { 0 } else { head };
let mut sum = 0_u64;
let mut min_us = u64::MAX;
let mut max_us = 0_u64;
let mut sorted = [0_u64; N];
for i in 0..count {
let v = samples[(start + i) % N];
sorted[i] = v;
sum = sum.saturating_add(v);
min_us = min_us.min(v);
max_us = max_us.max(v);
}
let valid = &mut sorted[..count];
valid.sort_unstable();
#[allow(clippy::cast_precision_loss)]
let mean_us = sum as f64 / count as f64;
Some(Self {
count,
fps: if mean_us > 0.0 {
1_000_000.0 / mean_us
} else {
f64::INFINITY
},
avg_ms: mean_us / 1_000.0,
#[allow(clippy::cast_precision_loss)]
min_ms: min_us as f64 / 1_000.0,
#[allow(clippy::cast_precision_loss)]
max_ms: max_us as f64 / 1_000.0,
p50_ms: percentile_ms(valid, 50.0),
p95_ms: percentile_ms(valid, 95.0),
p99_ms: percentile_ms(valid, 99.0),
})
}
}
fn percentile_ms(sorted_us: &[u64], pct: f64) -> f64 {
let n = sorted_us.len();
debug_assert!(n > 0, "called with empty slice");
if n == 1 {
#[allow(clippy::cast_precision_loss)]
return sorted_us[0] as f64 / 1_000.0;
}
#[allow(clippy::cast_precision_loss)]
let rank = pct / 100.0 * (n - 1) as f64;
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
let lo = rank as usize;
let hi = (lo + 1).min(n - 1);
#[allow(clippy::cast_precision_loss)]
let frac = rank - lo as f64;
#[allow(clippy::cast_precision_loss)]
let result = frac.mul_add(
sorted_us[hi] as f64 - sorted_us[lo] as f64,
sorted_us[lo] as f64,
) / 1_000.0;
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_sample() {
let samples = [16_000_u64; 1];
let stats = FrameStats::compute(&samples, 0, 1).unwrap();
assert!((stats.avg_ms - 16.0).abs() < f64::EPSILON);
assert!((stats.p50_ms - 16.0).abs() < f64::EPSILON);
assert!((stats.p99_ms - 16.0).abs() < f64::EPSILON);
}
#[test]
fn empty_returns_none() {
let samples = [0_u64; 4];
assert!(FrameStats::compute(&samples, 0, 0).is_none());
}
#[test]
fn min_max_correct() {
let samples = [1_000_u64, 2_000, 3_000, 4_000];
let stats = FrameStats::compute(&samples, 0, 4).unwrap();
assert!((stats.min_ms - 1.0).abs() < f64::EPSILON);
assert!((stats.max_ms - 4.0).abs() < f64::EPSILON);
}
#[test]
fn ring_wrap_uses_correct_window() {
let mut samples = [10_000_u64, 20_000, 30_000, 40_000];
samples[0] = 50_000;
samples[1] = 60_000;
let stats = FrameStats::compute(&samples, 2, 4).unwrap();
assert!((stats.avg_ms - 45.0).abs() < 0.001);
}
#[test]
fn percentile_monotone() {
let samples = [
5_000_u64, 10_000, 15_000, 20_000, 25_000, 30_000, 35_000, 40_000,
];
let stats = FrameStats::compute(&samples, 0, 8).unwrap();
assert!(stats.p50_ms <= stats.p95_ms);
assert!(stats.p95_ms <= stats.p99_ms);
}
}