Skip to main content

rumdl_lib/
profiling.rs

1//!
2//! This module provides profiling utilities for measuring and reporting execution times in rumdl.
3
4use std::collections::HashMap;
5use std::sync::LazyLock;
6use std::sync::Mutex;
7use std::time::{Duration, Instant};
8
9// Global profiler state
10static PROFILER: LazyLock<Mutex<Profiler>> = LazyLock::new(|| Mutex::new(Profiler::new()));
11
12// Enable/disable profiling with a feature flag
13#[cfg(feature = "profiling")]
14pub(crate) const PROFILING_ENABLED: bool = true;
15
16#[cfg(not(feature = "profiling"))]
17pub(crate) const PROFILING_ENABLED: bool = false;
18
19/// A simple profiling utility to measure and report execution times
20pub struct Profiler {
21    measurements: HashMap<String, (Duration, usize)>,
22    active_timers: HashMap<String, Instant>,
23}
24
25impl Default for Profiler {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl Profiler {
32    /// Create a new profiler instance
33    pub fn new() -> Self {
34        Profiler {
35            measurements: HashMap::new(),
36            active_timers: HashMap::new(),
37        }
38    }
39
40    /// Start a timer for a specific section of code
41    pub fn start_timer(&mut self, section: &str) {
42        if PROFILING_ENABLED {
43            let section_name = section.to_string();
44            self.active_timers.insert(section_name, Instant::now());
45        }
46    }
47
48    /// Stop the timer for a section and record the elapsed time
49    pub fn stop_timer(&mut self, section: &str) {
50        if PROFILING_ENABLED {
51            let section_name = section.to_string();
52            if let Some(start_time) = self.active_timers.remove(&section_name) {
53                let elapsed = start_time.elapsed();
54                self.record_duration(&section_name, elapsed);
55            }
56        }
57    }
58
59    /// Record a completed measurement.
60    pub fn record_duration(&mut self, section: &str, duration: Duration) {
61        if PROFILING_ENABLED {
62            let entry = self
63                .measurements
64                .entry(section.to_string())
65                .or_insert((Duration::new(0, 0), 0));
66            entry.0 += duration;
67            entry.1 += 1;
68        }
69    }
70
71    /// Get a report of all measurements
72    pub fn get_report(&self) -> String {
73        if !PROFILING_ENABLED || self.measurements.is_empty() {
74            return "Profiling disabled or no measurements recorded.".to_string();
75        }
76
77        // Sort measurements by total time (descending)
78        let mut sorted_measurements: Vec<_> = self.measurements.iter().collect();
79        sorted_measurements.sort_by(|a, b| b.1.0.cmp(&a.1.0));
80
81        // Calculate total time across all sections
82        let total_time: Duration = sorted_measurements.iter().map(|(_, (duration, _))| duration).sum();
83
84        // Generate the report
85        let mut report = String::new();
86        report.push_str("=== Profiling Report ===\n");
87        report.push_str(&format!(
88            "Total measured time: {:.6} seconds (parallel sections may overlap)\n\n",
89            total_time.as_secs_f64()
90        ));
91        report.push_str(
92            "Section                                  | Total Time (s) | Calls | Avg Time (ms) | % of Total\n",
93        );
94        report.push_str(
95            "------------------------------------------|----------------|-------|---------------|----------\n",
96        );
97
98        for (section, (duration, calls)) in sorted_measurements {
99            let total_seconds = duration.as_secs_f64();
100            let avg_ms = (duration.as_nanos() as f64) / (calls * 1_000_000) as f64;
101            let percentage = (total_seconds / total_time.as_secs_f64()) * 100.0;
102
103            report.push_str(&format!(
104                "{section:<42} | {total_seconds:<14.6} | {calls:<5} | {avg_ms:<13.3} | {percentage:<8.2}%\n"
105            ));
106        }
107
108        report
109    }
110
111    /// Reset all measurements
112    pub fn reset(&mut self) {
113        self.measurements.clear();
114        self.active_timers.clear();
115    }
116}
117
118/// Start a timer for a section
119///
120/// If the mutex is poisoned, this is a no-op. Profiling failures should not crash the application.
121pub fn start_timer(section: &str) {
122    if PROFILING_ENABLED && let Ok(mut profiler) = PROFILER.lock() {
123        profiler.start_timer(section);
124    }
125}
126
127/// Stop a timer for a section
128///
129/// If the mutex is poisoned, this is a no-op. Profiling failures should not crash the application.
130pub fn stop_timer(section: &str) {
131    if PROFILING_ENABLED && let Ok(mut profiler) = PROFILER.lock() {
132        profiler.stop_timer(section);
133    }
134}
135
136/// Record a completed duration for a section.
137///
138/// If the mutex is poisoned, this is a no-op. Profiling failures should not crash the application.
139pub fn record_duration(section: &str, duration: Duration) {
140    if PROFILING_ENABLED && let Ok(mut profiler) = PROFILER.lock() {
141        profiler.record_duration(section, duration);
142    }
143}
144
145/// Get a report of all measurements
146///
147/// If the mutex is poisoned, returns a message indicating the error rather than panicking.
148pub fn get_report() -> String {
149    if PROFILING_ENABLED {
150        match PROFILER.lock() {
151            Ok(profiler) => profiler.get_report(),
152            Err(_) => "Profiling report unavailable (mutex poisoned).".to_string(),
153        }
154    } else {
155        "Profiling is disabled.".to_string()
156    }
157}
158
159/// Reset all measurements
160///
161/// If the mutex is poisoned, this is a no-op. Profiling failures should not crash the application.
162pub fn reset() {
163    if PROFILING_ENABLED && let Ok(mut profiler) = PROFILER.lock() {
164        profiler.reset();
165    }
166}
167
168/// A utility struct to time a section of code using RAII
169pub struct ScopedTimer {
170    section: String,
171    enabled: bool,
172    start: Option<Instant>,
173}
174
175impl ScopedTimer {
176    /// Create a new scoped timer
177    pub fn new(section: &str) -> Self {
178        let enabled = PROFILING_ENABLED;
179        ScopedTimer {
180            section: section.to_string(),
181            enabled,
182            start: enabled.then(Instant::now),
183        }
184    }
185}
186
187impl Drop for ScopedTimer {
188    fn drop(&mut self) {
189        if self.enabled
190            && let Some(start) = self.start.take()
191        {
192            record_duration(&self.section, start.elapsed());
193        }
194    }
195}
196
197/// Convenience macro to time a block of code.
198///
199/// When the `profiling` feature is disabled this expands to just the block,
200/// avoiding the `String` allocation that `ScopedTimer::new` would otherwise
201/// perform on every call. Hot-path call sites pay nothing in the default build.
202#[cfg(feature = "profiling")]
203#[macro_export]
204macro_rules! time_section {
205    ($section:expr, $block:block) => {{
206        let _timer = $crate::profiling::ScopedTimer::new($section);
207        $block
208    }};
209}
210
211#[cfg(not(feature = "profiling"))]
212#[macro_export]
213macro_rules! time_section {
214    ($section:expr, $block:block) => {{ $block }};
215}
216
217/// Convenience macro to time a function call.
218///
219/// See `time_section!` for the rationale behind the feature gate.
220#[cfg(feature = "profiling")]
221#[macro_export]
222macro_rules! time_function {
223    ($section:expr, $func:expr) => {{
224        let _timer = $crate::profiling::ScopedTimer::new($section);
225        $func
226    }};
227}
228
229#[cfg(not(feature = "profiling"))]
230#[macro_export]
231macro_rules! time_function {
232    ($section:expr, $func:expr) => {{ $func }};
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use std::thread;
239    use std::time::Duration;
240
241    #[test]
242    fn test_profiler_new() {
243        let profiler = Profiler::new();
244        assert!(profiler.measurements.is_empty());
245        assert!(profiler.active_timers.is_empty());
246    }
247
248    #[test]
249    fn test_profiler_default() {
250        let profiler = Profiler::default();
251        assert!(profiler.measurements.is_empty());
252        assert!(profiler.active_timers.is_empty());
253    }
254
255    #[test]
256    fn test_profiler_start_stop_timer() {
257        let mut profiler = Profiler::new();
258
259        // Force profiling to be enabled for this test
260        if PROFILING_ENABLED {
261            profiler.start_timer("test_section");
262            thread::sleep(Duration::from_millis(10));
263            profiler.stop_timer("test_section");
264
265            assert!(profiler.measurements.contains_key("test_section"));
266            let (duration, count) = profiler.measurements.get("test_section").unwrap();
267            assert!(*count == 1);
268            assert!(duration.as_millis() >= 10);
269        }
270    }
271
272    #[test]
273    fn test_profiler_multiple_measurements() {
274        let mut profiler = Profiler::new();
275
276        if PROFILING_ENABLED {
277            // Multiple measurements of same section
278            for _ in 0..3 {
279                profiler.start_timer("test_section");
280                thread::sleep(Duration::from_millis(5));
281                profiler.stop_timer("test_section");
282            }
283
284            assert!(profiler.measurements.contains_key("test_section"));
285            let (duration, count) = profiler.measurements.get("test_section").unwrap();
286            assert_eq!(*count, 3);
287            assert!(duration.as_millis() >= 15);
288        }
289    }
290
291    #[test]
292    fn test_profiler_get_report() {
293        let mut profiler = Profiler::new();
294
295        if PROFILING_ENABLED {
296            profiler.start_timer("section1");
297            thread::sleep(Duration::from_millis(20));
298            profiler.stop_timer("section1");
299
300            profiler.start_timer("section2");
301            thread::sleep(Duration::from_millis(10));
302            profiler.stop_timer("section2");
303
304            let report = profiler.get_report();
305            assert!(report.contains("Profiling Report"));
306            assert!(report.contains("section1"));
307            assert!(report.contains("section2"));
308            assert!(report.contains("Total measured time"));
309        } else {
310            let report = profiler.get_report();
311            assert_eq!(report, "Profiling disabled or no measurements recorded.");
312        }
313    }
314
315    #[test]
316    fn test_profiler_reset() {
317        let mut profiler = Profiler::new();
318
319        if PROFILING_ENABLED {
320            profiler.start_timer("test_section");
321            profiler.stop_timer("test_section");
322
323            assert!(!profiler.measurements.is_empty());
324
325            profiler.reset();
326            assert!(profiler.measurements.is_empty());
327            assert!(profiler.active_timers.is_empty());
328        }
329    }
330
331    #[test]
332    fn test_profiler_stop_without_start() {
333        let mut profiler = Profiler::new();
334
335        // Should not panic
336        profiler.stop_timer("nonexistent_section");
337        assert!(profiler.measurements.is_empty());
338    }
339
340    #[test]
341    #[serial_test::serial]
342    fn test_global_start_stop_timer() {
343        if PROFILING_ENABLED {
344            reset(); // Clear any previous measurements
345
346            start_timer("global_test");
347            thread::sleep(Duration::from_millis(10));
348            stop_timer("global_test");
349
350            let report = get_report();
351            assert!(report.contains("global_test"));
352        }
353    }
354
355    #[test]
356    fn test_global_get_report() {
357        let report = get_report();
358        if PROFILING_ENABLED {
359            assert!(report.contains("Profiling Report") || report.contains("no measurements"));
360        } else {
361            assert_eq!(report, "Profiling is disabled.");
362        }
363    }
364
365    #[test]
366    #[serial_test::serial]
367    fn test_global_reset() {
368        if PROFILING_ENABLED {
369            start_timer("test_reset");
370            stop_timer("test_reset");
371
372            reset();
373            let report = get_report();
374            assert!(!report.contains("test_reset"));
375        }
376    }
377
378    #[test]
379    #[serial_test::serial]
380    fn test_scoped_timer() {
381        if PROFILING_ENABLED {
382            reset();
383
384            {
385                let _timer = ScopedTimer::new("scoped_test");
386                thread::sleep(Duration::from_millis(10));
387            } // Timer should stop here
388
389            let report = get_report();
390            assert!(report.contains("scoped_test"));
391        }
392    }
393
394    #[test]
395    fn test_scoped_timer_drop() {
396        let timer = ScopedTimer::new("drop_test");
397        assert_eq!(timer.section, "drop_test");
398        assert_eq!(timer.enabled, PROFILING_ENABLED);
399        // Timer will be dropped and stop_timer called
400    }
401
402    #[test]
403    fn test_empty_report() {
404        let profiler = Profiler::new();
405        let report = profiler.get_report();
406
407        if PROFILING_ENABLED {
408            assert_eq!(report, "Profiling disabled or no measurements recorded.");
409        }
410    }
411
412    #[test]
413    fn test_report_formatting() {
414        let mut profiler = Profiler::new();
415
416        if PROFILING_ENABLED {
417            // Create predictable measurements
418            profiler
419                .measurements
420                .insert("test1".to_string(), (Duration::from_secs(1), 10));
421            profiler
422                .measurements
423                .insert("test2".to_string(), (Duration::from_millis(500), 5));
424
425            let report = profiler.get_report();
426
427            // Check report structure
428            assert!(report.contains("Section"));
429            assert!(report.contains("Total Time (s)"));
430            assert!(report.contains("Calls"));
431            assert!(report.contains("Avg Time (ms)"));
432            assert!(report.contains("% of Total"));
433
434            // Check that test1 appears before test2 (sorted by duration)
435            let test1_pos = report.find("test1").unwrap();
436            let test2_pos = report.find("test2").unwrap();
437            assert!(test1_pos < test2_pos);
438        }
439    }
440
441    #[test]
442    #[serial_test::serial]
443    fn test_concurrent_access() {
444        use std::sync::Arc;
445        use std::sync::Barrier;
446
447        if PROFILING_ENABLED {
448            reset();
449
450            let barrier = Arc::new(Barrier::new(3));
451            let mut handles = vec![];
452
453            for i in 0..3 {
454                let b = barrier.clone();
455                let handle = thread::spawn(move || {
456                    b.wait();
457                    start_timer(&format!("thread_{i}"));
458                    thread::sleep(Duration::from_millis(10));
459                    stop_timer(&format!("thread_{i}"));
460                });
461                handles.push(handle);
462            }
463
464            for handle in handles {
465                handle.join().unwrap();
466            }
467
468            let report = get_report();
469            assert!(report.contains("thread_0"));
470            assert!(report.contains("thread_1"));
471            assert!(report.contains("thread_2"));
472        }
473    }
474}