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