Skip to main content

ipfrs_tensorlogic/
memory_tracker.rs

1//! Inference Memory Tracker
2//!
3//! Tracks memory allocations made during inference sessions, detecting leaks,
4//! peaks, and per-rule memory usage.
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// AllocEvent
10// ---------------------------------------------------------------------------
11
12/// An event that can be recorded by [`InferenceMemoryTracker`].
13#[derive(Clone, Debug, PartialEq)]
14pub enum AllocEvent {
15    /// A memory region was allocated.
16    Alloc {
17        /// Unique identifier for the region.
18        region_id: u64,
19        /// Size of the allocation in bytes.
20        size_bytes: u64,
21        /// Optional rule that triggered this allocation.
22        rule_id: Option<u64>,
23    },
24    /// A previously allocated region was freed.
25    Free {
26        /// Identifier of the region to free.
27        region_id: u64,
28    },
29    /// A named checkpoint in the event log.
30    Checkpoint {
31        /// Human-readable label for the checkpoint.
32        label: String,
33    },
34}
35
36// ---------------------------------------------------------------------------
37// RegionInfo
38// ---------------------------------------------------------------------------
39
40/// Metadata about a single allocated memory region.
41#[derive(Clone, Debug, PartialEq)]
42pub struct RegionInfo {
43    /// Unique identifier for this region.
44    pub region_id: u64,
45    /// Size of the region in bytes.
46    pub size_bytes: u64,
47    /// Rule that caused this allocation, if any.
48    pub rule_id: Option<u64>,
49    /// Index into the event log at which this region was allocated.
50    pub allocated_at_event: usize,
51    /// Whether this region has been freed.
52    pub freed: bool,
53}
54
55// ---------------------------------------------------------------------------
56// MemorySnapshot
57// ---------------------------------------------------------------------------
58
59/// A point-in-time view of tracked memory usage.
60#[derive(Clone, Debug, PartialEq)]
61pub struct MemorySnapshot {
62    /// Total bytes in currently-live (non-freed) regions.
63    pub live_bytes: u64,
64    /// Number of currently-live regions.
65    pub live_regions: usize,
66    /// Highest `live_bytes` value observed during the session.
67    pub peak_bytes: u64,
68    /// Bytes in regions that were never freed (live at end of session).
69    pub leaked_bytes: u64,
70}
71
72impl MemorySnapshot {
73    /// Returns the fraction of `capacity_bytes` that is currently live.
74    ///
75    /// Returns `0.0` when `capacity_bytes` is zero to avoid division by zero.
76    pub fn utilization(&self, capacity_bytes: u64) -> f64 {
77        if capacity_bytes == 0 {
78            return 0.0;
79        }
80        self.live_bytes as f64 / capacity_bytes as f64
81    }
82}
83
84// ---------------------------------------------------------------------------
85// TrackerStats
86// ---------------------------------------------------------------------------
87
88/// Aggregate counters for an [`InferenceMemoryTracker`] session.
89#[derive(Clone, Debug, Default, PartialEq)]
90pub struct TrackerStats {
91    /// Total number of `Alloc` events recorded.
92    pub total_allocs: u64,
93    /// Total number of `Free` events recorded (including ignored double-frees).
94    pub total_frees: u64,
95    /// Total number of `Checkpoint` events recorded.
96    pub total_checkpoints: u64,
97}
98
99impl TrackerStats {
100    /// Returns the number of allocations that were never matched by a free.
101    ///
102    /// This is computed as `total_allocs - total_frees`, clamped to zero so
103    /// that spurious double-frees do not produce a negative result.
104    pub fn leak_count(&self) -> u64 {
105        self.total_allocs.saturating_sub(self.total_frees)
106    }
107}
108
109// ---------------------------------------------------------------------------
110// InferenceMemoryTracker
111// ---------------------------------------------------------------------------
112
113/// Tracks memory allocations made during a single inference session.
114///
115/// Record [`AllocEvent`]s via [`InferenceMemoryTracker::record`], then query
116/// the current state via [`InferenceMemoryTracker::snapshot`],
117/// [`InferenceMemoryTracker::leaked_regions`], and
118/// [`InferenceMemoryTracker::memory_by_rule`].
119#[derive(Debug)]
120pub struct InferenceMemoryTracker {
121    /// Full, ordered event log.
122    pub events: Vec<AllocEvent>,
123    /// Map from `region_id` to its [`RegionInfo`].
124    pub regions: HashMap<u64, RegionInfo>,
125    /// Current live byte total.
126    pub live_bytes: u64,
127    /// Highest live_bytes value seen so far.
128    pub peak_bytes: u64,
129    /// Aggregate counters.
130    pub stats: TrackerStats,
131}
132
133impl InferenceMemoryTracker {
134    /// Creates a new, empty tracker.
135    pub fn new() -> Self {
136        Self {
137            events: Vec::new(),
138            regions: HashMap::new(),
139            live_bytes: 0,
140            peak_bytes: 0,
141            stats: TrackerStats::default(),
142        }
143    }
144
145    /// Records an event and updates internal state accordingly.
146    ///
147    /// - [`AllocEvent::Alloc`]: inserts a new [`RegionInfo`], increases
148    ///   `live_bytes`, and updates `peak_bytes`.
149    /// - [`AllocEvent::Free`]: marks the region freed and decreases
150    ///   `live_bytes`. Silently ignored when the region is not found or
151    ///   already freed (double-free).
152    /// - [`AllocEvent::Checkpoint`]: just appended to the event log.
153    pub fn record(&mut self, event: AllocEvent) {
154        match &event {
155            AllocEvent::Alloc {
156                region_id,
157                size_bytes,
158                rule_id,
159            } => {
160                let event_idx = self.events.len();
161                self.regions.insert(
162                    *region_id,
163                    RegionInfo {
164                        region_id: *region_id,
165                        size_bytes: *size_bytes,
166                        rule_id: *rule_id,
167                        allocated_at_event: event_idx,
168                        freed: false,
169                    },
170                );
171                self.live_bytes = self.live_bytes.saturating_add(*size_bytes);
172                if self.live_bytes > self.peak_bytes {
173                    self.peak_bytes = self.live_bytes;
174                }
175                self.stats.total_allocs += 1;
176            }
177            AllocEvent::Free { region_id } => {
178                self.stats.total_frees += 1;
179                if let Some(region) = self.regions.get_mut(region_id) {
180                    if !region.freed {
181                        region.freed = true;
182                        self.live_bytes = self.live_bytes.saturating_sub(region.size_bytes);
183                    }
184                    // else: already freed — double-free, ignore silently
185                }
186                // region not found — ignore silently
187            }
188            AllocEvent::Checkpoint { .. } => {
189                self.stats.total_checkpoints += 1;
190            }
191        }
192        self.events.push(event);
193    }
194
195    /// Returns a snapshot of the current memory state.
196    ///
197    /// `leaked_bytes` is the sum of `size_bytes` for every region that has
198    /// never been freed.
199    pub fn snapshot(&self) -> MemorySnapshot {
200        let live_regions = self.regions.values().filter(|r| !r.freed).count();
201        let leaked_bytes: u64 = self
202            .regions
203            .values()
204            .filter(|r| !r.freed)
205            .map(|r| r.size_bytes)
206            .sum();
207
208        MemorySnapshot {
209            live_bytes: self.live_bytes,
210            live_regions,
211            peak_bytes: self.peak_bytes,
212            leaked_bytes,
213        }
214    }
215
216    /// Returns the total bytes currently held by live regions associated with
217    /// the given `rule_id`.
218    pub fn memory_by_rule(&self, rule_id: u64) -> u64 {
219        self.regions
220            .values()
221            .filter(|r| !r.freed && r.rule_id == Some(rule_id))
222            .map(|r| r.size_bytes)
223            .sum()
224    }
225
226    /// Returns references to all regions that have not been freed.
227    pub fn leaked_regions(&self) -> Vec<&RegionInfo> {
228        let mut leaked: Vec<&RegionInfo> = self.regions.values().filter(|r| !r.freed).collect();
229        // Sort by region_id for deterministic output.
230        leaked.sort_by_key(|r| r.region_id);
231        leaked
232    }
233
234    /// Returns a reference to the aggregate counters.
235    pub fn stats(&self) -> &TrackerStats {
236        &self.stats
237    }
238
239    /// Resets all state so the tracker can be reused for a new session.
240    pub fn reset(&mut self) {
241        self.events.clear();
242        self.regions.clear();
243        self.live_bytes = 0;
244        self.peak_bytes = 0;
245        self.stats = TrackerStats::default();
246    }
247}
248
249impl Default for InferenceMemoryTracker {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255// ---------------------------------------------------------------------------
256// Tests
257// ---------------------------------------------------------------------------
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn alloc(region_id: u64, size_bytes: u64, rule_id: Option<u64>) -> AllocEvent {
264        AllocEvent::Alloc {
265            region_id,
266            size_bytes,
267            rule_id,
268        }
269    }
270
271    fn free(region_id: u64) -> AllocEvent {
272        AllocEvent::Free { region_id }
273    }
274
275    fn checkpoint(label: &str) -> AllocEvent {
276        AllocEvent::Checkpoint {
277            label: label.to_string(),
278        }
279    }
280
281    // -----------------------------------------------------------------------
282    // 1. Alloc increases live_bytes
283    // -----------------------------------------------------------------------
284    #[test]
285    fn test_alloc_increases_live_bytes() {
286        let mut tracker = InferenceMemoryTracker::new();
287        tracker.record(alloc(1, 1024, None));
288        assert_eq!(tracker.live_bytes, 1024);
289        tracker.record(alloc(2, 512, None));
290        assert_eq!(tracker.live_bytes, 1536);
291    }
292
293    // -----------------------------------------------------------------------
294    // 2. Free decreases live_bytes
295    // -----------------------------------------------------------------------
296    #[test]
297    fn test_free_decreases_live_bytes() {
298        let mut tracker = InferenceMemoryTracker::new();
299        tracker.record(alloc(1, 1024, None));
300        tracker.record(free(1));
301        assert_eq!(tracker.live_bytes, 0);
302    }
303
304    // -----------------------------------------------------------------------
305    // 3. peak_bytes tracks highest value
306    // -----------------------------------------------------------------------
307    #[test]
308    fn test_peak_bytes_tracked() {
309        let mut tracker = InferenceMemoryTracker::new();
310        tracker.record(alloc(1, 2048, None));
311        tracker.record(alloc(2, 1024, None));
312        tracker.record(free(1));
313        tracker.record(free(2));
314        // Peak was 3072 (2048 + 1024); current live is 0.
315        assert_eq!(tracker.peak_bytes, 3072);
316        assert_eq!(tracker.live_bytes, 0);
317    }
318
319    // -----------------------------------------------------------------------
320    // 4. Double-free is ignored (live_bytes does not go negative)
321    // -----------------------------------------------------------------------
322    #[test]
323    fn test_double_free_ignored() {
324        let mut tracker = InferenceMemoryTracker::new();
325        tracker.record(alloc(1, 500, None));
326        tracker.record(free(1));
327        tracker.record(free(1)); // second free — should be a no-op
328        assert_eq!(tracker.live_bytes, 0);
329    }
330
331    // -----------------------------------------------------------------------
332    // 5. Free of unknown region is ignored
333    // -----------------------------------------------------------------------
334    #[test]
335    fn test_free_unknown_region_ignored() {
336        let mut tracker = InferenceMemoryTracker::new();
337        tracker.record(free(99)); // region 99 was never allocated
338        assert_eq!(tracker.live_bytes, 0);
339        assert_eq!(tracker.stats().total_frees, 1); // event still counted
340    }
341
342    // -----------------------------------------------------------------------
343    // 6. leaked_regions after session (some regions never freed)
344    // -----------------------------------------------------------------------
345    #[test]
346    fn test_leaked_regions_after_session() {
347        let mut tracker = InferenceMemoryTracker::new();
348        tracker.record(alloc(1, 100, None));
349        tracker.record(alloc(2, 200, None));
350        tracker.record(free(1));
351        // Region 2 is never freed.
352        let leaked = tracker.leaked_regions();
353        assert_eq!(leaked.len(), 1);
354        assert_eq!(leaked[0].region_id, 2);
355        assert_eq!(leaked[0].size_bytes, 200);
356    }
357
358    // -----------------------------------------------------------------------
359    // 7. leaked_regions returns empty when all regions freed
360    // -----------------------------------------------------------------------
361    #[test]
362    fn test_leaked_regions_empty_when_all_freed() {
363        let mut tracker = InferenceMemoryTracker::new();
364        tracker.record(alloc(1, 100, None));
365        tracker.record(free(1));
366        assert!(tracker.leaked_regions().is_empty());
367    }
368
369    // -----------------------------------------------------------------------
370    // 8. memory_by_rule sums correctly
371    // -----------------------------------------------------------------------
372    #[test]
373    fn test_memory_by_rule_sums_correctly() {
374        let mut tracker = InferenceMemoryTracker::new();
375        tracker.record(alloc(1, 400, Some(10)));
376        tracker.record(alloc(2, 600, Some(10)));
377        tracker.record(alloc(3, 200, Some(20)));
378        assert_eq!(tracker.memory_by_rule(10), 1000);
379        assert_eq!(tracker.memory_by_rule(20), 200);
380        assert_eq!(tracker.memory_by_rule(99), 0);
381    }
382
383    // -----------------------------------------------------------------------
384    // 9. memory_by_rule excludes freed regions
385    // -----------------------------------------------------------------------
386    #[test]
387    fn test_memory_by_rule_excludes_freed() {
388        let mut tracker = InferenceMemoryTracker::new();
389        tracker.record(alloc(1, 400, Some(10)));
390        tracker.record(alloc(2, 600, Some(10)));
391        tracker.record(free(2));
392        assert_eq!(tracker.memory_by_rule(10), 400);
393    }
394
395    // -----------------------------------------------------------------------
396    // 10. Checkpoint is counted in stats
397    // -----------------------------------------------------------------------
398    #[test]
399    fn test_checkpoint_counted() {
400        let mut tracker = InferenceMemoryTracker::new();
401        tracker.record(checkpoint("start"));
402        tracker.record(checkpoint("middle"));
403        assert_eq!(tracker.stats().total_checkpoints, 2);
404    }
405
406    // -----------------------------------------------------------------------
407    // 11. snapshot.leaked_bytes reflects unfree'd regions
408    // -----------------------------------------------------------------------
409    #[test]
410    fn test_snapshot_leaked_bytes() {
411        let mut tracker = InferenceMemoryTracker::new();
412        tracker.record(alloc(1, 100, None));
413        tracker.record(alloc(2, 300, None));
414        tracker.record(free(1));
415        let snap = tracker.snapshot();
416        assert_eq!(snap.leaked_bytes, 300);
417    }
418
419    // -----------------------------------------------------------------------
420    // 12. snapshot.leaked_bytes is zero when nothing leaks
421    // -----------------------------------------------------------------------
422    #[test]
423    fn test_snapshot_no_leaks() {
424        let mut tracker = InferenceMemoryTracker::new();
425        tracker.record(alloc(1, 100, None));
426        tracker.record(free(1));
427        let snap = tracker.snapshot();
428        assert_eq!(snap.leaked_bytes, 0);
429    }
430
431    // -----------------------------------------------------------------------
432    // 13. utilization calculation
433    // -----------------------------------------------------------------------
434    #[test]
435    fn test_utilization() {
436        let snap = MemorySnapshot {
437            live_bytes: 512,
438            live_regions: 1,
439            peak_bytes: 1024,
440            leaked_bytes: 512,
441        };
442        let util = snap.utilization(1024);
443        assert!((util - 0.5).abs() < f64::EPSILON);
444    }
445
446    // -----------------------------------------------------------------------
447    // 14. utilization with zero capacity returns 0.0
448    // -----------------------------------------------------------------------
449    #[test]
450    fn test_utilization_zero_capacity() {
451        let snap = MemorySnapshot {
452            live_bytes: 512,
453            live_regions: 1,
454            peak_bytes: 512,
455            leaked_bytes: 512,
456        };
457        assert_eq!(snap.utilization(0), 0.0);
458    }
459
460    // -----------------------------------------------------------------------
461    // 15. reset clears all state
462    // -----------------------------------------------------------------------
463    #[test]
464    fn test_reset_clears_state() {
465        let mut tracker = InferenceMemoryTracker::new();
466        tracker.record(alloc(1, 1024, Some(5)));
467        tracker.record(checkpoint("cp"));
468        tracker.reset();
469        assert_eq!(tracker.live_bytes, 0);
470        assert_eq!(tracker.peak_bytes, 0);
471        assert!(tracker.events.is_empty());
472        assert!(tracker.regions.is_empty());
473        assert_eq!(tracker.stats().total_allocs, 0);
474        assert_eq!(tracker.stats().total_frees, 0);
475        assert_eq!(tracker.stats().total_checkpoints, 0);
476    }
477
478    // -----------------------------------------------------------------------
479    // 16. stats.leak_count equals allocs minus frees
480    // -----------------------------------------------------------------------
481    #[test]
482    fn test_stats_leak_count() {
483        let mut tracker = InferenceMemoryTracker::new();
484        tracker.record(alloc(1, 100, None));
485        tracker.record(alloc(2, 200, None));
486        tracker.record(alloc(3, 300, None));
487        tracker.record(free(1));
488        // 3 allocs, 1 free → leak_count = 2
489        assert_eq!(tracker.stats().leak_count(), 2);
490    }
491
492    // -----------------------------------------------------------------------
493    // 17. stats.leak_count does not underflow on more frees than allocs
494    // -----------------------------------------------------------------------
495    #[test]
496    fn test_stats_leak_count_no_underflow() {
497        let mut tracker = InferenceMemoryTracker::new();
498        tracker.record(alloc(1, 100, None));
499        tracker.record(free(1));
500        tracker.record(free(1)); // double-free still counted in total_frees
501                                 // total_allocs=1, total_frees=2 → saturating_sub → 0
502        assert_eq!(tracker.stats().leak_count(), 0);
503    }
504
505    // -----------------------------------------------------------------------
506    // 18. Full session: alloc → work → free → snapshot integrity
507    // -----------------------------------------------------------------------
508    #[test]
509    fn test_full_session_snapshot_integrity() {
510        let mut tracker = InferenceMemoryTracker::new();
511        tracker.record(checkpoint("session_start"));
512        tracker.record(alloc(10, 1000, Some(1)));
513        tracker.record(alloc(11, 2000, Some(1)));
514        tracker.record(alloc(12, 500, Some(2)));
515        tracker.record(checkpoint("after_allocs"));
516        tracker.record(free(10));
517        tracker.record(free(12));
518        // Region 11 is never freed.
519        let snap = tracker.snapshot();
520        assert_eq!(snap.live_bytes, 2000);
521        assert_eq!(snap.live_regions, 1);
522        assert_eq!(snap.peak_bytes, 3500);
523        assert_eq!(snap.leaked_bytes, 2000);
524        assert_eq!(tracker.stats().total_allocs, 3);
525        assert_eq!(tracker.stats().total_frees, 2);
526        assert_eq!(tracker.stats().total_checkpoints, 2);
527        assert_eq!(tracker.stats().leak_count(), 1);
528    }
529
530    // -----------------------------------------------------------------------
531    // 19. event log is complete and ordered
532    // -----------------------------------------------------------------------
533    #[test]
534    fn test_event_log_ordered() {
535        let mut tracker = InferenceMemoryTracker::new();
536        tracker.record(checkpoint("a"));
537        tracker.record(alloc(1, 100, None));
538        tracker.record(free(1));
539        assert_eq!(tracker.events.len(), 3);
540        assert_eq!(tracker.events[0], checkpoint("a"));
541        assert_eq!(tracker.events[1], alloc(1, 100, None));
542        assert_eq!(tracker.events[2], free(1));
543    }
544
545    // -----------------------------------------------------------------------
546    // 20. allocated_at_event index is set correctly
547    // -----------------------------------------------------------------------
548    #[test]
549    fn test_allocated_at_event_index() {
550        let mut tracker = InferenceMemoryTracker::new();
551        tracker.record(checkpoint("init")); // event 0
552        tracker.record(alloc(1, 100, None)); // event 1
553        tracker.record(alloc(2, 200, None)); // event 2
554        assert_eq!(tracker.regions[&1].allocated_at_event, 1);
555        assert_eq!(tracker.regions[&2].allocated_at_event, 2);
556    }
557}