Skip to main content

ixa/
execution_stats.rs

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