use std::collections::VecDeque;
use std::fmt;
use std::time::{Duration, Instant};
const DEFAULT_INTERVAL_WINDOW: usize = 240;
const MIN_ESTIMATION_INTERVALS: usize = 10;
const MISSED_INTERVAL_FACTOR: f64 = 1.5;
#[derive(Debug)]
pub struct PacingDiagnostics {
intervals: VecDeque<Duration>,
window: usize,
last_presented_at: Option<Instant>,
presented_frames: u64,
untimed_frames: u64,
missed_intervals: u64,
}
impl Default for PacingDiagnostics {
fn default() -> Self {
Self::new()
}
}
impl PacingDiagnostics {
#[must_use]
pub fn new() -> Self {
Self::with_window(DEFAULT_INTERVAL_WINDOW)
}
#[must_use]
pub fn with_window(window: usize) -> Self {
Self {
intervals: VecDeque::new(),
window: window.max(1),
last_presented_at: None,
presented_frames: 0,
untimed_frames: 0,
missed_intervals: 0,
}
}
pub fn record_presented(&mut self, presented_at: Instant) {
self.presented_frames += 1;
if let Some(previous) = self.last_presented_at
&& let Some(interval) = presented_at.checked_duration_since(previous)
&& !interval.is_zero()
{
if let Some(cadence) = self.estimated_cadence()
&& interval.as_secs_f64() > cadence.as_secs_f64() * MISSED_INTERVAL_FACTOR
{
self.missed_intervals += 1;
}
if self.intervals.len() >= self.window {
self.intervals.pop_front();
}
self.intervals.push_back(interval);
}
self.last_presented_at = Some(presented_at);
}
pub fn record_untimed_presented(&mut self) {
self.presented_frames += 1;
self.untimed_frames += 1;
}
#[must_use]
pub fn estimated_cadence(&self) -> Option<Duration> {
if self.intervals.len() < MIN_ESTIMATION_INTERVALS {
return None;
}
let mut sorted: Vec<Duration> = self.intervals.iter().copied().collect();
sorted.sort_unstable();
Some(sorted[(sorted.len() - 1) / 2])
}
#[must_use]
pub fn report(&self) -> PacingReport {
let mut sorted: Vec<Duration> = self.intervals.iter().copied().collect();
sorted.sort_unstable();
let recent_intervals = (!sorted.is_empty()).then(|| IntervalSummary {
samples: sorted.len(),
min: sorted[0],
median: sorted[(sorted.len() - 1) / 2],
p95: sorted[percentile_rank(sorted.len(), 95)],
max: sorted[sorted.len() - 1],
});
PacingReport {
presented_frames: self.presented_frames,
untimed_frames: self.untimed_frames,
estimated_cadence: self.estimated_cadence(),
recent_intervals,
missed_intervals: self.missed_intervals,
}
}
}
fn percentile_rank(length: usize, percent: usize) -> usize {
(percent * length).div_ceil(100).max(1) - 1
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct IntervalSummary {
pub samples: usize,
pub min: Duration,
pub median: Duration,
pub p95: Duration,
pub max: Duration,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PacingReport {
pub presented_frames: u64,
pub untimed_frames: u64,
pub estimated_cadence: Option<Duration>,
pub recent_intervals: Option<IntervalSummary>,
pub missed_intervals: u64,
}
impl fmt::Display for PacingReport {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{} presented frames ({} without a display time)",
self.presented_frames, self.untimed_frames
)?;
if let Some(cadence) = self.estimated_cadence {
write!(
formatter,
", estimated cadence {:.3} ms",
cadence.as_secs_f64() * 1_000.0
)?;
}
if let Some(intervals) = self.recent_intervals {
write!(
formatter,
", recent intervals (n={}) min {:.3} ms, median {:.3} ms, p95 {:.3} ms, max {:.3} ms",
intervals.samples,
intervals.min.as_secs_f64() * 1_000.0,
intervals.median.as_secs_f64() * 1_000.0,
intervals.p95.as_secs_f64() * 1_000.0,
intervals.max.as_secs_f64() * 1_000.0,
)?;
}
write!(formatter, ", {} missed intervals", self.missed_intervals)
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use super::{MIN_ESTIMATION_INTERVALS, PacingDiagnostics};
const STEP: Duration = Duration::from_micros(16_667);
fn diagnostics_after_steady_frames(count: usize) -> (PacingDiagnostics, Instant) {
let mut diagnostics = PacingDiagnostics::new();
let base = Instant::now();
let mut at = base;
for _ in 0..count {
diagnostics.record_presented(at);
at += STEP;
}
(diagnostics, at)
}
#[test]
fn steady_frames_estimate_their_cadence() {
let (diagnostics, _) = diagnostics_after_steady_frames(60);
let report = diagnostics.report();
assert_eq!(report.presented_frames, 60);
assert_eq!(report.untimed_frames, 0);
assert_eq!(report.estimated_cadence, Some(STEP));
assert_eq!(report.missed_intervals, 0);
let intervals = report.recent_intervals.unwrap();
assert_eq!(intervals.samples, 59);
assert_eq!(intervals.min, STEP);
assert_eq!(intervals.max, STEP);
}
#[test]
fn a_skipped_vsync_counts_as_missed_once_estimation_exists() {
let (mut diagnostics, mut at) = diagnostics_after_steady_frames(30);
at += STEP;
diagnostics.record_presented(at);
assert_eq!(diagnostics.report().missed_intervals, 1);
assert_eq!(diagnostics.report().estimated_cadence, Some(STEP));
}
#[test]
fn estimation_requires_enough_intervals() {
let (diagnostics, _) = diagnostics_after_steady_frames(MIN_ESTIMATION_INTERVALS);
assert_eq!(diagnostics.report().estimated_cadence, None);
let (diagnostics, _) = diagnostics_after_steady_frames(MIN_ESTIMATION_INTERVALS + 1);
assert!(diagnostics.report().estimated_cadence.is_some());
}
#[test]
fn untimed_and_non_monotonic_frames_count_without_intervals() {
let mut diagnostics = PacingDiagnostics::new();
let base = Instant::now();
diagnostics.record_presented(base);
diagnostics.record_presented(base);
diagnostics.record_untimed_presented();
let report = diagnostics.report();
assert_eq!(report.presented_frames, 3);
assert_eq!(report.untimed_frames, 1);
assert!(report.recent_intervals.is_none());
}
#[test]
fn the_interval_window_is_bounded() {
let mut diagnostics = PacingDiagnostics::with_window(4);
let mut at = Instant::now();
for _ in 0..20 {
diagnostics.record_presented(at);
at += STEP;
}
assert_eq!(diagnostics.report().recent_intervals.unwrap().samples, 4);
}
}