Skip to main content

ipfrs_tensorlogic/
profiler.rs

1//! TensorProfiler — operation profiling for tensor computations.
2//!
3//! Records per-operation timing, element counts, and aggregates
4//! into per-op-name profiles for throughput analysis and hotspot
5//! identification.
6
7use std::collections::HashMap;
8
9/// A single recorded profiling entry.
10#[derive(Debug, Clone)]
11pub struct ProfileEntry {
12    /// Name of the operation (e.g. "matmul", "conv2d").
13    pub op_name: String,
14    /// Wall-clock duration of the operation in nanoseconds.
15    pub duration_ns: u64,
16    /// Number of input elements consumed.
17    pub input_elements: u64,
18    /// Number of output elements produced.
19    pub output_elements: u64,
20    /// Monotonic tick at which the entry was recorded.
21    pub tick: u64,
22}
23
24/// Aggregate profile for a single operation name.
25#[derive(Debug, Clone)]
26pub struct OpProfile {
27    /// Operation name.
28    pub op_name: String,
29    /// Total number of calls recorded.
30    pub call_count: u64,
31    /// Cumulative wall-clock time in nanoseconds.
32    pub total_ns: u64,
33    /// Minimum single-call duration in nanoseconds.
34    pub min_ns: u64,
35    /// Maximum single-call duration in nanoseconds.
36    pub max_ns: u64,
37    /// Cumulative input element count.
38    pub total_input_elements: u64,
39    /// Cumulative output element count.
40    pub total_output_elements: u64,
41}
42
43/// Summary statistics for the profiler.
44#[derive(Debug, Clone)]
45pub struct ProfilerStats {
46    /// Total number of entries stored.
47    pub total_entries: usize,
48    /// Number of distinct operation names.
49    pub unique_ops: usize,
50    /// Sum of `duration_ns` across all entries.
51    pub total_ns: u64,
52    /// Whether the profiler is currently enabled.
53    pub enabled: bool,
54}
55
56/// Operation profiler for tensor computations.
57///
58/// Records individual operation entries and maintains running
59/// aggregates per operation name.  Supports enable/disable toggling,
60/// configurable maximum entry count (oldest entries evicted on
61/// overflow), and tick-based ordering.
62pub struct TensorProfiler {
63    entries: Vec<ProfileEntry>,
64    op_profiles: HashMap<String, OpProfile>,
65    enabled: bool,
66    current_tick: u64,
67    max_entries: usize,
68}
69
70impl TensorProfiler {
71    /// Create a new profiler with the given maximum entry capacity.
72    ///
73    /// The profiler starts **enabled** by default.
74    pub fn new(max_entries: usize) -> Self {
75        Self {
76            entries: Vec::new(),
77            op_profiles: HashMap::new(),
78            enabled: true,
79            current_tick: 0,
80            max_entries,
81        }
82    }
83
84    /// Record an operation.
85    ///
86    /// If the profiler is disabled the call is a no-op.  When the
87    /// entry buffer is full the oldest entry is evicted (FIFO).
88    pub fn record(
89        &mut self,
90        op_name: &str,
91        duration_ns: u64,
92        input_elements: u64,
93        output_elements: u64,
94    ) {
95        if !self.enabled {
96            return;
97        }
98
99        // Only store in the entry buffer if capacity allows.
100        if self.max_entries > 0 {
101            // Evict oldest if at capacity.
102            if self.entries.len() >= self.max_entries {
103                self.entries.remove(0);
104            }
105
106            let entry = ProfileEntry {
107                op_name: op_name.to_string(),
108                duration_ns,
109                input_elements,
110                output_elements,
111                tick: self.current_tick,
112            };
113            self.entries.push(entry);
114        }
115
116        // Update aggregate profile.
117        let profile = self
118            .op_profiles
119            .entry(op_name.to_string())
120            .or_insert_with(|| OpProfile {
121                op_name: op_name.to_string(),
122                call_count: 0,
123                total_ns: 0,
124                min_ns: u64::MAX,
125                max_ns: 0,
126                total_input_elements: 0,
127                total_output_elements: 0,
128            });
129
130        profile.call_count += 1;
131        profile.total_ns += duration_ns;
132        if duration_ns < profile.min_ns {
133            profile.min_ns = duration_ns;
134        }
135        if duration_ns > profile.max_ns {
136            profile.max_ns = duration_ns;
137        }
138        profile.total_input_elements += input_elements;
139        profile.total_output_elements += output_elements;
140    }
141
142    /// Enable recording.
143    pub fn enable(&mut self) {
144        self.enabled = true;
145    }
146
147    /// Disable recording.  Subsequent `record` calls become no-ops.
148    pub fn disable(&mut self) {
149        self.enabled = false;
150    }
151
152    /// Returns `true` if the profiler is currently recording.
153    pub fn is_enabled(&self) -> bool {
154        self.enabled
155    }
156
157    /// Look up the aggregate profile for a given operation name.
158    pub fn get_profile(&self, op_name: &str) -> Option<&OpProfile> {
159        self.op_profiles.get(op_name)
160    }
161
162    /// Average duration per call in nanoseconds for the given op.
163    pub fn avg_ns(&self, op_name: &str) -> Option<f64> {
164        self.op_profiles.get(op_name).map(|p| {
165            if p.call_count == 0 {
166                0.0
167            } else {
168                p.total_ns as f64 / p.call_count as f64
169            }
170        })
171    }
172
173    /// Throughput in elements/second for the given op.
174    ///
175    /// Computed as `total_output_elements / (total_ns / 1e9)`.
176    pub fn throughput(&self, op_name: &str) -> Option<f64> {
177        self.op_profiles.get(op_name).map(|p| {
178            if p.total_ns == 0 {
179                0.0
180            } else {
181                p.total_output_elements as f64 / (p.total_ns as f64 / 1e9)
182            }
183        })
184    }
185
186    /// Return the top `n` operations by cumulative time (descending).
187    pub fn hottest_ops(&self, n: usize) -> Vec<&OpProfile> {
188        let mut profiles: Vec<&OpProfile> = self.op_profiles.values().collect();
189        profiles.sort_by_key(|p| std::cmp::Reverse(p.total_ns));
190        profiles.truncate(n);
191        profiles
192    }
193
194    /// Advance the internal tick counter by one.
195    pub fn tick(&mut self) {
196        self.current_tick += 1;
197    }
198
199    /// Clear all entries and aggregate profiles.
200    pub fn reset(&mut self) {
201        self.entries.clear();
202        self.op_profiles.clear();
203        self.current_tick = 0;
204    }
205
206    /// Number of entries currently stored.
207    pub fn entry_count(&self) -> usize {
208        self.entries.len()
209    }
210
211    /// Return a snapshot of summary statistics.
212    pub fn stats(&self) -> ProfilerStats {
213        let total_ns: u64 = self.op_profiles.values().map(|p| p.total_ns).sum();
214        ProfilerStats {
215            total_entries: self.entries.len(),
216            unique_ops: self.op_profiles.len(),
217            total_ns,
218            enabled: self.enabled,
219        }
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Tests
225// ---------------------------------------------------------------------------
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_new_profiler_defaults() {
233        let p = TensorProfiler::new(100);
234        assert!(p.is_enabled());
235        assert_eq!(p.entry_count(), 0);
236        let s = p.stats();
237        assert_eq!(s.total_entries, 0);
238        assert_eq!(s.unique_ops, 0);
239        assert_eq!(s.total_ns, 0);
240        assert!(s.enabled);
241    }
242
243    #[test]
244    fn test_record_updates_profile() {
245        let mut p = TensorProfiler::new(100);
246        p.record("matmul", 1000, 64, 32);
247        let prof = p.get_profile("matmul").expect("profile should exist");
248        assert_eq!(prof.call_count, 1);
249        assert_eq!(prof.total_ns, 1000);
250        assert_eq!(prof.min_ns, 1000);
251        assert_eq!(prof.max_ns, 1000);
252        assert_eq!(prof.total_input_elements, 64);
253        assert_eq!(prof.total_output_elements, 32);
254        assert_eq!(p.entry_count(), 1);
255    }
256
257    #[test]
258    fn test_disabled_skips_recording() {
259        let mut p = TensorProfiler::new(100);
260        p.disable();
261        p.record("matmul", 500, 10, 10);
262        assert_eq!(p.entry_count(), 0);
263        assert!(p.get_profile("matmul").is_none());
264    }
265
266    #[test]
267    fn test_avg_ns_calculation() {
268        let mut p = TensorProfiler::new(100);
269        p.record("add", 100, 10, 10);
270        p.record("add", 200, 10, 10);
271        p.record("add", 300, 10, 10);
272        let avg = p.avg_ns("add").expect("avg should exist");
273        assert!((avg - 200.0).abs() < f64::EPSILON);
274    }
275
276    #[test]
277    fn test_avg_ns_missing_op() {
278        let p = TensorProfiler::new(100);
279        assert!(p.avg_ns("nope").is_none());
280    }
281
282    #[test]
283    fn test_throughput_calculation() {
284        let mut p = TensorProfiler::new(100);
285        // 1_000_000_000 ns = 1 second, 500 output elements => 500 elem/s
286        p.record("conv", 1_000_000_000, 1000, 500);
287        let tp = p.throughput("conv").expect("throughput should exist");
288        assert!((tp - 500.0).abs() < 1e-6);
289    }
290
291    #[test]
292    fn test_throughput_zero_time() {
293        let mut p = TensorProfiler::new(100);
294        p.record("noop", 0, 0, 0);
295        let tp = p.throughput("noop").expect("throughput should exist");
296        assert!((tp - 0.0).abs() < f64::EPSILON);
297    }
298
299    #[test]
300    fn test_throughput_missing_op() {
301        let p = TensorProfiler::new(100);
302        assert!(p.throughput("nope").is_none());
303    }
304
305    #[test]
306    fn test_hottest_ops_ordering() {
307        let mut p = TensorProfiler::new(1000);
308        p.record("fast", 100, 1, 1);
309        p.record("slow", 5000, 1, 1);
310        p.record("mid", 2000, 1, 1);
311
312        let hot = p.hottest_ops(3);
313        assert_eq!(hot.len(), 3);
314        assert_eq!(hot[0].op_name, "slow");
315        assert_eq!(hot[1].op_name, "mid");
316        assert_eq!(hot[2].op_name, "fast");
317    }
318
319    #[test]
320    fn test_hottest_ops_fewer_than_n() {
321        let mut p = TensorProfiler::new(100);
322        p.record("only", 100, 1, 1);
323        let hot = p.hottest_ops(10);
324        assert_eq!(hot.len(), 1);
325    }
326
327    #[test]
328    fn test_max_entries_eviction() {
329        let mut p = TensorProfiler::new(3);
330        p.record("a", 10, 1, 1);
331        p.record("b", 20, 1, 1);
332        p.record("c", 30, 1, 1);
333        assert_eq!(p.entry_count(), 3);
334
335        // Fourth entry evicts the oldest ("a")
336        p.record("d", 40, 1, 1);
337        assert_eq!(p.entry_count(), 3);
338
339        // The aggregate profiles still include all four ops.
340        assert!(p.get_profile("a").is_some());
341        assert!(p.get_profile("d").is_some());
342    }
343
344    #[test]
345    fn test_reset_clears_all() {
346        let mut p = TensorProfiler::new(100);
347        p.record("x", 100, 10, 10);
348        p.tick();
349        p.reset();
350        assert_eq!(p.entry_count(), 0);
351        assert!(p.get_profile("x").is_none());
352        let s = p.stats();
353        assert_eq!(s.total_entries, 0);
354        assert_eq!(s.unique_ops, 0);
355        assert_eq!(s.total_ns, 0);
356    }
357
358    #[test]
359    fn test_multiple_ops_tracked_independently() {
360        let mut p = TensorProfiler::new(100);
361        p.record("matmul", 1000, 64, 32);
362        p.record("relu", 200, 32, 32);
363        p.record("matmul", 1500, 64, 32);
364
365        let mm = p.get_profile("matmul").expect("matmul profile");
366        assert_eq!(mm.call_count, 2);
367        assert_eq!(mm.total_ns, 2500);
368
369        let relu = p.get_profile("relu").expect("relu profile");
370        assert_eq!(relu.call_count, 1);
371        assert_eq!(relu.total_ns, 200);
372    }
373
374    #[test]
375    fn test_min_max_tracking() {
376        let mut p = TensorProfiler::new(100);
377        p.record("op", 300, 1, 1);
378        p.record("op", 100, 1, 1);
379        p.record("op", 500, 1, 1);
380        let prof = p.get_profile("op").expect("profile");
381        assert_eq!(prof.min_ns, 100);
382        assert_eq!(prof.max_ns, 500);
383    }
384
385    #[test]
386    fn test_enable_disable_toggle() {
387        let mut p = TensorProfiler::new(100);
388        assert!(p.is_enabled());
389        p.disable();
390        assert!(!p.is_enabled());
391        p.enable();
392        assert!(p.is_enabled());
393    }
394
395    #[test]
396    fn test_stats_accuracy() {
397        let mut p = TensorProfiler::new(100);
398        p.record("a", 100, 10, 5);
399        p.record("b", 200, 20, 10);
400        p.record("a", 300, 30, 15);
401
402        let s = p.stats();
403        assert_eq!(s.total_entries, 3);
404        assert_eq!(s.unique_ops, 2);
405        assert_eq!(s.total_ns, 600); // 400 (a) + 200 (b)
406        assert!(s.enabled);
407    }
408
409    #[test]
410    fn test_empty_profiler() {
411        let p = TensorProfiler::new(100);
412        assert_eq!(p.entry_count(), 0);
413        assert!(p.get_profile("any").is_none());
414        assert!(p.avg_ns("any").is_none());
415        assert!(p.throughput("any").is_none());
416        let hot = p.hottest_ops(5);
417        assert!(hot.is_empty());
418    }
419
420    #[test]
421    fn test_tick_increments() {
422        let mut p = TensorProfiler::new(100);
423        p.record("a", 10, 1, 1);
424        p.tick();
425        p.record("b", 20, 1, 1);
426
427        assert_eq!(p.entries[0].tick, 0);
428        assert_eq!(p.entries[1].tick, 1);
429    }
430
431    #[test]
432    fn test_record_after_enable() {
433        let mut p = TensorProfiler::new(100);
434        p.disable();
435        p.record("x", 100, 1, 1);
436        assert_eq!(p.entry_count(), 0);
437
438        p.enable();
439        p.record("x", 200, 1, 1);
440        assert_eq!(p.entry_count(), 1);
441        let prof = p.get_profile("x").expect("profile");
442        assert_eq!(prof.total_ns, 200);
443    }
444
445    #[test]
446    fn test_input_elements_accumulation() {
447        let mut p = TensorProfiler::new(100);
448        p.record("op", 10, 100, 50);
449        p.record("op", 20, 200, 100);
450        let prof = p.get_profile("op").expect("profile");
451        assert_eq!(prof.total_input_elements, 300);
452        assert_eq!(prof.total_output_elements, 150);
453    }
454
455    #[test]
456    fn test_hottest_ops_empty() {
457        let p = TensorProfiler::new(100);
458        assert!(p.hottest_ops(5).is_empty());
459    }
460
461    #[test]
462    fn test_max_entries_zero() {
463        // Edge case: max_entries = 0 means buffer stays empty.
464        let mut p = TensorProfiler::new(0);
465        p.record("a", 10, 1, 1);
466        // Entry buffer stays empty but aggregates are updated.
467        assert_eq!(p.entry_count(), 0);
468        let prof = p.get_profile("a").expect("profile");
469        assert_eq!(prof.call_count, 1);
470    }
471
472    #[test]
473    fn test_stats_disabled() {
474        let mut p = TensorProfiler::new(100);
475        p.disable();
476        let s = p.stats();
477        assert!(!s.enabled);
478    }
479
480    #[test]
481    fn test_large_duration_values() {
482        let mut p = TensorProfiler::new(100);
483        let big = u64::MAX / 2;
484        p.record("big", big, 1, 1);
485        let prof = p.get_profile("big").expect("profile");
486        assert_eq!(prof.total_ns, big);
487        assert_eq!(prof.min_ns, big);
488        assert_eq!(prof.max_ns, big);
489    }
490
491    #[test]
492    fn test_eviction_preserves_order() {
493        let mut p = TensorProfiler::new(2);
494        p.record("a", 10, 1, 1);
495        p.record("b", 20, 1, 1);
496        p.record("c", 30, 1, 1); // evicts "a"
497
498        assert_eq!(p.entry_count(), 2);
499        assert_eq!(p.entries[0].op_name, "b");
500        assert_eq!(p.entries[1].op_name, "c");
501    }
502
503    #[test]
504    fn test_single_entry_avg_ns() {
505        let mut p = TensorProfiler::new(100);
506        p.record("solo", 42, 1, 1);
507        let avg = p.avg_ns("solo").expect("avg");
508        assert!((avg - 42.0).abs() < f64::EPSILON);
509    }
510
511    #[test]
512    fn test_throughput_multiple_records() {
513        let mut p = TensorProfiler::new(100);
514        // 2 records: total_output = 200, total_ns = 2_000_000_000 (2s)
515        // throughput = 200 / 2.0 = 100 elem/s
516        p.record("op", 1_000_000_000, 100, 100);
517        p.record("op", 1_000_000_000, 100, 100);
518        let tp = p.throughput("op").expect("throughput");
519        assert!((tp - 100.0).abs() < 1e-6);
520    }
521
522    #[test]
523    fn test_reset_then_record() {
524        let mut p = TensorProfiler::new(100);
525        p.record("a", 100, 1, 1);
526        p.reset();
527        p.record("b", 200, 2, 2);
528        assert_eq!(p.entry_count(), 1);
529        assert!(p.get_profile("a").is_none());
530        let prof = p.get_profile("b").expect("profile");
531        assert_eq!(prof.call_count, 1);
532    }
533
534    #[test]
535    fn test_op_name_preserves_string() {
536        let mut p = TensorProfiler::new(100);
537        let name = "my_custom_op_v2.1";
538        p.record(name, 10, 1, 1);
539        let prof = p.get_profile(name).expect("profile");
540        assert_eq!(prof.op_name, name);
541    }
542}