ixa/
execution_stats.rs

1// Loss of precision is allowable in this module's use cases.
2#![allow(clippy::cast_precision_loss)]
3
4use std::time::Duration;
5#[cfg(not(target_arch = "wasm32"))]
6use std::time::Instant;
7
8use humantime::format_duration;
9use log::info;
10#[cfg(feature = "profiling")]
11use log::{debug, error};
12use serde_derive::Serialize;
13#[cfg(feature = "profiling")]
14use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
15#[cfg(target_arch = "wasm32")]
16use wasm_bindgen::prelude::*;
17
18#[cfg(target_arch = "wasm32")]
19#[wasm_bindgen]
20/// The `wasm` target does not support `std::time::Instant::now()`.
21/// Works in both Window and Web Worker contexts.
22pub fn get_high_res_time() -> f64 {
23    use web_sys::js_sys::Reflect;
24    let global = web_sys::js_sys::global();
25    let performance = Reflect::get(&global, &"performance".into())
26        .ok()
27        .and_then(|v: JsValue| v.dyn_into::<web_sys::Performance>().ok());
28    match performance {
29        Some(perf) => perf.now(),
30        None => 0.0,
31    }
32}
33
34/// A container struct for computed final statistics.
35#[derive(Serialize)]
36pub struct ExecutionStatistics {
37    pub max_memory_usage: u64,
38    pub max_plans_in_flight: u64,
39    pub max_plan_queue_memory_in_use: u64,
40    pub cpu_time: Duration,
41    pub wall_time: Duration,
42}
43
44#[cfg(feature = "profiling")]
45#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
46/// How frequently we update the max memory used value.
47const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
48
49#[cfg(feature = "profiling")]
50#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
51pub(crate) struct ExecutionProfilingCollector {
52    /// Simulation start time, used to compute elapsed wall time for the simulation execution
53    #[cfg(not(target_arch = "wasm32"))]
54    start_time: Instant,
55    #[cfg(target_arch = "wasm32")]
56    start_time: f64,
57    /// We keep track of the last time we refreshed so that client code doesn't have to and can
58    /// just call `ExecutionProfilingCollector::refresh` in its event loop.
59    #[cfg(not(target_arch = "wasm32"))]
60    last_refresh: Instant,
61    #[cfg(target_arch = "wasm32")]
62    last_refresh: f64,
63    /// The accumulated CPU time of the process in CPU-milliseconds at simulation start, used
64    /// to compute the CPU time of the simulation execution
65    start_cpu_time: u64,
66    /// The maximum amount of real memory used by the process as reported by
67    /// `sysinfo::System::process::memory()`. This value is polled during execution to capture the
68    /// max.
69    max_memory_usage: u64,
70    /// A `sysinfo::System` for polling memory use
71    system: System,
72    /// Current process, set to `None` on unsupported platforms, wasm32 in particular
73    process_id: Option<Pid>,
74}
75
76#[cfg(feature = "profiling")]
77impl ExecutionProfilingCollector {
78    pub fn new() -> ExecutionProfilingCollector {
79        let process_id = sysinfo::get_current_pid().ok();
80        #[cfg(target_arch = "wasm32")]
81        let now = get_high_res_time();
82        #[cfg(not(target_arch = "wasm32"))]
83        let now = Instant::now();
84
85        let mut new_stats = ExecutionProfilingCollector {
86            start_time: now,
87            last_refresh: now,
88            start_cpu_time: 0,
89            max_memory_usage: 0,
90            system: System::new(),
91            process_id,
92        };
93        // Only refreshable on supported platforms.
94        if let Some(process_id) = process_id {
95            debug!("Process ID: {}", process_id);
96            let process_refresh_kind = ProcessRefreshKind::nothing().with_cpu().with_memory();
97            new_stats.update_system_info(process_refresh_kind);
98
99            let process = new_stats.system.process(process_id).unwrap();
100
101            new_stats.max_memory_usage = process.memory();
102            new_stats.start_cpu_time = process.accumulated_cpu_time();
103        }
104
105        new_stats
106    }
107
108    /// If at least `REFRESH_INTERVAL` (1 second) has passed since the previous
109    /// refresh, memory usage is polled and updated. Call this method as frequently
110    /// as you like, as it takes care of limiting polling frequency itself.
111    #[inline]
112    pub fn refresh(&mut self) {
113        #[cfg(not(target_arch = "wasm32"))]
114        if self.last_refresh.elapsed() >= REFRESH_INTERVAL {
115            self.poll_memory();
116            self.last_refresh = Instant::now();
117        }
118    }
119
120    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
121    /// Updates maximum memory usage. This method should be called about once per second,
122    /// as it is a relatively expensive system call.
123    fn poll_memory(&mut self) {
124        if let Some(pid) = self.process_id {
125            // Only refreshes memory statistics
126            self.update_system_info(ProcessRefreshKind::nothing().with_memory());
127            let process = self.system.process(pid).unwrap();
128            self.max_memory_usage = self.max_memory_usage.max(process.memory());
129        }
130    }
131
132    /// Gives accumulated CPU time of the process in CPU-milliseconds since simulation start.
133    #[allow(unused)]
134    pub fn cpu_time(&mut self) -> u64 {
135        if let Some(process_id) = self.process_id {
136            // Only refresh cpu statistics
137            self.update_system_info(ProcessRefreshKind::nothing().with_cpu());
138
139            let process = self.system.process(process_id).unwrap();
140            process.accumulated_cpu_time() - self.start_cpu_time
141        } else {
142            0
143        }
144    }
145
146    /// Refreshes the internal `sysinfo::System` object for this process using the given
147    /// [`ProcessRefreshKind`](sysinfo::ProcessRefreshKind).
148    #[inline]
149    fn update_system_info(&mut self, process_refresh_kind: ProcessRefreshKind) {
150        if let Some(pid) = self.process_id {
151            if self.system.refresh_processes_specifics(
152                ProcessesToUpdate::Some(&[pid]),
153                true,
154                process_refresh_kind,
155            ) < 1
156            {
157                error!("could not refresh process statistics");
158            }
159        }
160    }
161
162    /// Computes the final summary statistics
163    pub fn compute_final_statistics(&mut self) -> ExecutionStatistics {
164        let mut cpu_time_millis = 0;
165
166        if let Some(pid) = self.process_id {
167            // Update both memory and cpu statistics
168            self.update_system_info(ProcessRefreshKind::nothing().with_cpu().with_memory());
169            let process = self.system.process(pid).unwrap();
170
171            self.max_memory_usage = self.max_memory_usage.max(process.memory());
172            cpu_time_millis = process.accumulated_cpu_time() - self.start_cpu_time;
173        }
174
175        // Convert to `Duration`s in preparation for formatting
176        let cpu_time = Duration::from_millis(cpu_time_millis);
177        #[cfg(target_arch = "wasm32")]
178        let wall_time = get_high_res_time() - self.start_time;
179        #[cfg(not(target_arch = "wasm32"))]
180        let wall_time = self.start_time.elapsed();
181
182        #[cfg(target_arch = "wasm32")]
183        let wall_time = Duration::from_millis(wall_time as u64);
184
185        ExecutionStatistics {
186            max_memory_usage: self.max_memory_usage,
187            max_plans_in_flight: 0,
188            max_plan_queue_memory_in_use: 0,
189            cpu_time,
190            wall_time,
191        }
192    }
193}
194
195#[cfg(not(feature = "profiling"))]
196pub(crate) struct ExecutionProfilingCollector {
197    #[cfg(not(target_arch = "wasm32"))]
198    start_time: Instant,
199    #[cfg(target_arch = "wasm32")]
200    start_time: f64,
201}
202
203#[cfg(not(feature = "profiling"))]
204impl ExecutionProfilingCollector {
205    pub fn new() -> ExecutionProfilingCollector {
206        #[cfg(target_arch = "wasm32")]
207        let now = get_high_res_time();
208        #[cfg(not(target_arch = "wasm32"))]
209        let now = Instant::now();
210
211        ExecutionProfilingCollector { start_time: now }
212    }
213
214    #[inline]
215    pub fn refresh(&mut self) {}
216
217    pub fn compute_final_statistics(&mut self) -> ExecutionStatistics {
218        #[cfg(target_arch = "wasm32")]
219        let wall_time = get_high_res_time() - self.start_time;
220        #[cfg(not(target_arch = "wasm32"))]
221        let wall_time = self.start_time.elapsed();
222
223        #[cfg(target_arch = "wasm32")]
224        let wall_time = Duration::from_millis(wall_time as u64);
225
226        ExecutionStatistics {
227            max_memory_usage: 0,
228            max_plans_in_flight: 0,
229            max_plan_queue_memory_in_use: 0,
230            cpu_time: Duration::ZERO,
231            wall_time,
232        }
233    }
234}
235
236/// Prints execution statistics to the console.
237///
238/// Use `ExecutionProfilingCollector::compute_final_statistics()` to construct [`ExecutionStatistics`].
239pub fn print_execution_statistics(summary: &ExecutionStatistics) {
240    println!("━━━━ Execution Summary ━━━━");
241    if summary.max_memory_usage == 0 {
242        println!("Memory and CPU statistics are not available on your platform.");
243    } else {
244        #[cfg(feature = "profiling")]
245        {
246            println!(
247                "{:<25}{}",
248                "Max memory usage:",
249                bytesize::ByteSize::b(summary.max_memory_usage)
250            );
251            println!(
252                "{:<25}{}",
253                "Max plans in flight:", summary.max_plans_in_flight
254            );
255            println!(
256                "{:<25}{}",
257                "Max plan queue memory:",
258                bytesize::ByteSize::b(summary.max_plan_queue_memory_in_use)
259            );
260            println!("{:<25}{}", "CPU time:", format_duration(summary.cpu_time));
261        }
262    }
263
264    println!("{:<25}{}", "Wall time:", format_duration(summary.wall_time));
265}
266
267/// Logs execution statistics with the logging system.
268///
269/// Use `ExecutionProfilingCollector::compute_final_statistics()` to construct [`ExecutionStatistics`].
270pub fn log_execution_statistics(stats: &ExecutionStatistics) {
271    info!("Execution complete.");
272    if stats.max_memory_usage == 0 {
273        info!("Memory and CPU statistics are not available on your platform.");
274    } else {
275        #[cfg(feature = "profiling")]
276        {
277            info!(
278                "Max memory usage: {}",
279                bytesize::ByteSize::b(stats.max_memory_usage)
280            );
281            info!("Max plans in flight: {}", stats.max_plans_in_flight);
282            info!(
283                "Max plan queue memory: {}",
284                bytesize::ByteSize::b(stats.max_plan_queue_memory_in_use)
285            );
286            info!("CPU time: {}", format_duration(stats.cpu_time));
287        }
288    }
289    info!("Wall time: {}", format_duration(stats.wall_time));
290}
291
292#[cfg(all(test, feature = "profiling"))]
293mod tests {
294    use std::thread;
295    use std::time::Duration;
296
297    use super::*;
298
299    #[test]
300    fn test_collector_initialization() {
301        let collector = ExecutionProfilingCollector::new();
302
303        // Ensure that initial max memory usage is non-zero
304        assert!(collector.max_memory_usage > 0);
305    }
306
307    #[test]
308    fn test_refresh_respects_interval() {
309        let mut collector = ExecutionProfilingCollector::new();
310        let before = collector.max_memory_usage;
311
312        // Call refresh immediately — it should not poll
313        collector.refresh();
314        let after = collector.max_memory_usage;
315        assert_eq!(before, after);
316
317        // Sleep enough time to trigger refresh
318        thread::sleep(Duration::from_secs(2));
319        collector.refresh();
320        // Now memory usage should be refreshed — allow it to stay same or increase
321        assert!(collector.max_memory_usage >= before);
322    }
323
324    #[test]
325    fn test_compute_final_statistics_structure() {
326        let mut collector = ExecutionProfilingCollector::new();
327
328        thread::sleep(Duration::from_millis(100));
329        let stats = collector.compute_final_statistics();
330
331        // Fields should be non-zero
332        assert!(stats.max_memory_usage > 0);
333        assert_eq!(stats.max_plans_in_flight, 0);
334        assert_eq!(stats.max_plan_queue_memory_in_use, 0);
335        assert!(stats.wall_time > Duration::ZERO);
336    }
337
338    #[test]
339    fn test_cpu_time_increases_over_time() {
340        let mut collector = ExecutionProfilingCollector::new();
341
342        // Burn ~30ms CPU time. Likely will be < 30ms, as this thread will not have 100% of CPU
343        // during 30ms wall time.
344        let start = Instant::now();
345        while start.elapsed().as_millis() < 30u128 {
346            std::hint::black_box(0); // Prevent optimization
347        }
348
349        let cpu_time_1 = collector.cpu_time();
350
351        // Burn ~50ms CPU time
352        let start = Instant::now();
353        while start.elapsed().as_millis() < 50u128 {
354            std::hint::black_box(0); // Prevent optimization
355        }
356
357        let cpu_time_2 = collector.cpu_time();
358        assert!(cpu_time_2 > cpu_time_1);
359    }
360}