Skip to main content

candle_graph/trace/
memory.rs

1//! Logical storage-lifetime and physical device-memory analysis.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use serde::{Deserialize, Serialize};
6
7use super::document::TraceDocument;
8
9/// Semantic category attached to a logical storage lifetime.
10#[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/// Allocation or deallocation recorded during a probe run.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum MemoryAction {
25    Alloc,
26    Free,
27}
28
29/// Derive element size in bytes from a Candle-style dtype label (`f32`, `F32`, ...).
30pub 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
40/// Product of shape dimensions; an empty shape is a scalar with one element.
41pub fn elem_count(shape: &[usize]) -> u64 {
42    shape
43        .iter()
44        .fold(1u64, |acc, dim| acc.saturating_mul(*dim as u64))
45}
46
47/// Dense tensor footprint (`elem_count * dtype_bytes`), not backing-allocation size.
48pub 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
52/// Resolve an explicit dense footprint or derive it from shape and dtype.
53pub 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
61/// Map a training step and tensor flags to a semantic memory category.
62pub 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/// One backend storage lifetime. Tensor aliases are metadata on the storage, not allocations.
80#[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/// Simultaneous logical-memory state after all events at one timestamp.
97#[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/// The simultaneous global logical-memory peak.
106#[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/// Logical-memory statistics for one device.
116#[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    /// `None` means no allocation was observed for this device.
123    #[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/// Evidence derived only from `(device, storage_id)` allocation lifetimes.
130#[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    /// `None` means the stream contained no allocation from which to calculate a peak.
136    #[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/// One physical device or allocator checkpoint, retained without logical inference.
144#[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/// Observed physical-memory extrema for one device.
159#[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/// Physical memory evidence derived only from explicit device samples.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct PhysicalMemoryProfile {
186    pub timeline: Vec<PhysicalMemoryTimelinePoint>,
187    pub by_device: Vec<PhysicalDeviceMemoryStats>,
188}
189
190/// Memory evidence for a trace. An absent plane is represented by `None`, never zeroes.
191#[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
245/// Analyze the logical and physical memory planes independently.
246pub 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/// Per-node logical memory evidence. `None` means that metric was not observed.
505#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
506pub struct NodeMemoryMetrics {
507    /// Unique storage bytes allocated directly by this span.
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub direct_allocated_bytes: Option<u64>,
510    /// Simultaneous live bytes from this span and descendants, clipped to the span interval.
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub subtree_peak_live_bytes: Option<u64>,
513    /// Subtree storage still live at the end of the span.
514    #[serde(skip_serializing_if = "Option::is_none")]
515    pub subtree_residual_bytes: Option<u64>,
516    /// Dense output tensor footprint metadata for an operation node.
517    #[serde(skip_serializing_if = "Option::is_none")]
518    pub output_dense_bytes: Option<u64>,
519}
520
521/// Attribute logical memory evidence to span ids and `{span}/op/{n}` operation ids.
522pub 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    // Under partial or undeclared coverage an unobserved span stays `None`; a zero is only a
553    // known zero when the producer declared complete logical-memory coverage.
554    let complete_coverage =
555        doc.run.capture_contract.logical_memory == crate::capability::CoverageLevel::Complete;
556
557    // Attribute lifetimes to operation nodes when the recorded op name resolves to exactly one
558    // operation in the allocating span; ambiguous names keep span-level attribution only.
559    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        // Unique op name: attributed to the operation node.
984        assert_eq!(metrics["root/op/0"].direct_allocated_bytes, Some(64));
985        // Ambiguous op name within the span: span-level attribution only.
986        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}