use core::fmt;
use core::marker::PhantomData;
use crate::phase::{PhaseData, PhaseGuard};
use crate::stats::FrameStats;
use crate::time::{DefaultTime, TimeSource};
pub const MAX_PHASES: usize = 8;
pub struct FrameTimer<const N: usize = 64, T = DefaultTime> {
samples: [u64; N],
head: usize,
count: usize,
frame_start_us: u64,
started: bool,
phases: [PhaseData<N>; MAX_PHASES],
phase_len: usize,
_time: PhantomData<T>,
}
impl<const N: usize, T> FrameTimer<N, T> {
#[must_use]
pub const fn new() -> Self {
let empty = PhaseData::empty();
Self {
samples: [0_u64; N],
head: 0,
count: 0,
frame_start_us: 0,
started: false,
phases: [empty; MAX_PHASES],
phase_len: 0,
_time: PhantomData,
}
}
pub fn reset(&mut self) {
self.samples = [0_u64; N];
self.head = 0;
self.count = 0;
self.frame_start_us = 0;
self.started = false;
for phase in &mut self.phases[..self.phase_len] {
phase.samples = [0_u64; N];
phase.head = 0;
phase.count = 0;
}
}
}
impl<const N: usize, T> Default for FrameTimer<N, T> {
fn default() -> Self {
Self::new()
}
}
impl<const N: usize, T: TimeSource> FrameTimer<N, T> {
pub fn begin_frame(&mut self) {
let now = T::now_us();
if self.started {
let elapsed = now.saturating_sub(self.frame_start_us);
self.samples[self.head] = elapsed;
self.head = (self.head + 1) % N;
if self.count < N {
self.count += 1;
}
}
self.frame_start_us = now;
self.started = true;
}
#[must_use = "dropping PhaseGuard immediately records a near-zero duration; \
keep it alive for the duration of the phase"]
pub fn phase(&mut self, name: &'static str) -> PhaseGuard<'_, N, T> {
PhaseGuard::new(self, name)
}
}
impl<const N: usize, T> FrameTimer<N, T> {
#[must_use]
pub const fn count(&self) -> usize {
self.count
}
#[must_use]
pub fn stats(&self) -> Option<FrameStats> {
FrameStats::compute(&self.samples, self.head, self.count)
}
#[must_use]
pub fn phase_stats(&self, name: &'static str) -> Option<FrameStats> {
self.phases[..self.phase_len]
.iter()
.find(|p| p.name == name)
.and_then(|p| FrameStats::compute(&p.samples, p.head, p.count))
}
#[must_use]
pub fn samples_us(&self) -> impl DoubleEndedIterator<Item = u64> + '_ {
let start = if self.count < N { 0 } else { self.head };
let count = self.count;
(0..count).map(move |i| self.samples[(start + i) % N])
}
pub fn phase_names(&self) -> impl Iterator<Item = &'static str> + '_ {
self.phases[..self.phase_len].iter().map(|p| p.name)
}
#[must_use]
pub fn to_csv(&self) -> String {
let parts: Vec<String> = self.samples_us().map(|us| us.to_string()).collect();
parts.join(",")
}
#[must_use]
pub fn is_over_budget(&self, budget_ms: f64) -> bool {
self.samples_us().next_back().is_some_and(|us| {
#[allow(clippy::cast_precision_loss)]
let ms = us as f64 / 1_000.0;
ms > budget_ms
})
}
pub(crate) fn record_phase(&mut self, name: &'static str, elapsed_us: u64) {
for phase in &mut self.phases[..self.phase_len] {
if phase.name == name {
phase.samples[phase.head] = elapsed_us;
phase.head = (phase.head + 1) % N;
if phase.count < N {
phase.count += 1;
}
return;
}
}
if self.phase_len < MAX_PHASES {
let slot = &mut self.phases[self.phase_len];
slot.name = name;
slot.samples[0] = elapsed_us;
slot.head = 1 % N;
slot.count = 1;
self.phase_len += 1;
}
}
}
impl<const N: usize, T> Clone for FrameTimer<N, T> {
fn clone(&self) -> Self {
Self {
samples: self.samples,
head: self.head,
count: self.count,
frame_start_us: self.frame_start_us,
started: self.started,
phases: self.phases,
phase_len: self.phase_len,
_time: PhantomData,
}
}
}
impl<const N: usize, T> fmt::Debug for FrameTimer<N, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FrameTimer")
.field("sample_count", &self.count)
.field("phase_count", &self.phase_len)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::time::StdTime;
#[test]
fn new_has_no_samples() {
let timer: FrameTimer<64, StdTime> = FrameTimer::new();
assert_eq!(timer.count(), 0);
assert!(timer.stats().is_none());
}
#[test]
fn single_begin_frame_still_no_samples() {
let mut timer: FrameTimer<64, StdTime> = FrameTimer::new();
timer.begin_frame();
assert_eq!(timer.count(), 0);
}
#[test]
fn two_begin_frames_yields_one_sample() {
let mut timer: FrameTimer<64, StdTime> = FrameTimer::new();
timer.begin_frame();
timer.begin_frame();
assert_eq!(timer.count(), 1);
assert!(timer.stats().is_some());
}
#[test]
fn window_saturates_at_n() {
let mut timer: FrameTimer<4, StdTime> = FrameTimer::new();
for _ in 0..10 {
timer.begin_frame();
}
assert_eq!(timer.count(), 4);
}
#[test]
fn reset_clears_samples_but_keeps_phase_names() {
let mut timer: FrameTimer<8, StdTime> = FrameTimer::new();
timer.begin_frame();
{
let _p = timer.phase("render");
}
timer.begin_frame();
timer.reset();
assert_eq!(timer.count(), 0);
assert!(timer.stats().is_none());
assert!(timer.phase_stats("render").is_none());
assert_eq!(timer.phase_names().next(), Some("render"));
}
#[test]
fn to_csv_empty_when_no_samples() {
let timer: FrameTimer<64, StdTime> = FrameTimer::new();
assert_eq!(timer.to_csv(), "");
}
#[test]
fn is_over_budget_false_when_no_samples() {
let timer: FrameTimer<64, StdTime> = FrameTimer::new();
assert!(!timer.is_over_budget(16.0));
}
#[test]
fn phase_stats_none_for_unknown_phase() {
let timer: FrameTimer<64, StdTime> = FrameTimer::new();
assert!(timer.phase_stats("nonexistent").is_none());
}
#[test]
fn clone_is_independent() {
let mut timer: FrameTimer<8, StdTime> = FrameTimer::new();
timer.begin_frame();
timer.begin_frame();
let clone = timer.clone();
timer.reset();
assert_eq!(timer.count(), 0);
assert_eq!(clone.count(), 1);
}
#[test]
fn phase_guard_records_duration() {
let mut timer: FrameTimer<8, StdTime> = FrameTimer::new();
timer.begin_frame();
{
let _p = timer.phase("work");
}
timer.begin_frame();
assert!(timer.phase_stats("work").is_some());
}
#[test]
fn extra_phases_beyond_max_are_silently_ignored() {
let mut timer: FrameTimer<8, StdTime> = FrameTimer::new();
timer.begin_frame();
for i in 0..=MAX_PHASES {
let name = Box::leak(format!("phase_{i}").into_boxed_str());
drop(timer.phase(name));
}
assert_eq!(timer.phase_names().count(), MAX_PHASES);
}
}