synvael/crates/server/src/tick_stats.rs

116 lines
4.1 KiB
Rust

// 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<TickWindow> {
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;