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}