Skip to main content

ixa/profiling/
data.rs

1#[cfg(feature = "profiling")]
2use std::sync::{Mutex, MutexGuard, OnceLock};
3use std::time::{Duration, Instant};
4
5use super::computed_statistic::ComputableType;
6use super::Span;
7#[cfg(feature = "profiling")]
8use super::{
9    ComputedStatistic, ComputedValue, CustomStatisticComputer, CustomStatisticPrinter,
10    TOTAL_MEASURED,
11};
12use crate::HashMap;
13
14#[cfg(feature = "profiling")]
15static PROFILING_DATA: OnceLock<Mutex<ProfilingData>> = OnceLock::new();
16
17/// Acquires an exclusive lock on the profiling data, blocking until it's available.
18#[cfg(feature = "profiling")]
19pub(super) fn profiling_data() -> MutexGuard<'static, ProfilingData> {
20    PROFILING_DATA
21        .get_or_init(|| Mutex::new(ProfilingData::new()))
22        .lock()
23        .unwrap()
24}
25
26#[derive(Default)]
27pub struct ProfilingData {
28    pub start_time: Option<Instant>,
29    pub counts: HashMap<&'static str, usize>,
30    // We store span counts with the span duration, because they are updated when
31    // the spans are and displayed with the spans rather than with the other counts.
32    pub spans: HashMap<&'static str, (Duration, usize)>,
33    // The number of spans that are currently open. We use this and the `total_measured` span to
34    // compute the amount of time accounted for by all the spans. This together with total
35    // runtime can tell you if there is significant runtime not accounted for by the existing
36    // spans. When `open_span_count` transitions from `0`, the `total_measured` span is opened.
37    // When `open_span_count` transitions back to `0`, `total_measured` is closed and duration
38    // is recorded.
39    #[cfg(feature = "profiling")]
40    pub(super) open_span_count: usize,
41    #[cfg(feature = "profiling")]
42    pub(super) coverage: Option<Instant>,
43    // Custom computed statistics. They are wrapped in an `Option` to allow for temporarily
44    // removing them to avoid a double borrow.
45    #[cfg(feature = "profiling")]
46    pub(super) computed_statistics: Vec<Option<ComputedStatistic>>,
47}
48
49#[cfg(feature = "profiling")]
50impl ProfilingData {
51    /// Initialize a new `ProfilingData`.
52    fn new() -> Self {
53        Self::default()
54    }
55
56    pub(super) fn increment_named_count(&mut self, key: &'static str) {
57        self.init_start_time();
58        self.counts.entry(key).and_modify(|v| *v += 1).or_insert(1);
59    }
60
61    pub(super) fn get_named_count(&self, key: &'static str) -> Option<usize> {
62        self.counts.get(&key).copied()
63    }
64
65    fn init_start_time(&mut self) {
66        if self.start_time.is_none() {
67            self.start_time = Some(Instant::now());
68        }
69    }
70
71    fn open_span(&mut self, label: &'static str) -> Span {
72        self.init_start_time();
73        if self.open_span_count == 0 {
74            // Start recording coverage time.
75            self.coverage = Some(Instant::now());
76        }
77        self.open_span_count += 1;
78        Span::new(label)
79    }
80
81    /// Do not call directly. This method is called from `Span::drop`.
82    pub(super) fn close_span(&mut self, span: &Span) {
83        if self.open_span_count > 0 {
84            self.open_span_count -= 1;
85            if self.open_span_count == 0 {
86                // Stop recording coverage time. The `total_measured` must be `Some(..)` if
87                // `open_span_count` was nonzero, so unwrap always succeeds.
88                let coverage = self.coverage.take().unwrap();
89                self.close_span_without_coverage(TOTAL_MEASURED, coverage.elapsed());
90            }
91        }
92        // Always record the span itself.
93        self.close_span_without_coverage(span.label, span.start_time.elapsed());
94    }
95
96    /// Closes the span without checking the coverage span.
97    fn close_span_without_coverage(&mut self, label: &'static str, elapsed: Duration) {
98        self.spans
99            .entry(label)
100            .and_modify(|(time, count)| {
101                *time += elapsed;
102                *count += 1;
103            })
104            .or_insert((elapsed, 1));
105    }
106
107    /// Constructs a table of ("Event Label", "Count", "Rate (per sec)"). Used to print
108    /// stats to the console and write the stats to a file.
109    pub(super) fn get_named_counts_table(&self) -> Vec<(String, usize, f64)> {
110        let elapsed = match self.start_time {
111            Some(start_time) => start_time.elapsed().as_secs_f64(),
112            None => 0.0,
113        };
114        let mut rows = vec![];
115
116        // Collect data rows
117        for (key, count) in &self.counts {
118            let rate = (*count as f64) / elapsed; // Just allow this to be `inf`/`nan` if `elapsed == 0.0`.
119
120            rows.push(((*key).to_string(), *count, rate));
121        }
122
123        rows
124    }
125
126    /// Constructs a table of "Span Label", "Count", "Duration", "% runtime". Used to print
127    /// stats to the console and write the stats to a file.
128    pub(super) fn get_named_spans_table(&self) -> Vec<(String, usize, Duration, f64)> {
129        let elapsed = match self.start_time {
130            Some(start_time) => start_time.elapsed().as_secs_f64(),
131            None => 0.0,
132        };
133
134        let mut rows = vec![];
135
136        // Add all regular span rows
137        for (&label, &(duration, count)) in self.spans.iter().filter(|(k, _)| *k != &TOTAL_MEASURED)
138        {
139            rows.push((
140                label.to_string(),
141                count,
142                duration,
143                duration.as_secs_f64() / elapsed * 100.0,
144            ));
145        }
146
147        // Add the "Total measured" row at the end
148        if let Some(&(duration, count)) = self.spans.get(&TOTAL_MEASURED) {
149            rows.push((
150                TOTAL_MEASURED.to_string(),
151                count,
152                duration,
153                duration.as_secs_f64() / elapsed * 100.0,
154            ));
155        }
156
157        rows
158    }
159
160    pub(super) fn add_computed_statistic<T: ComputableType>(
161        &mut self,
162        label: &'static str,
163        description: &'static str,
164        computer: CustomStatisticComputer<T>,
165        printer: CustomStatisticPrinter<T>,
166    ) {
167        let computed_stat = ComputedStatistic {
168            label,
169            description,
170            value: None,
171            functions: T::new_functions(computer, printer),
172        };
173        self.computed_statistics.push(Some(computed_stat));
174    }
175}
176
177#[cfg(feature = "profiling")]
178pub fn increment_named_count(key: &'static str) {
179    let mut container = profiling_data();
180    container.increment_named_count(key);
181}
182
183#[cfg(not(feature = "profiling"))]
184pub fn increment_named_count(_key: &'static str) {}
185
186#[cfg(feature = "profiling")]
187#[must_use]
188pub fn open_span(label: &'static str) -> Span {
189    let mut container = profiling_data();
190    container.open_span(label)
191}
192
193#[cfg(not(feature = "profiling"))]
194#[must_use]
195pub fn open_span(label: &'static str) -> Span {
196    Span::new(label)
197}
198
199/// Call this if you want to explicitly close a span before the end of the scope in which the
200/// span was defined. Equivalent to `span.drop()`.
201pub fn close_span(_span: Span) {
202    // The `span` is dropped here, and `ProfilingData::close_span` is called
203    // from `Span::drop`. Incidentally, this is the same implementation as `span.drop()`!
204}
205
206#[cfg(all(test, feature = "profiling"))]
207mod tests {
208    use std::time::Duration;
209
210    use super::*;
211    use crate::profiling::{get_profiling_data, increment_named_count};
212
213    #[test]
214    fn test_span_basic() {
215        {
216            let _span = open_span("test_operation_basic");
217            std::thread::sleep(Duration::from_millis(10));
218        }
219
220        let data = get_profiling_data();
221        assert!(data.spans.contains_key("test_operation_basic"));
222        let (duration, count) = data.spans.get("test_operation_basic").unwrap();
223        assert_eq!(*count, 1);
224        assert!(*duration >= Duration::from_millis(10));
225    }
226
227    #[test]
228    fn test_span_multiple_calls() {
229        for _ in 0..5 {
230            let _span = open_span("repeated_operation_multi_test");
231            std::thread::sleep(Duration::from_millis(5));
232        }
233
234        let data = get_profiling_data();
235        let (duration, count) = data.spans.get("repeated_operation_multi_test").unwrap();
236        assert!(*count >= 4, "expected at least 4 drops, got {}", count);
237        assert!(*duration >= Duration::from_millis(15));
238    }
239
240    #[test]
241    fn test_span_explicit_close() {
242        let span = open_span("explicit_close_test");
243        std::thread::sleep(Duration::from_millis(10));
244        close_span(span);
245
246        let data = get_profiling_data();
247        assert!(data.spans.contains_key("explicit_close_test"));
248    }
249
250    #[test]
251    fn test_span_nesting() {
252        {
253            let _outer = open_span("outer_nesting_test");
254            std::thread::sleep(Duration::from_millis(5));
255            {
256                let _inner = open_span("inner_nesting_test");
257                std::thread::sleep(Duration::from_millis(5));
258            }
259            std::thread::sleep(Duration::from_millis(5));
260        }
261
262        let data = get_profiling_data();
263        assert!(data.spans.contains_key("outer_nesting_test"));
264        assert!(data.spans.contains_key("inner_nesting_test"));
265
266        let (outer_duration, _) = data.spans.get("outer_nesting_test").unwrap();
267        let (inner_duration, _) = data.spans.get("inner_nesting_test").unwrap();
268
269        assert!(*outer_duration > *inner_duration);
270        assert!(*outer_duration >= Duration::from_millis(15));
271        assert!(*inner_duration >= Duration::from_millis(5));
272    }
273
274    #[test]
275    fn test_total_measured_span() {
276        {
277            let _span1 = open_span("operation1_total_measured");
278            std::thread::sleep(Duration::from_millis(10));
279        }
280
281        std::thread::sleep(Duration::from_millis(5));
282
283        {
284            let _span2 = open_span("operation2_total_measured");
285            std::thread::sleep(Duration::from_millis(10));
286        }
287
288        let data = get_profiling_data();
289
290        // Just verify our specific spans exist
291        assert!(data.spans.contains_key("operation1_total_measured"));
292        assert!(data.spans.contains_key("operation2_total_measured"));
293
294        let (duration1, _) = data.spans.get("operation1_total_measured").unwrap();
295        let (duration2, _) = data.spans.get("operation2_total_measured").unwrap();
296
297        assert!(*duration1 >= Duration::from_millis(10));
298        assert!(*duration2 >= Duration::from_millis(10));
299    }
300
301    #[test]
302    fn test_get_named_counts_table() {
303        // Capture container start_time before adding counts
304        let container_start = {
305            let data = get_profiling_data();
306            data.start_time
307        };
308        increment_named_count("event_a_counts_table_test");
309        increment_named_count("event_a_counts_table_test");
310        increment_named_count("event_b_counts_table_test");
311
312        // Sleep to ensure measurable time has passed
313        std::thread::sleep(Duration::from_millis(100));
314
315        // Use the same origin as container rate calculation; if None, fall back to local start
316        let elapsed = if let Some(start_time) = container_start {
317            start_time.elapsed().as_secs_f64()
318        } else {
319            // If profiling hasn't started yet, rate will be based on init at first increment,
320            // so approximate by measuring from the first increment call using a local Instant.
321            // In practice, this path should rarely trigger.
322            0.1
323        };
324
325        let data = get_profiling_data();
326        let table = data.get_named_counts_table();
327
328        // Find our specific events instead of checking total table length
329        let event_a = table
330            .iter()
331            .find(|(label, _, _)| label == "event_a_counts_table_test");
332        assert!(event_a.is_some());
333        let (_, count, rate) = event_a.unwrap();
334        assert_eq!(*count, 2);
335        // Rate should be approximately 2/elapsed (2 events / ~0.1 second = ~20/sec)
336        let expected_rate = 2.0 / elapsed;
337
338        // Allow 10% margin for timing variations
339        assert!(*rate > expected_rate * 0.9 && *rate < expected_rate * 1.1);
340
341        let event_b = table
342            .iter()
343            .find(|(label, _, _)| label == "event_b_counts_table_test");
344        assert!(event_b.is_some());
345        let (_, count, _) = event_b.unwrap();
346        assert_eq!(*count, 1);
347    }
348
349    #[test]
350    fn test_get_named_spans_table() {
351        // Capture container start time without mutating it
352        let container_start = {
353            let data = get_profiling_data();
354            data.start_time
355        };
356
357        {
358            let _span = open_span("test_span_table");
359            std::thread::sleep(Duration::from_millis(100));
360        }
361
362        std::thread::sleep(Duration::from_millis(100));
363
364        let data = get_profiling_data();
365        let table = data.get_named_spans_table();
366
367        assert!(table.len() >= 2);
368
369        let test_span = table
370            .iter()
371            .find(|(label, _, _, _)| label == "test_span_table");
372        assert!(test_span.is_some());
373
374        let last = table.last().unwrap();
375        assert_eq!(last.0, "Total Measured");
376
377        let (_, _, _, percent) = test_span.unwrap();
378        // Compute expected percent from container start time
379        let elapsed = if let Some(start_time) = container_start {
380            start_time.elapsed().as_secs_f64()
381        } else {
382            // If profiling hasn't started yet, approximate with 0.2s total elapsed (100ms span + 100ms idle)
383            0.2
384        };
385        let (duration, _) = data.spans.get("test_span_table").unwrap();
386        let expected_percent = duration.as_secs_f64() / elapsed * 100.0;
387        // Allow reasonable tolerance for timing variations
388        assert!((*percent - expected_percent).abs() < 5.0);
389    }
390}