use std::collections::VecDeque;
use std::time::Duration;
use serde::Serialize;
pub const WINDOW: usize = 1024;
#[derive(Debug, Default)]
struct Series {
recent: VecDeque<Duration>,
}
impl Series {
fn push(&mut self, d: Duration) {
if self.recent.len() >= WINDOW {
self.recent.pop_front();
}
self.recent.push_back(d);
}
fn summary(&self) -> PhaseSummary {
let mut v: Vec<Duration> = self.recent.iter().copied().collect();
v.sort_unstable();
let pick = |q: f64| -> f64 {
if v.is_empty() {
return 0.0;
}
let i = ((v.len() as f64 - 1.0) * q).round() as usize;
v.get(i.min(v.len() - 1)).copied().map_or(0.0, ms)
};
PhaseSummary { window: v.len(), p50_ms: pick(0.5), p95_ms: pick(0.95), max_ms: v.last().copied().map_or(0.0, ms) }
}
}
#[derive(Debug, Default)]
pub struct FrameStats {
ui: Series,
pass: Series,
tessellate: Series,
frame_cpu: Series,
frames: u64,
first_frame: Option<Duration>,
launch: LaunchTimes,
max_ever: Duration,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
pub struct LaunchTimes {
pub to_window_ms: Option<f64>,
pub to_first_frame_ms: Option<f64>,
pub launch_to_present_ms: Option<f64>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct PhaseSummary {
pub window: usize,
pub p50_ms: f64,
pub p95_ms: f64,
pub max_ms: f64,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct StatsSummary {
pub frames: u64,
pub window: usize,
pub p50_ms: f64,
pub p95_ms: f64,
pub max_ms: f64,
pub max_ever_ms: f64,
pub first_frame_ms: Option<f64>,
pub launch: LaunchTimes,
pub pass: PhaseSummary,
pub tessellate: PhaseSummary,
pub frame_cpu: PhaseSummary,
}
fn ms(d: Duration) -> f64 {
d.as_secs_f64() * 1000.0
}
impl FrameStats {
pub fn record(&mut self, d: Duration) {
self.ui.push(d);
self.frames = self.frames.saturating_add(1);
self.max_ever = self.max_ever.max(d);
}
pub fn record_pass(&mut self, d: Duration) {
self.pass.push(d);
}
pub fn record_tessellate(&mut self, d: Duration) {
self.tessellate.push(d);
}
pub fn record_frame_cpu(&mut self, d: Duration) {
self.frame_cpu.push(d);
}
pub fn set_first_frame(&mut self, d: Duration) {
self.first_frame.get_or_insert(d);
}
pub(crate) fn set_to_window(&mut self, d: Duration) {
self.launch.to_window_ms.get_or_insert(ms(d));
}
pub(crate) fn set_to_first_frame(&mut self, d: Duration) {
self.launch.to_first_frame_ms.get_or_insert(ms(d));
}
pub(crate) fn set_presented(&mut self, d: Duration) {
self.launch.launch_to_present_ms.get_or_insert(ms(d));
}
pub fn launch(&self) -> LaunchTimes {
self.launch
}
pub fn frames(&self) -> u64 {
self.frames
}
pub fn reset_window(&mut self) {
self.ui.recent.clear();
self.pass.recent.clear();
self.tessellate.recent.clear();
self.frame_cpu.recent.clear();
}
pub fn summary(&self) -> StatsSummary {
let ui = self.ui.summary();
StatsSummary {
frames: self.frames,
window: ui.window,
p50_ms: ui.p50_ms,
p95_ms: ui.p95_ms,
max_ms: ui.max_ms,
max_ever_ms: ms(self.max_ever),
first_frame_ms: self.first_frame.map(ms),
launch: self.launch,
pass: self.pass.summary(),
tessellate: self.tessellate.summary(),
frame_cpu: self.frame_cpu.summary(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
pub struct ProcessStats {
pub footprint_bytes: Option<u64>,
pub peak_bytes: Option<u64>,
pub cpu_s: Option<f64>,
pub threads: Option<u64>,
}
#[derive(Debug, Default)]
pub struct ProcessProbe {
peak: Option<u64>,
last_tick: Option<std::time::Instant>,
}
impl ProcessProbe {
pub fn tick(&mut self) {
let Some(now) = crate::session::now() else { return };
if self.last_tick.is_some_and(|t| now.duration_since(t) < Duration::from_secs(1)) {
return;
}
self.last_tick = Some(now);
self.footprint();
}
fn footprint(&mut self) -> Option<u64> {
let m = ashlar_native::memory::process_memory()?;
let seen = m.peak.map_or(m.footprint, |p| p.max(m.footprint));
self.peak = Some(self.peak.map_or(seen, |p| p.max(seen)));
Some(m.footprint)
}
pub fn read(&mut self) -> ProcessStats {
let footprint_bytes = self.footprint();
ProcessStats { footprint_bytes, peak_bytes: self.peak, cpu_s: process_cpu_s(), threads: threads() }
}
}
fn process_cpu_s() -> Option<f64> {
#[cfg(not(target_arch = "wasm32"))]
{
cpu_time::ProcessTime::try_now().ok().map(|t| t.as_duration().as_secs_f64())
}
#[cfg(target_arch = "wasm32")]
{
None
}
}
fn threads() -> Option<u64> {
ashlar_native::memory::thread_count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_process_is_read_and_its_peak_kept() {
let mut p = ProcessProbe::default();
p.tick();
let s = p.read();
if cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) {
let f = s.footprint_bytes.unwrap();
assert!(f > 0 && s.peak_bytes.unwrap() >= f, "{s:?}");
assert!(s.cpu_s.unwrap() > 0.0, "{s:?}");
}
if cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) {
assert!(s.threads.unwrap() >= 1, "{s:?}");
}
let v = serde_json::to_value(s).unwrap();
for k in ["footprint_bytes", "peak_bytes", "cpu_s", "threads"] {
assert!(v.get(k).is_some(), "{k} in {v}");
}
}
#[test]
fn app_stats_reports_the_process() {
let mut d = crate::Driver::new(crate::App::<i64>::new("statstest", "0.0.0").runtime());
d.frame();
let v = d.call("app.stats", serde_json::json!({})).unwrap();
let p = &v["process"];
for k in ["footprint_bytes", "peak_bytes", "cpu_s", "threads"] {
assert!(p.get(k).is_some(), "{k} in {p}");
}
if cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) {
assert!(p["footprint_bytes"].as_u64().is_some_and(|f| f > 0), "{p}");
assert!(p["peak_bytes"].as_u64() >= p["footprint_bytes"].as_u64(), "{p}");
assert!(p["threads"].as_u64().is_some_and(|t| t >= 1), "{p}");
}
}
#[test]
fn percentiles() {
let mut s = FrameStats::default();
for i in 1..=100u64 {
s.record(Duration::from_millis(i));
}
let sum = s.summary();
assert_eq!(sum.frames, 100);
assert!((sum.p50_ms - 50.0).abs() <= 1.0);
assert!((sum.p95_ms - 95.0).abs() <= 1.0);
assert!((sum.max_ms - 100.0).abs() < 1e-9);
assert_eq!(FrameStats::default().summary().p95_ms, 0.0);
}
#[test]
fn phases_are_kept_apart() {
let mut s = FrameStats::default();
s.record(Duration::from_millis(1));
s.record_pass(Duration::from_millis(2));
s.record_tessellate(Duration::from_millis(3));
s.record_frame_cpu(Duration::from_millis(5));
let sum = s.summary();
assert!((sum.p95_ms - 1.0).abs() < 1e-9);
assert!((sum.pass.p95_ms - 2.0).abs() < 1e-9);
assert!((sum.tessellate.max_ms - 3.0).abs() < 1e-9);
assert!((sum.frame_cpu.p50_ms - 5.0).abs() < 1e-9);
assert_eq!((sum.window, sum.pass.window, sum.tessellate.window, sum.frame_cpu.window), (1, 1, 1, 1));
s.reset_window();
let sum = s.summary();
assert_eq!((sum.window, sum.pass.window, sum.tessellate.window, sum.frame_cpu.window), (0, 0, 0, 0));
assert_eq!(sum.frames, 1);
}
#[test]
fn the_window_is_bounded() {
let mut s = FrameStats::default();
for _ in 0..(WINDOW * 3) {
s.record(Duration::from_micros(10));
}
assert_eq!(s.summary().window, WINDOW);
assert_eq!(s.frames() as usize, WINDOW * 3);
}
}