// SPDX-License-Identifier: AGPL-3.0-only //! Measurement of the simulation loop's own timing. use std::time::{Duration, Instant}; /// Wall-clock cadence at which a measurement window closes and a report is produced. /// /// One second is short enough to surface a stall promptly and long enough that the report costs nothing next to the ticks it summarises. pub const REPORT_INTERVAL: Duration = Duration::from_secs(1); /// The summary produced when a measurement window closes. #[derive(Debug, Clone, Copy, PartialEq)] pub struct TickWindow { /// Ticks completed in the window, expressed per second. pub measured_tps: f32, /// Mean duration of a tick body in the window, in milliseconds. pub mean_tick_ms: f32, /// Longest tick body in the window, in milliseconds. pub max_tick_ms: f32, /// Share of the nominal tick period consumed by the mean tick body, in percent. pub tick_budget_percent: f32, } /// Accumulates tick timings and closes a measurement window on a fixed cadence. #[derive(Debug)] pub struct TickMeter { /// Nominal period one tick is budgeted, against which utilisation is computed. period: Duration, /// Instant the current window opened; the window closes once [`REPORT_INTERVAL`] has elapsed from here. window_start: Instant, /// Tick bodies recorded in the current window. ticks: u32, /// Summed duration of every tick body in the current window. total: Duration, /// Longest single tick body in the current window. max: Duration, } impl TickMeter { /// Opens the first measurement window at `now`, budgeting each tick `period`. #[must_use] pub fn new(now: Instant, period: Duration) -> Self { Self { period, window_start: now, ticks: 0, total: Duration::ZERO, max: Duration::ZERO, } } /// Records one completed tick body of duration `elapsed`. pub fn record(&mut self, elapsed: Duration) { self.ticks = self.ticks.saturating_add(1); self.total = self.total.saturating_add(elapsed); self.max = self.max.max(elapsed); } /// Closes the window and returns its summary once [`REPORT_INTERVAL`] has elapsed since it opened, otherwise returns [`None`]. /// /// On close the accumulators reset and a fresh window opens at `now`, so windows tile the timeline without gaps or overlap. pub fn take_window(&mut self, now: Instant) -> Option { let elapsed = now.saturating_duration_since(self.window_start); if elapsed < REPORT_INTERVAL { return None; } let window = summarise(self.ticks, self.total, self.max, elapsed, self.period); self.window_start = now; self.ticks = 0; self.total = Duration::ZERO; self.max = Duration::ZERO; Some(window) } } /// Derives a window summary from its raw accumulators. /// /// Split out from [`TickMeter::take_window`] so the arithmetic is exercisable without driving a clock. A window containing no ticks reports zeroes throughout rather than dividing by zero, which is the correct reading of "nothing completed". fn summarise( ticks: u32, total: Duration, max: Duration, elapsed: Duration, period: Duration, ) -> TickWindow { if ticks == 0 || elapsed.is_zero() { return TickWindow { measured_tps: 0.0, mean_tick_ms: 0.0, max_tick_ms: max.as_secs_f32() * 1000.0, tick_budget_percent: 0.0, }; } let mean = total.as_secs_f32() / f32::from(u16::try_from(ticks).unwrap_or(u16::MAX)); let period_secs = period.as_secs_f32(); TickWindow { measured_tps: f32::from(u16::try_from(ticks).unwrap_or(u16::MAX)) / elapsed.as_secs_f32(), mean_tick_ms: mean * 1000.0, max_tick_ms: max.as_secs_f32() * 1000.0, // A zero period would mean no budget exists to consume, so utilisation is undefined and reported as zero. tick_budget_percent: if period_secs > 0.0 { mean / period_secs * 100.0 } else { 0.0 }, } } #[cfg(test)] #[path = "tests/tick_stats.rs"] mod tests;