1use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use serde::{Deserialize, Serialize};
6
7use super::document::TraceDocument;
8
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum MemoryCategory {
13 Parameter,
14 Activation,
15 Gradient,
16 Optimizer,
17 #[default]
18 Other,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum MemoryAction {
25 Alloc,
26 Free,
27}
28
29pub fn dtype_size_bytes(dtype: &str) -> Option<usize> {
31 match dtype.trim().to_ascii_lowercase().as_str() {
32 "u8" | "i8" | "bool" | "f8" => Some(1),
33 "u16" | "i16" | "f16" | "bf16" => Some(2),
34 "u32" | "i32" | "f32" => Some(4),
35 "u64" | "i64" | "f64" => Some(8),
36 _ => None,
37 }
38}
39
40pub fn elem_count(shape: &[usize]) -> u64 {
42 shape
43 .iter()
44 .fold(1u64, |acc, dim| acc.saturating_mul(*dim as u64))
45}
46
47pub fn dense_tensor_bytes(shape: &[usize], dtype: &str) -> Option<u64> {
49 dtype_size_bytes(dtype).map(|size| elem_count(shape).saturating_mul(size as u64))
50}
51
52pub fn resolve_dense_tensor_bytes(
54 explicit: Option<u64>,
55 shape: &[usize],
56 dtype: &str,
57) -> Option<u64> {
58 explicit.or_else(|| dense_tensor_bytes(shape, dtype))
59}
60
61pub fn category_for_step(
63 step: Option<crate::phase::ExecutionStep>,
64 requires_grad: bool,
65) -> MemoryCategory {
66 match step {
67 Some(crate::phase::ExecutionStep::Backward) => MemoryCategory::Gradient,
68 Some(crate::phase::ExecutionStep::Optimizer) => MemoryCategory::Optimizer,
69 Some(crate::phase::ExecutionStep::Forward) => MemoryCategory::Activation,
70 None if requires_grad => MemoryCategory::Parameter,
71 None => MemoryCategory::Activation,
72 }
73}
74
75fn category_key(category: MemoryCategory) -> String {
76 format!("{category:?}").to_ascii_lowercase()
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct LogicalStorageLifetime {
82 pub device: String,
83 pub storage_id: String,
84 pub tensor_ids: Vec<String>,
85 pub allocation_span_id: String,
86 pub op_name: Option<String>,
87 pub bytes: u64,
88 pub shape: Vec<usize>,
89 pub dtype: String,
90 pub category: MemoryCategory,
91 pub start_timestamp_ns: u64,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub end_timestamp_ns: Option<u64>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct LogicalMemoryTimelinePoint {
99 pub timestamp_ns: u64,
100 pub live_bytes: u64,
101 pub live_bytes_by_device: BTreeMap<String, u64>,
102 pub live_bytes_by_category: BTreeMap<String, u64>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct LogicalMemoryPeak {
108 pub timestamp_ns: u64,
109 pub live_bytes: u64,
110 pub live_bytes_by_device: BTreeMap<String, u64>,
111 pub live_bytes_by_category: BTreeMap<String, u64>,
112 pub live_allocations: Vec<LogicalStorageLifetime>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct LogicalDeviceMemoryStats {
118 pub device: String,
119 pub storage_allocation_count: u64,
120 pub matched_storage_free_count: u64,
121 pub total_allocated_bytes: u64,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub peak_live_bytes: Option<u64>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub peak_timestamp_ns: Option<u64>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct LogicalMemoryProfile {
132 pub storage_allocation_count: u64,
133 pub matched_storage_free_count: u64,
134 pub total_allocated_bytes: u64,
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub peak: Option<LogicalMemoryPeak>,
138 pub timeline: Vec<LogicalMemoryTimelinePoint>,
139 pub lifetimes: Vec<LogicalStorageLifetime>,
140 pub by_device: Vec<LogicalDeviceMemoryStats>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct PhysicalMemoryTimelinePoint {
146 pub timestamp_ns: u64,
147 pub device: String,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub used_bytes: Option<u64>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub free_bytes: Option<u64>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub reserved_bytes: Option<u64>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub capacity_bytes: Option<u64>,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct PhysicalDeviceMemoryStats {
161 pub device: String,
162 pub sample_count: u64,
163 pub used_sample_count: u64,
164 pub free_sample_count: u64,
165 pub reserved_sample_count: u64,
166 pub capacity_sample_count: u64,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub peak_used_bytes: Option<u64>,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub peak_used_timestamp_ns: Option<u64>,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub peak_reserved_bytes: Option<u64>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub peak_reserved_timestamp_ns: Option<u64>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub minimum_free_bytes: Option<u64>,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub minimum_free_timestamp_ns: Option<u64>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub maximum_observed_capacity_bytes: Option<u64>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct PhysicalMemoryProfile {
186 pub timeline: Vec<PhysicalMemoryTimelinePoint>,
187 pub by_device: Vec<PhysicalDeviceMemoryStats>,
188}
189
190#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
192pub struct MemoryProfile {
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub logical: Option<LogicalMemoryProfile>,
195 #[serde(skip_serializing_if = "Option::is_none")]
196 pub physical: Option<PhysicalMemoryProfile>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
200struct StorageKey {
201 device: String,
202 storage_id: String,
203}
204
205#[derive(Debug, Clone)]
206struct ActiveStorage {
207 key: StorageKey,
208 tensor_ids: BTreeSet<String>,
209 allocation_span_id: String,
210 op_name: Option<String>,
211 bytes: u64,
212 shape: Vec<usize>,
213 dtype: String,
214 category: MemoryCategory,
215 start_timestamp_ns: u64,
216}
217
218impl ActiveStorage {
219 fn lifetime(&self, end_timestamp_ns: Option<u64>) -> LogicalStorageLifetime {
220 LogicalStorageLifetime {
221 device: self.key.device.clone(),
222 storage_id: self.key.storage_id.clone(),
223 tensor_ids: self.tensor_ids.iter().cloned().collect(),
224 allocation_span_id: self.allocation_span_id.clone(),
225 op_name: self.op_name.clone(),
226 bytes: self.bytes,
227 shape: self.shape.clone(),
228 dtype: self.dtype.clone(),
229 category: self.category,
230 start_timestamp_ns: self.start_timestamp_ns,
231 end_timestamp_ns,
232 }
233 }
234}
235
236#[derive(Debug, Default)]
237struct LogicalDeviceAccumulator {
238 allocation_count: u64,
239 free_count: u64,
240 total_allocated_bytes: u64,
241 peak_live_bytes: Option<u64>,
242 peak_timestamp_ns: Option<u64>,
243}
244
245pub fn analyze_memory(doc: &TraceDocument) -> MemoryProfile {
247 MemoryProfile {
248 logical: analyze_logical_memory(doc),
249 physical: analyze_physical_memory(doc),
250 }
251}
252
253fn analyze_logical_memory(doc: &TraceDocument) -> Option<LogicalMemoryProfile> {
254 if doc.memory.is_empty() {
255 return None;
256 }
257
258 let mut events: Vec<_> = doc.memory.iter().enumerate().collect();
259 events.sort_by_key(|(index, event)| (event.timestamp_ns, *index));
260
261 let mut active: BTreeMap<StorageKey, ActiveStorage> = BTreeMap::new();
262 let mut lifetimes = Vec::new();
263 let mut timeline = Vec::new();
264 let mut devices: BTreeMap<String, LogicalDeviceAccumulator> = BTreeMap::new();
265 let mut total_allocation_count = 0u64;
266 let mut total_free_count = 0u64;
267 let mut total_allocated_bytes = 0u64;
268 let mut peak: Option<LogicalMemoryPeak> = None;
269
270 let mut cursor = 0usize;
271 while cursor < events.len() {
272 let timestamp_ns = events[cursor].1.timestamp_ns;
273 let end = events[cursor..]
274 .iter()
275 .position(|(_, event)| event.timestamp_ns != timestamp_ns)
276 .map_or(events.len(), |offset| cursor + offset);
277
278 for (_, event) in &events[cursor..end] {
279 let key = StorageKey {
280 device: event.device.clone(),
281 storage_id: event.storage_id.clone(),
282 };
283 let device_stats = devices.entry(event.device.clone()).or_default();
284 match event.action {
285 MemoryAction::Alloc => {
286 if let Some(existing) = active.get_mut(&key) {
287 existing.tensor_ids.insert(event.tensor_id.clone());
288 } else {
289 let mut tensor_ids = BTreeSet::new();
290 tensor_ids.insert(event.tensor_id.clone());
291 active.insert(
292 key.clone(),
293 ActiveStorage {
294 key,
295 tensor_ids,
296 allocation_span_id: event.span_id.clone(),
297 op_name: event.op_name.clone(),
298 bytes: event.bytes,
299 shape: event.shape.clone(),
300 dtype: event.dtype.clone(),
301 category: event.category,
302 start_timestamp_ns: event.timestamp_ns,
303 },
304 );
305 total_allocation_count += 1;
306 total_allocated_bytes = total_allocated_bytes.saturating_add(event.bytes);
307 device_stats.allocation_count += 1;
308 device_stats.total_allocated_bytes = device_stats
309 .total_allocated_bytes
310 .saturating_add(event.bytes);
311 }
312 }
313 MemoryAction::Free => {
314 if let Some(mut storage) = active.remove(&key) {
315 storage.tensor_ids.insert(event.tensor_id.clone());
316 lifetimes.push(storage.lifetime(Some(event.timestamp_ns)));
317 total_free_count += 1;
318 device_stats.free_count += 1;
319 }
320 }
321 }
322 }
323
324 let point = logical_timeline_point(timestamp_ns, &active);
325 for (device, stats) in &mut devices {
326 let live = point.live_bytes_by_device.get(device).copied().unwrap_or(0);
327 if stats.allocation_count > 0
328 && stats.peak_live_bytes.is_none_or(|current| live > current)
329 {
330 stats.peak_live_bytes = Some(live);
331 stats.peak_timestamp_ns = Some(timestamp_ns);
332 }
333 }
334 if total_allocation_count > 0
335 && peak
336 .as_ref()
337 .is_none_or(|current| point.live_bytes > current.live_bytes)
338 {
339 let mut live_allocations: Vec<_> = active
340 .values()
341 .map(|storage| storage.lifetime(None))
342 .collect();
343 sort_lifetimes(&mut live_allocations);
344 peak = Some(LogicalMemoryPeak {
345 timestamp_ns,
346 live_bytes: point.live_bytes,
347 live_bytes_by_device: point.live_bytes_by_device.clone(),
348 live_bytes_by_category: point.live_bytes_by_category.clone(),
349 live_allocations,
350 });
351 }
352 timeline.push(point);
353 cursor = end;
354 }
355
356 lifetimes.extend(active.values().map(|storage| storage.lifetime(None)));
357 sort_lifetimes(&mut lifetimes);
358 let by_device = devices
359 .into_iter()
360 .map(|(device, stats)| LogicalDeviceMemoryStats {
361 device,
362 storage_allocation_count: stats.allocation_count,
363 matched_storage_free_count: stats.free_count,
364 total_allocated_bytes: stats.total_allocated_bytes,
365 peak_live_bytes: stats.peak_live_bytes,
366 peak_timestamp_ns: stats.peak_timestamp_ns,
367 })
368 .collect();
369
370 Some(LogicalMemoryProfile {
371 storage_allocation_count: total_allocation_count,
372 matched_storage_free_count: total_free_count,
373 total_allocated_bytes,
374 peak,
375 timeline,
376 lifetimes,
377 by_device,
378 })
379}
380
381fn logical_timeline_point(
382 timestamp_ns: u64,
383 active: &BTreeMap<StorageKey, ActiveStorage>,
384) -> LogicalMemoryTimelinePoint {
385 let mut live_bytes = 0u64;
386 let mut live_bytes_by_device = BTreeMap::new();
387 let mut live_bytes_by_category = BTreeMap::new();
388 for storage in active.values() {
389 live_bytes = live_bytes.saturating_add(storage.bytes);
390 let device = live_bytes_by_device
391 .entry(storage.key.device.clone())
392 .or_insert(0u64);
393 *device = device.saturating_add(storage.bytes);
394 let category = live_bytes_by_category
395 .entry(category_key(storage.category))
396 .or_insert(0u64);
397 *category = category.saturating_add(storage.bytes);
398 }
399 LogicalMemoryTimelinePoint {
400 timestamp_ns,
401 live_bytes,
402 live_bytes_by_device,
403 live_bytes_by_category,
404 }
405}
406
407fn sort_lifetimes(lifetimes: &mut [LogicalStorageLifetime]) {
408 lifetimes.sort_by(|a, b| {
409 a.start_timestamp_ns
410 .cmp(&b.start_timestamp_ns)
411 .then_with(|| a.device.cmp(&b.device))
412 .then_with(|| a.storage_id.cmp(&b.storage_id))
413 });
414}
415
416fn analyze_physical_memory(doc: &TraceDocument) -> Option<PhysicalMemoryProfile> {
417 if doc.device_memory.is_empty() {
418 return None;
419 }
420
421 let mut samples: Vec<_> = doc.device_memory.iter().collect();
422 samples.sort_by(|a, b| {
423 a.timestamp_ns
424 .cmp(&b.timestamp_ns)
425 .then_with(|| a.device.cmp(&b.device))
426 });
427 let timeline = samples
428 .iter()
429 .map(|sample| PhysicalMemoryTimelinePoint {
430 timestamp_ns: sample.timestamp_ns,
431 device: sample.device.clone(),
432 used_bytes: sample.used_bytes,
433 free_bytes: sample.free_bytes,
434 reserved_bytes: sample.reserved_bytes,
435 capacity_bytes: sample.capacity_bytes,
436 })
437 .collect();
438
439 let mut by_device_samples: BTreeMap<&str, Vec<_>> = BTreeMap::new();
440 for sample in samples {
441 by_device_samples
442 .entry(sample.device.as_str())
443 .or_default()
444 .push(sample);
445 }
446 let by_device = by_device_samples
447 .into_iter()
448 .map(|(device, samples)| {
449 let peak_used = samples
450 .iter()
451 .filter_map(|sample| sample.used_bytes.map(|bytes| (bytes, sample.timestamp_ns)))
452 .max_by_key(|(bytes, timestamp)| (*bytes, std::cmp::Reverse(*timestamp)));
453 let minimum_free = samples
454 .iter()
455 .filter_map(|sample| sample.free_bytes.map(|bytes| (bytes, sample.timestamp_ns)))
456 .min_by_key(|(bytes, timestamp)| (*bytes, *timestamp));
457 let peak_reserved = samples
458 .iter()
459 .filter_map(|sample| {
460 sample
461 .reserved_bytes
462 .map(|bytes| (bytes, sample.timestamp_ns))
463 })
464 .max_by_key(|(bytes, timestamp)| (*bytes, std::cmp::Reverse(*timestamp)));
465 PhysicalDeviceMemoryStats {
466 device: device.to_string(),
467 sample_count: samples.len() as u64,
468 used_sample_count: samples
469 .iter()
470 .filter(|sample| sample.used_bytes.is_some())
471 .count() as u64,
472 free_sample_count: samples
473 .iter()
474 .filter(|sample| sample.free_bytes.is_some())
475 .count() as u64,
476 reserved_sample_count: samples
477 .iter()
478 .filter(|sample| sample.reserved_bytes.is_some())
479 .count() as u64,
480 capacity_sample_count: samples
481 .iter()
482 .filter(|sample| sample.capacity_bytes.is_some())
483 .count() as u64,
484 peak_used_bytes: peak_used.map(|(bytes, _)| bytes),
485 peak_used_timestamp_ns: peak_used.map(|(_, timestamp)| timestamp),
486 peak_reserved_bytes: peak_reserved.map(|(bytes, _)| bytes),
487 peak_reserved_timestamp_ns: peak_reserved.map(|(_, timestamp)| timestamp),
488 minimum_free_bytes: minimum_free.map(|(bytes, _)| bytes),
489 minimum_free_timestamp_ns: minimum_free.map(|(_, timestamp)| timestamp),
490 maximum_observed_capacity_bytes: samples
491 .iter()
492 .filter_map(|sample| sample.capacity_bytes)
493 .max(),
494 }
495 })
496 .collect();
497
498 Some(PhysicalMemoryProfile {
499 timeline,
500 by_device,
501 })
502}
503
504#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
506pub struct NodeMemoryMetrics {
507 #[serde(skip_serializing_if = "Option::is_none")]
509 pub direct_allocated_bytes: Option<u64>,
510 #[serde(skip_serializing_if = "Option::is_none")]
512 pub subtree_peak_live_bytes: Option<u64>,
513 #[serde(skip_serializing_if = "Option::is_none")]
515 pub subtree_residual_bytes: Option<u64>,
516 #[serde(skip_serializing_if = "Option::is_none")]
518 pub output_dense_bytes: Option<u64>,
519}
520
521pub fn node_memory_metrics(
523 doc: &TraceDocument,
524 op_node_ids: &HashMap<(String, usize), String>,
525) -> HashMap<String, NodeMemoryMetrics> {
526 let profile = analyze_memory(doc);
527 let lifetimes = profile
528 .logical
529 .as_ref()
530 .map(|logical| logical.lifetimes.as_slice());
531 let mut metrics: HashMap<String, NodeMemoryMetrics> = HashMap::new();
532
533 let mut per_span_index: HashMap<String, usize> = HashMap::new();
534 for op in &doc.ops {
535 let index = per_span_index.entry(op.span_id.clone()).or_insert(0);
536 let op_index = *index;
537 *index += 1;
538 let Some(bytes) = resolve_dense_tensor_bytes(op.output_dense_bytes, &op.shape, &op.dtype)
539 else {
540 continue;
541 };
542 let node_id = op_node_ids
543 .get(&(op.span_id.clone(), op_index))
544 .cloned()
545 .unwrap_or_else(|| format!("{}/op/{op_index}", op.span_id));
546 metrics.entry(node_id).or_default().output_dense_bytes = Some(bytes);
547 }
548
549 let Some(lifetimes) = lifetimes else {
550 return metrics;
551 };
552 let complete_coverage =
555 doc.run.capture_contract.logical_memory == crate::capability::CoverageLevel::Complete;
556
557 let mut op_ids_by_name: HashMap<(&str, &str), Vec<&str>> = HashMap::new();
560 let mut per_span_op_index: HashMap<&str, usize> = HashMap::new();
561 for op in &doc.ops {
562 let index = per_span_op_index.entry(op.span_id.as_str()).or_insert(0);
563 let op_index = *index;
564 *index += 1;
565 if let Some(id) = op_node_ids.get(&(op.span_id.clone(), op_index)) {
566 op_ids_by_name
567 .entry((op.span_id.as_str(), op.op_name.as_str()))
568 .or_default()
569 .push(id);
570 }
571 }
572 for lifetime in lifetimes {
573 let Some(op_name) = lifetime.op_name.as_deref() else {
574 continue;
575 };
576 let Some([op_id]) = op_ids_by_name
577 .get(&(lifetime.allocation_span_id.as_str(), op_name))
578 .map(Vec::as_slice)
579 else {
580 continue;
581 };
582 let entry = metrics.entry((*op_id).to_string()).or_default();
583 entry.direct_allocated_bytes = Some(
584 entry
585 .direct_allocated_bytes
586 .unwrap_or(0)
587 .saturating_add(lifetime.bytes),
588 );
589 }
590
591 let parent_by_id: HashMap<_, _> = doc
592 .spans
593 .iter()
594 .map(|span| (span.id.as_str(), span.parent_id.as_deref()))
595 .collect();
596 for span in &doc.spans {
597 let mut direct_observed = false;
598 let mut direct_allocated_bytes = 0u64;
599 for lifetime in lifetimes
600 .iter()
601 .filter(|lifetime| lifetime.allocation_span_id == span.id)
602 {
603 direct_observed = true;
604 direct_allocated_bytes = direct_allocated_bytes.saturating_add(lifetime.bytes);
605 }
606
607 let subtree_lifetimes: Vec<_> = lifetimes
608 .iter()
609 .filter(|lifetime| span_contains(&parent_by_id, &span.id, &lifetime.allocation_span_id))
610 .collect();
611 let entry = metrics.entry(span.id.clone()).or_default();
612 if complete_coverage || direct_observed {
613 entry.direct_allocated_bytes = Some(direct_allocated_bytes);
614 }
615
616 if span.closed && (complete_coverage || !subtree_lifetimes.is_empty()) {
617 let interval_end = span.start_ns.saturating_add(span.duration_ns);
618 let (peak, residual) =
619 clipped_lifetime_metrics(&subtree_lifetimes, span.start_ns, interval_end);
620 entry.subtree_peak_live_bytes = Some(peak);
621 entry.subtree_residual_bytes = Some(residual);
622 }
623 }
624
625 metrics
626}
627
628fn span_contains(
629 parent_by_id: &HashMap<&str, Option<&str>>,
630 ancestor_id: &str,
631 descendant_id: &str,
632) -> bool {
633 let mut current = Some(descendant_id);
634 let mut visited = BTreeSet::new();
635 while let Some(id) = current {
636 if id == ancestor_id {
637 return true;
638 }
639 if !visited.insert(id) {
640 return false;
641 }
642 current = parent_by_id.get(id).copied().flatten();
643 }
644 false
645}
646
647fn clipped_lifetime_metrics(
648 lifetimes: &[&LogicalStorageLifetime],
649 interval_start: u64,
650 interval_end: u64,
651) -> (u64, u64) {
652 if interval_end <= interval_start {
653 return (0, 0);
654 }
655
656 let mut live_at_start = 0u64;
657 let mut changes: BTreeMap<u64, (u64, u64)> = BTreeMap::new();
658 let mut residual = 0u64;
659 for lifetime in lifetimes {
660 let lifetime_end = lifetime.end_timestamp_ns.unwrap_or(u64::MAX);
661 if lifetime.start_timestamp_ns >= interval_end || lifetime_end <= interval_start {
662 continue;
663 }
664 if lifetime.start_timestamp_ns <= interval_start {
665 live_at_start = live_at_start.saturating_add(lifetime.bytes);
666 } else {
667 let change = changes.entry(lifetime.start_timestamp_ns).or_default();
668 change.1 = change.1.saturating_add(lifetime.bytes);
669 }
670 if lifetime_end > interval_start && lifetime_end < interval_end {
671 let change = changes.entry(lifetime_end).or_default();
672 change.0 = change.0.saturating_add(lifetime.bytes);
673 }
674 if lifetime.start_timestamp_ns < interval_end && lifetime_end > interval_end {
675 residual = residual.saturating_add(lifetime.bytes);
676 }
677 }
678
679 let mut live = live_at_start;
680 let mut peak = live;
681 for (_, (freed, allocated)) in changes {
682 live = live.saturating_sub(freed).saturating_add(allocated);
683 peak = peak.max(live);
684 }
685 (peak, residual)
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use crate::capability::CaptureContract;
692 use crate::trace::events::{DeviceMemoryEvent, MemoryEvent, TerminalEvent};
693 use crate::trace::schema::{RunOutcome, SpanKind, SpanRecord, TraceRunMeta, SCHEMA};
694
695 fn empty_doc() -> TraceDocument {
696 TraceDocument {
697 schema: SCHEMA.to_string(),
698 run: TraceRunMeta {
699 run_id: "r".into(),
700 correlation_id: "test/update-1".into(),
701 entrypoint: "test".into(),
702 phase: crate::phase::ExecutionPhase::Train,
703 timestamp: "2026-01-01T00:00:00Z".into(),
704 capture_step: 1,
705 warmup_steps: 0,
706 device: "cpu".into(),
707 measured_region_device_synchronized: false,
708 timing_mode: crate::trace::TimingMode::Host,
709 capture_contract: CaptureContract::default(),
710 comparison_identity: None,
711 tags: Default::default(),
712 candle_version: None,
713 },
714 spans: vec![],
715 ops: vec![],
716 tensors: vec![],
717 tensor_stats: vec![],
718 memory: vec![],
719 device_memory: vec![],
720 device_intervals: vec![],
721 gradients: vec![],
722 edges: vec![],
723 terminal: TerminalEvent {
724 outcome: RunOutcome::Complete,
725 timestamp_ns: 100,
726 reason: None,
727 },
728 }
729 }
730
731 fn memory(
732 timestamp_ns: u64,
733 device: &str,
734 storage_id: &str,
735 tensor_id: &str,
736 span_id: &str,
737 bytes: u64,
738 action: MemoryAction,
739 ) -> MemoryEvent {
740 MemoryEvent {
741 timestamp_ns,
742 storage_id: storage_id.into(),
743 tensor_id: tensor_id.into(),
744 span_id: span_id.into(),
745 op_name: None,
746 device: device.into(),
747 bytes,
748 action,
749 shape: vec![bytes as usize],
750 dtype: "u8".into(),
751 category: MemoryCategory::Activation,
752 }
753 }
754
755 #[test]
756 fn physical_only_samples_remain_a_separate_evidence_plane() {
757 let mut doc = empty_doc();
758 doc.device_memory = vec![
759 DeviceMemoryEvent {
760 timestamp_ns: 10,
761 device: "cuda:0".into(),
762 used_bytes: Some(100),
763 free_bytes: Some(900),
764 reserved_bytes: None,
765 capacity_bytes: Some(1_000),
766 },
767 DeviceMemoryEvent {
768 timestamp_ns: 20,
769 device: "cuda:0".into(),
770 used_bytes: Some(300),
771 free_bytes: Some(700),
772 reserved_bytes: Some(400),
773 capacity_bytes: Some(1_000),
774 },
775 ];
776
777 let profile = analyze_memory(&doc);
778 assert!(profile.logical.is_none());
779 let physical = profile.physical.unwrap();
780 assert_eq!(physical.timeline.len(), 2);
781 assert_eq!(physical.by_device[0].peak_used_bytes, Some(300));
782 assert_eq!(physical.by_device[0].peak_reserved_bytes, Some(400));
783 assert_eq!(physical.by_device[0].minimum_free_bytes, Some(700));
784 assert_eq!(
785 physical.by_device[0].maximum_observed_capacity_bytes,
786 Some(1_000)
787 );
788 }
789
790 #[test]
791 fn physical_unknowns_are_not_derived_from_other_measurements() {
792 let mut doc = empty_doc();
793 doc.device_memory = vec![DeviceMemoryEvent {
794 timestamp_ns: 10,
795 device: "cuda:0".into(),
796 used_bytes: None,
797 free_bytes: None,
798 reserved_bytes: Some(400),
799 capacity_bytes: None,
800 }];
801 let stats = &analyze_memory(&doc).physical.unwrap().by_device[0];
802 assert_eq!(stats.used_sample_count, 0);
803 assert_eq!(stats.peak_used_bytes, None);
804 assert_eq!(stats.minimum_free_bytes, None);
805 assert_eq!(stats.maximum_observed_capacity_bytes, None);
806 }
807
808 #[test]
809 fn storage_identity_is_scoped_by_device() {
810 let mut doc = empty_doc();
811 doc.memory = vec![
812 memory(10, "cpu", "shared", "cpu-t", "s", 10, MemoryAction::Alloc),
813 memory(
814 10,
815 "cuda:0",
816 "shared",
817 "gpu-t",
818 "s",
819 20,
820 MemoryAction::Alloc,
821 ),
822 ];
823
824 let logical = analyze_memory(&doc).logical.unwrap();
825 assert_eq!(logical.storage_allocation_count, 2);
826 assert_eq!(logical.peak.unwrap().live_bytes, 30);
827 assert_eq!(logical.by_device.len(), 2);
828 }
829
830 #[test]
831 fn aliases_share_one_storage_lifetime_and_preserve_tensor_ids() {
832 let mut doc = empty_doc();
833 doc.memory = vec![
834 memory(10, "cpu", "a", "base", "s", 64, MemoryAction::Alloc),
835 memory(20, "cpu", "a", "view", "s", 64, MemoryAction::Alloc),
836 memory(40, "cpu", "a", "view", "s", 64, MemoryAction::Free),
837 ];
838
839 let logical = analyze_memory(&doc).logical.unwrap();
840 assert_eq!(logical.storage_allocation_count, 1);
841 assert_eq!(logical.matched_storage_free_count, 1);
842 assert_eq!(logical.peak.unwrap().live_bytes, 64);
843 assert_eq!(logical.lifetimes[0].tensor_ids, vec!["base", "view"]);
844 assert_eq!(logical.lifetimes[0].end_timestamp_ns, Some(40));
845 }
846
847 #[test]
848 fn peak_is_the_simultaneous_sum_not_the_largest_allocation() {
849 let mut doc = empty_doc();
850 doc.memory = vec![
851 memory(10, "cpu", "a", "a", "s", 40, MemoryAction::Alloc),
852 memory(20, "cpu", "b", "b", "s", 70, MemoryAction::Alloc),
853 memory(30, "cpu", "a", "a", "s", 40, MemoryAction::Free),
854 ];
855
856 let logical = analyze_memory(&doc).logical.unwrap();
857 let peak = logical.peak.unwrap();
858 assert_eq!(peak.live_bytes, 110);
859 assert_eq!(peak.timestamp_ns, 20);
860 assert_eq!(logical.by_device[0].peak_live_bytes, Some(110));
861 }
862
863 #[test]
864 fn subtree_peak_and_residual_are_clipped_to_span_interval() {
865 let mut doc = empty_doc();
866 doc.spans = vec![
867 SpanRecord {
868 id: "root".into(),
869 parent_id: None,
870 name: "root".into(),
871 kind: SpanKind::Function,
872 measured: true,
873 start_ns: 10,
874 closed: true,
875 duration_ns: 30,
876 step: None,
877 },
878 SpanRecord {
879 id: "child".into(),
880 parent_id: Some("root".into()),
881 name: "child".into(),
882 kind: SpanKind::Function,
883 measured: false,
884 start_ns: 15,
885 closed: true,
886 duration_ns: 15,
887 step: None,
888 },
889 ];
890 doc.memory = vec![
891 memory(16, "cpu", "a", "a", "child", 40, MemoryAction::Alloc),
892 memory(20, "cpu", "b", "b", "child", 60, MemoryAction::Alloc),
893 memory(25, "cpu", "a", "a", "child", 40, MemoryAction::Free),
894 memory(45, "cpu", "b", "b", "child", 60, MemoryAction::Free),
895 ];
896
897 let metrics = node_memory_metrics(&doc, &HashMap::new());
898 let child = &metrics["child"];
899 assert_eq!(child.subtree_peak_live_bytes, Some(100));
900 assert_eq!(child.subtree_residual_bytes, Some(60));
901 let root = &metrics["root"];
902 assert_eq!(root.subtree_peak_live_bytes, Some(100));
903 assert_eq!(root.subtree_residual_bytes, Some(60));
904 }
905
906 #[test]
907 fn partial_coverage_leaves_unobserved_spans_unknown_not_zero() {
908 let span = |id: &str, parent: Option<&str>| SpanRecord {
909 id: id.into(),
910 parent_id: parent.map(str::to_owned),
911 name: id.into(),
912 kind: SpanKind::Function,
913 measured: parent.is_none(),
914 start_ns: 0,
915 closed: true,
916 duration_ns: 100,
917 step: None,
918 };
919 let mut doc = empty_doc();
920 doc.spans = vec![
921 span("root", None),
922 span("busy", Some("root")),
923 span("quiet", Some("root")),
924 ];
925 doc.memory = vec![
926 memory(10, "cpu", "a", "a", "busy", 40, MemoryAction::Alloc),
927 memory(20, "cpu", "a", "a", "busy", 40, MemoryAction::Free),
928 ];
929
930 let metrics = node_memory_metrics(&doc, &HashMap::new());
931 assert_eq!(metrics["busy"].direct_allocated_bytes, Some(40));
932 let quiet = &metrics["quiet"];
933 assert_eq!(quiet.direct_allocated_bytes, None);
934 assert_eq!(quiet.subtree_peak_live_bytes, None);
935 assert_eq!(quiet.subtree_residual_bytes, None);
936
937 doc.run.capture_contract.logical_memory = crate::capability::CoverageLevel::Complete;
938 let complete = node_memory_metrics(&doc, &HashMap::new());
939 assert_eq!(complete["quiet"].direct_allocated_bytes, Some(0));
940 assert_eq!(complete["quiet"].subtree_peak_live_bytes, Some(0));
941 }
942
943 #[test]
944 fn op_allocations_attribute_to_uniquely_named_op_nodes() {
945 let mut doc = empty_doc();
946 doc.spans = vec![SpanRecord {
947 id: "root".into(),
948 parent_id: None,
949 name: "root".into(),
950 kind: SpanKind::Function,
951 measured: true,
952 start_ns: 0,
953 closed: true,
954 duration_ns: 100,
955 step: None,
956 }];
957 let op = |name: &str, timestamp_ns: u64| crate::trace::OpEvent {
958 span_id: "root".into(),
959 op_name: name.into(),
960 inputs: vec![],
961 output: None,
962 shape: vec![1],
963 dtype: "f32".into(),
964 device: "cpu".into(),
965 duration_ns: 1,
966 timestamp_ns,
967 output_dense_bytes: Some(4),
968 input_dense_bytes: 0,
969 };
970 doc.ops = vec![op("matmul", 10), op("add", 20), op("add", 30)];
971 let mut alloc = memory(11, "cpu", "m", "m", "root", 64, MemoryAction::Alloc);
972 alloc.op_name = Some("matmul".into());
973 let mut ambiguous = memory(21, "cpu", "x", "x", "root", 32, MemoryAction::Alloc);
974 ambiguous.op_name = Some("add".into());
975 doc.memory = vec![alloc, ambiguous];
976
977 let op_node_ids = HashMap::from([
978 (("root".to_string(), 0), "root/op/0".to_string()),
979 (("root".to_string(), 1), "root/op/1".to_string()),
980 (("root".to_string(), 2), "root/op/2".to_string()),
981 ]);
982 let metrics = node_memory_metrics(&doc, &op_node_ids);
983 assert_eq!(metrics["root/op/0"].direct_allocated_bytes, Some(64));
985 assert_eq!(
987 metrics
988 .get("root/op/1")
989 .and_then(|entry| entry.direct_allocated_bytes),
990 None
991 );
992 assert_eq!(
993 metrics
994 .get("root/op/2")
995 .and_then(|entry| entry.direct_allocated_bytes),
996 None
997 );
998 assert_eq!(metrics["root"].direct_allocated_bytes, Some(96));
999 }
1000}