Skip to main content

ixa/
execution_stats.rs

1use std::time::Duration;
2
3use humantime::format_duration;
4use log::info;
5#[cfg(feature = "profiling")]
6use log::{debug, error};
7use serde_derive::Serialize;
8#[cfg(feature = "profiling")]
9use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
10#[cfg(target_arch = "wasm32")]
11use wasm_bindgen::{JsCast, JsValue};
12
13#[cfg(feature = "profiling")]
14use crate::profiling::QueryProfiler;
15
16/// A monotonic timestamp used to measure profiling intervals.
17///
18/// Only `now()` and `elapsed()` are part of the portable API shared by native
19/// and WASM targets.
20#[cfg(not(target_arch = "wasm32"))]
21pub type ProfilingInstant = std::time::Instant;
22
23/// A monotonic timestamp used to measure profiling intervals.
24///
25/// Only `now()` and `elapsed()` are part of the portable API shared by native
26/// and WASM targets.
27#[cfg(target_arch = "wasm32")]
28#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
29pub struct ProfilingInstant(f64);
30
31#[cfg(target_arch = "wasm32")]
32impl ProfilingInstant {
33    #[must_use]
34    pub fn now() -> Self {
35        use web_sys::js_sys::Reflect;
36
37        let global = web_sys::js_sys::global();
38        let performance = Reflect::get(&global, &"performance".into())
39            .ok()
40            .and_then(|value: JsValue| value.dyn_into::<web_sys::Performance>().ok());
41
42        Self(performance.map_or(0.0, |performance| performance.now()))
43    }
44
45    #[must_use]
46    pub fn elapsed(&self) -> Duration {
47        let elapsed_milliseconds = Self::now().0 - self.0;
48        if !elapsed_milliseconds.is_finite() || elapsed_milliseconds <= 0.0 {
49            return Duration::ZERO;
50        }
51
52        Duration::from_secs_f64(elapsed_milliseconds / 1000.0)
53    }
54}
55
56/// A container struct for computed final statistics.
57#[derive(Serialize)]
58pub struct ExecutionStatistics {
59    pub max_memory_usage: u64,
60    pub max_plans_in_flight: u64,
61    pub max_plan_queue_memory_in_use: u64,
62    pub cpu_time: Duration,
63    pub wall_time: Duration,
64}
65
66#[cfg(feature = "profiling")]
67#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
68/// How frequently we update the max memory used value.
69const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
70
71#[cfg(feature = "profiling")]
72#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
73pub(crate) struct ExecutionProfilingCollector {
74    /// Aggregate profiling data for entity queries executed by this context.
75    pub(crate) query_profiler: QueryProfiler,
76    /// Simulation start time, used to compute elapsed wall time for the simulation execution
77    start_time: ProfilingInstant,
78    /// We keep track of the last time we refreshed so that client code doesn't have to and can
79    /// just call `ExecutionProfilingCollector::refresh` in its event loop.
80    last_refresh: ProfilingInstant,
81    /// The accumulated CPU time of the process in CPU-milliseconds at simulation start, used
82    /// to compute the CPU time of the simulation execution
83    start_cpu_time: u64,
84    /// The maximum amount of real memory used by the process as reported by
85    /// `sysinfo::System::process::memory()`. This value is polled during execution to capture the
86    /// max.
87    max_memory_usage: u64,
88    /// A `sysinfo::System` for polling memory use
89    system: System,
90    /// Current process, set to `None` on unsupported platforms, wasm32 in particular
91    process_id: Option<Pid>,
92}
93
94#[cfg(feature = "profiling")]
95impl ExecutionProfilingCollector {
96    #[must_use]
97    pub fn new() -> ExecutionProfilingCollector {
98        let process_id = sysinfo::get_current_pid().ok();
99        let now = ProfilingInstant::now();
100
101        let mut new_stats = ExecutionProfilingCollector {
102            query_profiler: QueryProfiler::default(),
103            start_time: now,
104            last_refresh: now,
105            start_cpu_time: 0,
106            max_memory_usage: 0,
107            system: System::new(),
108            process_id,
109        };
110        // Only refreshable on supported platforms.
111        if let Some(process_id) = process_id {
112            debug!("Process ID: {}", process_id);
113            let process_refresh_kind = ProcessRefreshKind::nothing().with_cpu().with_memory();
114            new_stats.update_system_info(process_refresh_kind);
115
116            let process = new_stats.system.process(process_id).unwrap();
117
118            new_stats.max_memory_usage = process.memory();
119            new_stats.start_cpu_time = process.accumulated_cpu_time();
120        }
121
122        new_stats
123    }
124
125    /// If at least `REFRESH_INTERVAL` (1 second) has passed since the previous
126    /// refresh, memory usage is polled and updated. Call this method as frequently
127    /// as you like, as it takes care of limiting polling frequency itself.
128    #[inline]
129    pub fn refresh(&mut self) {
130        #[cfg(not(target_arch = "wasm32"))]
131        if self.last_refresh.elapsed() >= REFRESH_INTERVAL {
132            self.poll_memory();
133            self.last_refresh = ProfilingInstant::now();
134        }
135    }
136
137    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
138    /// Updates maximum memory usage. This method should be called about once per second,
139    /// as it is a relatively expensive system call.
140    fn poll_memory(&mut self) {
141        if let Some(pid) = self.process_id {
142            // Only refreshes memory statistics
143            self.update_system_info(ProcessRefreshKind::nothing().with_memory());
144            let process = self.system.process(pid).unwrap();
145            self.max_memory_usage = self.max_memory_usage.max(process.memory());
146        }
147    }
148
149    /// Gives accumulated CPU time of the process in CPU-milliseconds since simulation start.
150    #[allow(unused)]
151    #[must_use]
152    pub fn cpu_time(&mut self) -> u64 {
153        if let Some(process_id) = self.process_id {
154            // Only refresh cpu statistics
155            self.update_system_info(ProcessRefreshKind::nothing().with_cpu());
156
157            let process = self.system.process(process_id).unwrap();
158            process.accumulated_cpu_time() - self.start_cpu_time
159        } else {
160            0
161        }
162    }
163
164    /// Refreshes the internal `sysinfo::System` object for this process using the given
165    /// [`ProcessRefreshKind`](sysinfo::ProcessRefreshKind).
166    #[inline]
167    fn update_system_info(&mut self, process_refresh_kind: ProcessRefreshKind) {
168        if let Some(pid) = self.process_id {
169            if self.system.refresh_processes_specifics(
170                ProcessesToUpdate::Some(&[pid]),
171                true,
172                process_refresh_kind,
173            ) < 1
174            {
175                error!("could not refresh process statistics");
176            }
177        }
178    }
179
180    /// Computes the final summary statistics
181    #[must_use]
182    pub fn compute_final_statistics(&mut self) -> ExecutionStatistics {
183        let mut cpu_time_millis = 0;
184
185        if let Some(pid) = self.process_id {
186            // Update both memory and cpu statistics
187            self.update_system_info(ProcessRefreshKind::nothing().with_cpu().with_memory());
188            let process = self.system.process(pid).unwrap();
189
190            self.max_memory_usage = self.max_memory_usage.max(process.memory());
191            cpu_time_millis = process.accumulated_cpu_time() - self.start_cpu_time;
192        }
193
194        // Convert to `Duration`s in preparation for formatting
195        let cpu_time = Duration::from_millis(cpu_time_millis);
196        let wall_time = self.start_time.elapsed();
197
198        ExecutionStatistics {
199            max_memory_usage: self.max_memory_usage,
200            max_plans_in_flight: 0,
201            max_plan_queue_memory_in_use: 0,
202            cpu_time,
203            wall_time,
204        }
205    }
206}
207
208#[cfg(not(feature = "profiling"))]
209pub(crate) struct ExecutionProfilingCollector {
210    start_time: ProfilingInstant,
211}
212
213#[cfg(not(feature = "profiling"))]
214impl ExecutionProfilingCollector {
215    #[must_use]
216    pub fn new() -> ExecutionProfilingCollector {
217        ExecutionProfilingCollector {
218            start_time: ProfilingInstant::now(),
219        }
220    }
221
222    #[inline]
223    pub fn refresh(&mut self) {}
224
225    #[must_use]
226    pub fn compute_final_statistics(&mut self) -> ExecutionStatistics {
227        let wall_time = self.start_time.elapsed();
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 = ProfilingInstant::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 = ProfilingInstant::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}