1use std::collections::VecDeque;
11use std::time::Duration;
12
13use serde::Serialize;
14
15pub const WINDOW: usize = 1024;
17
18#[derive(Debug, Default)]
20struct Series {
21 recent: VecDeque<Duration>,
22}
23
24impl Series {
25 fn push(&mut self, d: Duration) {
26 if self.recent.len() >= WINDOW {
27 self.recent.pop_front();
28 }
29 self.recent.push_back(d);
30 }
31
32 fn summary(&self) -> PhaseSummary {
33 let mut v: Vec<Duration> = self.recent.iter().copied().collect();
34 v.sort_unstable();
35 let pick = |q: f64| -> f64 {
36 if v.is_empty() {
37 return 0.0;
38 }
39 let i = ((v.len() as f64 - 1.0) * q).round() as usize;
40 v.get(i.min(v.len() - 1)).copied().map_or(0.0, ms)
41 };
42 PhaseSummary { window: v.len(), p50_ms: pick(0.5), p95_ms: pick(0.95), max_ms: v.last().copied().map_or(0.0, ms) }
43 }
44}
45
46#[derive(Debug, Default)]
48pub struct FrameStats {
49 ui: Series,
50 pass: Series,
51 tessellate: Series,
52 frame_cpu: Series,
53 frames: u64,
54 first_frame: Option<Duration>,
55 launch: LaunchTimes,
56 max_ever: Duration,
57}
58
59#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
62pub struct LaunchTimes {
63 pub to_window_ms: Option<f64>,
65 pub to_first_frame_ms: Option<f64>,
67 pub launch_to_present_ms: Option<f64>,
69}
70
71#[derive(Clone, Debug, Default, PartialEq, Serialize)]
73pub struct PhaseSummary {
74 pub window: usize,
76 pub p50_ms: f64,
77 pub p95_ms: f64,
78 pub max_ms: f64,
79}
80
81#[derive(Clone, Debug, Default, PartialEq, Serialize)]
83pub struct StatsSummary {
84 pub frames: u64,
86 pub window: usize,
88 pub p50_ms: f64,
90 pub p95_ms: f64,
91 pub max_ms: f64,
92 pub max_ever_ms: f64,
94 pub first_frame_ms: Option<f64>,
96 pub launch: LaunchTimes,
98 pub pass: PhaseSummary,
100 pub tessellate: PhaseSummary,
102 pub frame_cpu: PhaseSummary,
105}
106
107fn ms(d: Duration) -> f64 {
108 d.as_secs_f64() * 1000.0
109}
110
111impl FrameStats {
112 pub fn record(&mut self, d: Duration) {
114 self.ui.push(d);
115 self.frames = self.frames.saturating_add(1);
116 self.max_ever = self.max_ever.max(d);
117 }
118
119 pub fn record_pass(&mut self, d: Duration) {
121 self.pass.push(d);
122 }
123
124 pub fn record_tessellate(&mut self, d: Duration) {
126 self.tessellate.push(d);
127 }
128
129 pub fn record_frame_cpu(&mut self, d: Duration) {
131 self.frame_cpu.push(d);
132 }
133
134 pub fn set_first_frame(&mut self, d: Duration) {
135 self.first_frame.get_or_insert(d);
136 }
137
138 pub(crate) fn set_to_window(&mut self, d: Duration) {
139 self.launch.to_window_ms.get_or_insert(ms(d));
140 }
141
142 pub(crate) fn set_to_first_frame(&mut self, d: Duration) {
143 self.launch.to_first_frame_ms.get_or_insert(ms(d));
144 }
145
146 pub(crate) fn set_presented(&mut self, d: Duration) {
147 self.launch.launch_to_present_ms.get_or_insert(ms(d));
148 }
149
150 pub fn launch(&self) -> LaunchTimes {
152 self.launch
153 }
154
155 pub fn frames(&self) -> u64 {
156 self.frames
157 }
158
159 pub fn reset_window(&mut self) {
161 self.ui.recent.clear();
162 self.pass.recent.clear();
163 self.tessellate.recent.clear();
164 self.frame_cpu.recent.clear();
165 }
166
167 pub fn summary(&self) -> StatsSummary {
168 let ui = self.ui.summary();
169 StatsSummary {
170 frames: self.frames,
171 window: ui.window,
172 p50_ms: ui.p50_ms,
173 p95_ms: ui.p95_ms,
174 max_ms: ui.max_ms,
175 max_ever_ms: ms(self.max_ever),
176 first_frame_ms: self.first_frame.map(ms),
177 launch: self.launch,
178 pass: self.pass.summary(),
179 tessellate: self.tessellate.summary(),
180 frame_cpu: self.frame_cpu.summary(),
181 }
182 }
183}
184
185#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
188pub struct ProcessStats {
189 pub footprint_bytes: Option<u64>,
192 pub peak_bytes: Option<u64>,
196 pub cpu_s: Option<f64>,
198 pub threads: Option<u64>,
200}
201
202#[derive(Debug, Default)]
204pub struct ProcessProbe {
205 peak: Option<u64>,
206 last_tick: Option<std::time::Instant>,
207}
208
209impl ProcessProbe {
210 pub fn tick(&mut self) {
212 let Some(now) = crate::session::now() else { return };
213 if self.last_tick.is_some_and(|t| now.duration_since(t) < Duration::from_secs(1)) {
214 return;
215 }
216 self.last_tick = Some(now);
217 self.footprint();
218 }
219
220 fn footprint(&mut self) -> Option<u64> {
221 let m = ashlar_native::memory::process_memory()?;
222 let seen = m.peak.map_or(m.footprint, |p| p.max(m.footprint));
223 self.peak = Some(self.peak.map_or(seen, |p| p.max(seen)));
224 Some(m.footprint)
225 }
226
227 pub fn read(&mut self) -> ProcessStats {
229 let footprint_bytes = self.footprint();
230 ProcessStats { footprint_bytes, peak_bytes: self.peak, cpu_s: process_cpu_s(), threads: threads() }
231 }
232}
233
234fn process_cpu_s() -> Option<f64> {
235 #[cfg(not(target_arch = "wasm32"))]
236 {
237 cpu_time::ProcessTime::try_now().ok().map(|t| t.as_duration().as_secs_f64())
238 }
239 #[cfg(target_arch = "wasm32")]
240 {
241 None
242 }
243}
244
245fn threads() -> Option<u64> {
246 ashlar_native::memory::thread_count()
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn the_process_is_read_and_its_peak_kept() {
255 let mut p = ProcessProbe::default();
256 p.tick();
257 let s = p.read();
258 if cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) {
259 let f = s.footprint_bytes.unwrap();
260 assert!(f > 0 && s.peak_bytes.unwrap() >= f, "{s:?}");
261 assert!(s.cpu_s.unwrap() > 0.0, "{s:?}");
262 }
263 if cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) {
264 assert!(s.threads.unwrap() >= 1, "{s:?}");
265 }
266 let v = serde_json::to_value(s).unwrap();
267 for k in ["footprint_bytes", "peak_bytes", "cpu_s", "threads"] {
268 assert!(v.get(k).is_some(), "{k} in {v}");
269 }
270 }
271
272 #[test]
273 fn app_stats_reports_the_process() {
274 let mut d = crate::Driver::new(crate::App::<i64>::new("statstest", "0.0.0").runtime());
275 d.frame();
276 let v = d.call("app.stats", serde_json::json!({})).unwrap();
277 let p = &v["process"];
278 for k in ["footprint_bytes", "peak_bytes", "cpu_s", "threads"] {
279 assert!(p.get(k).is_some(), "{k} in {p}");
280 }
281 if cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) {
282 assert!(p["footprint_bytes"].as_u64().is_some_and(|f| f > 0), "{p}");
283 assert!(p["peak_bytes"].as_u64() >= p["footprint_bytes"].as_u64(), "{p}");
284 assert!(p["threads"].as_u64().is_some_and(|t| t >= 1), "{p}");
285 }
286 }
287
288 #[test]
289 fn percentiles() {
290 let mut s = FrameStats::default();
291 for i in 1..=100u64 {
292 s.record(Duration::from_millis(i));
293 }
294 let sum = s.summary();
295 assert_eq!(sum.frames, 100);
296 assert!((sum.p50_ms - 50.0).abs() <= 1.0);
297 assert!((sum.p95_ms - 95.0).abs() <= 1.0);
298 assert!((sum.max_ms - 100.0).abs() < 1e-9);
299 assert_eq!(FrameStats::default().summary().p95_ms, 0.0);
300 }
301
302 #[test]
303 fn phases_are_kept_apart() {
304 let mut s = FrameStats::default();
305 s.record(Duration::from_millis(1));
306 s.record_pass(Duration::from_millis(2));
307 s.record_tessellate(Duration::from_millis(3));
308 s.record_frame_cpu(Duration::from_millis(5));
309 let sum = s.summary();
310 assert!((sum.p95_ms - 1.0).abs() < 1e-9);
311 assert!((sum.pass.p95_ms - 2.0).abs() < 1e-9);
312 assert!((sum.tessellate.max_ms - 3.0).abs() < 1e-9);
313 assert!((sum.frame_cpu.p50_ms - 5.0).abs() < 1e-9);
314 assert_eq!((sum.window, sum.pass.window, sum.tessellate.window, sum.frame_cpu.window), (1, 1, 1, 1));
315 s.reset_window();
316 let sum = s.summary();
317 assert_eq!((sum.window, sum.pass.window, sum.tessellate.window, sum.frame_cpu.window), (0, 0, 0, 0));
318 assert_eq!(sum.frames, 1);
319 }
320
321 #[test]
322 fn the_window_is_bounded() {
323 let mut s = FrameStats::default();
324 for _ in 0..(WINDOW * 3) {
325 s.record(Duration::from_micros(10));
326 }
327 assert_eq!(s.summary().window, WINDOW);
328 assert_eq!(s.frames() as usize, WINDOW * 3);
329 }
330}