Skip to main content

candle_graph/
activation.rs

1//! Capability-qualified rankings for activation-producing operations.
2//!
3//! This module never collapses host time, device time, dense tensor footprint, logical storage,
4//! or physical allocator state into one score. Each metric keeps its own qualification.
5
6use std::collections::{BTreeSet, HashMap, HashSet};
7
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9
10use crate::capability::{CapabilityLevel, CapabilityState, CoverageLevel, EvidenceCapabilities};
11use crate::timing::TimingProfile;
12use crate::trace::memory::{resolve_dense_tensor_bytes, MemoryCategory, MemoryProfile};
13use crate::trace::{SpanKind, TraceDocument};
14
15/// What directly classified one observed operation output as an activation.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ActivationClassificationSource {
19    TensorOutput,
20    LogicalStorage,
21}
22
23/// Exact storage space-time cost. It serializes as a decimal string because realistic
24/// byte-nanosecond products can exceed JSON's `u64` number range.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26pub struct ByteNanoseconds(pub u128);
27
28impl Serialize for ByteNanoseconds {
29    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
30    where
31        S: Serializer,
32    {
33        serializer.serialize_str(&self.0.to_string())
34    }
35}
36
37impl<'de> Deserialize<'de> for ByteNanoseconds {
38    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
39    where
40        D: Deserializer<'de>,
41    {
42        let value = String::deserialize(deserializer)?;
43        value
44            .parse::<u128>()
45            .map(Self)
46            .map_err(serde::de::Error::custom)
47    }
48}
49
50/// Device-busy evidence attributable to a dedicated operation span.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct ActivationDeviceTiming {
53    pub device: String,
54    pub clock_id: String,
55    pub backends: Vec<String>,
56    pub streams: Vec<String>,
57    pub interval_count: usize,
58    /// Union of intervals on this device clock; it is never added across clocks.
59    pub busy_ns: u64,
60}
61
62/// One observed activation-producing operation and its deliberately separate cost metrics.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ActivationOperation {
65    /// Stable graph identity within this trace: the dedicated operation span ID when available,
66    /// otherwise `{span_id}/op/{op_index}`.
67    pub id: String,
68    pub span_id: String,
69    pub op_index: usize,
70    pub name: String,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub output_tensor_id: Option<String>,
73    pub shape: Vec<usize>,
74    pub dtype: String,
75    pub device: String,
76    /// Direct `OpEvent` host duration. On asynchronous devices this is commonly launch time.
77    pub observed_host_duration_ns: u64,
78    /// Present only when this event is the sole operation in a dedicated `SpanKind::Op` span.
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub device_timings: Vec<ActivationDeviceTiming>,
81    /// Dense output footprint (`shape × dtype`), not backing storage or allocator usage.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub dense_output_bytes: Option<u64>,
84    /// Unique logical activation storage directly attributable to this operation.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub logical_allocated_bytes: Option<u64>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub logical_storage_count: Option<usize>,
89    /// Sum of measured-region-clipped live durations across attributed storages.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub logical_storage_live_duration_ns: Option<u64>,
92    /// Sum of `storage bytes × measured-region-clipped live duration`.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub logical_byte_nanoseconds: Option<ByteNanoseconds>,
95    /// Attributed logical storage still live at the measured region's end.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub logical_retained_bytes_at_measured_end: Option<u64>,
98    pub classification_sources: Vec<ActivationClassificationSource>,
99}
100
101/// Activation hotspot evidence for one profile run.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct ActivationProfile {
104    pub definition: String,
105    pub coverage: CapabilityState,
106    pub host_time: CapabilityState,
107    pub device_time: CapabilityState,
108    pub dense_output: CapabilityState,
109    pub logical_memory: CapabilityState,
110    /// Physical samples do not carry operation or activation identity.
111    pub physical_memory_attribution: CapabilityState,
112    pub unattributed_activation_storages: usize,
113    pub operations: Vec<ActivationOperation>,
114}
115
116impl ActivationProfile {
117    pub(crate) fn from_trace(
118        document: &TraceDocument,
119        timing: &TimingProfile,
120        memory: &MemoryProfile,
121    ) -> Self {
122        let measured_ids = measured_subtree_ids(document);
123        let measured_bounds = document
124            .spans
125            .iter()
126            .find(|span| span.measured && span.closed)
127            .map(|span| {
128                (
129                    span.start_ns,
130                    span.start_ns.saturating_add(span.duration_ns),
131                )
132            });
133        let activation_tensor_ids = document
134            .tensors
135            .iter()
136            .filter(|tensor| tensor.category == MemoryCategory::Activation)
137            .map(|tensor| tensor.tensor_id.as_str())
138            .collect::<HashSet<_>>();
139
140        let mut span_op_counts = HashMap::<&str, usize>::new();
141        let mut ops_by_span = HashMap::<&str, Vec<usize>>::new();
142        let mut op_indices = Vec::with_capacity(document.ops.len());
143        for (global_index, op) in document.ops.iter().enumerate() {
144            let count = span_op_counts.entry(op.span_id.as_str()).or_default();
145            op_indices.push(*count);
146            *count += 1;
147            ops_by_span
148                .entry(op.span_id.as_str())
149                .or_default()
150                .push(global_index);
151        }
152
153        let activation_lifetimes = memory
154            .logical
155            .as_ref()
156            .map(|logical| {
157                logical
158                    .lifetimes
159                    .iter()
160                    .filter(|lifetime| {
161                        lifetime.category == MemoryCategory::Activation
162                            && measured_ids.contains(&lifetime.allocation_span_id)
163                    })
164                    .collect::<Vec<_>>()
165            })
166            .unwrap_or_default();
167        let mut attributed_lifetimes = HashMap::<usize, Vec<usize>>::new();
168        let mut unresolved_spans = HashSet::<&str>::new();
169        let mut unattributed_activation_storages = 0usize;
170        for (lifetime_index, lifetime) in activation_lifetimes.iter().enumerate() {
171            let span_ops = ops_by_span
172                .get(lifetime.allocation_span_id.as_str())
173                .map(Vec::as_slice)
174                .unwrap_or_default();
175            let output_candidates = span_ops
176                .iter()
177                .copied()
178                .filter(|&index| {
179                    document.ops[index].output.as_ref().is_some_and(|output| {
180                        lifetime
181                            .tensor_ids
182                            .iter()
183                            .any(|tensor_id| tensor_id == output)
184                    })
185                })
186                .collect::<Vec<_>>();
187            let named_candidates = lifetime
188                .op_name
189                .as_deref()
190                .map(|op_name| {
191                    span_ops
192                        .iter()
193                        .copied()
194                        .filter(|&index| document.ops[index].op_name == op_name)
195                        .collect::<Vec<_>>()
196                })
197                .unwrap_or_default();
198            let resolved = match (output_candidates.as_slice(), named_candidates.as_slice()) {
199                // A unique output-tensor link is the strongest evidence; accept it unless a
200                // uniquely named candidate contradicts it.
201                ([output], named) if named.is_empty() || named.contains(output) => Some(*output),
202                (outputs, [named]) if outputs.is_empty() || outputs.contains(named) => Some(*named),
203                _ => None,
204            };
205            if let Some(op_index) = resolved {
206                attributed_lifetimes
207                    .entry(op_index)
208                    .or_default()
209                    .push(lifetime_index);
210            } else {
211                unresolved_spans.insert(lifetime.allocation_span_id.as_str());
212                unattributed_activation_storages += 1;
213            }
214        }
215
216        let span_kinds = document
217            .spans
218            .iter()
219            .map(|span| (span.id.as_str(), span.kind))
220            .collect::<HashMap<_, _>>();
221        let mut device_spans_by_id = HashMap::<&str, Vec<&crate::timing::DeviceSpanTiming>>::new();
222        for item in &timing.device_spans {
223            device_spans_by_id
224                .entry(item.span_id.as_str())
225                .or_default()
226                .push(item);
227        }
228        let logical_complete =
229            document.run.capture_contract.logical_memory == CoverageLevel::Complete;
230        let mut operations = Vec::new();
231        for (global_index, op) in document.ops.iter().enumerate() {
232            if !measured_ids.contains(&op.span_id) {
233                continue;
234            }
235            let tensor_classified = op
236                .output
237                .as_deref()
238                .is_some_and(|output| activation_tensor_ids.contains(output));
239            let lifetime_indices = attributed_lifetimes.get(&global_index);
240            if !tensor_classified && lifetime_indices.is_none() {
241                continue;
242            }
243
244            let mut sources = BTreeSet::new();
245            if tensor_classified {
246                sources.insert(ActivationClassificationSource::TensorOutput);
247            }
248            if lifetime_indices.is_some() {
249                sources.insert(ActivationClassificationSource::LogicalStorage);
250            }
251            let (
252                logical_allocated_bytes,
253                logical_storage_count,
254                logical_storage_live_duration_ns,
255                logical_byte_nanoseconds,
256                logical_retained_bytes_at_measured_end,
257            ) = match (lifetime_indices, measured_bounds) {
258                (Some(indices), Some((measured_start, measured_end))) => {
259                    let mut allocated_bytes = 0u64;
260                    let mut live_duration_ns = 0u64;
261                    let mut byte_nanoseconds = 0u128;
262                    let mut retained_bytes = 0u64;
263                    for index in indices {
264                        let lifetime = activation_lifetimes[*index];
265                        allocated_bytes = allocated_bytes.saturating_add(lifetime.bytes);
266                        let lifetime_end = lifetime.end_timestamp_ns.unwrap_or(measured_end);
267                        let clipped_start = lifetime.start_timestamp_ns.max(measured_start);
268                        let clipped_end = lifetime_end.min(measured_end);
269                        let duration_ns = clipped_end.saturating_sub(clipped_start);
270                        live_duration_ns = live_duration_ns.saturating_add(duration_ns);
271                        byte_nanoseconds = byte_nanoseconds.saturating_add(
272                            u128::from(lifetime.bytes).saturating_mul(u128::from(duration_ns)),
273                        );
274                        if lifetime.start_timestamp_ns < measured_end
275                            && lifetime
276                                .end_timestamp_ns
277                                .is_none_or(|end| end > measured_end)
278                        {
279                            retained_bytes = retained_bytes.saturating_add(lifetime.bytes);
280                        }
281                    }
282                    (
283                        Some(allocated_bytes),
284                        Some(indices.len()),
285                        Some(live_duration_ns),
286                        Some(ByteNanoseconds(byte_nanoseconds)),
287                        Some(retained_bytes),
288                    )
289                }
290                (Some(indices), None) => (
291                    Some(indices.iter().fold(0u64, |total, index| {
292                        total.saturating_add(activation_lifetimes[*index].bytes)
293                    })),
294                    Some(indices.len()),
295                    None,
296                    None,
297                    None,
298                ),
299                // A confident zero requires complete logical coverage and no unresolved
300                // activation storage in this operation's span; otherwise the storage cost is
301                // unknown, never silently zero.
302                (None, _)
303                    if logical_complete && !unresolved_spans.contains(op.span_id.as_str()) =>
304                {
305                    (Some(0), Some(0), Some(0), Some(ByteNanoseconds(0)), Some(0))
306                }
307                (None, _) => (None, None, None, None, None),
308            };
309            let dedicated_op_span = span_kinds.get(op.span_id.as_str()) == Some(&SpanKind::Op)
310                && span_op_counts.get(op.span_id.as_str()) == Some(&1);
311            let device_timings = if dedicated_op_span {
312                device_spans_by_id
313                    .get(op.span_id.as_str())
314                    .map(Vec::as_slice)
315                    .unwrap_or_default()
316                    .iter()
317                    .map(|item| ActivationDeviceTiming {
318                        device: item.device.clone(),
319                        clock_id: item.clock_id.clone(),
320                        backends: item.backends.clone(),
321                        streams: item.streams.clone(),
322                        interval_count: item.interval_count,
323                        busy_ns: item.busy_ns,
324                    })
325                    .collect()
326            } else {
327                Vec::new()
328            };
329            let op_index = op_indices[global_index];
330            operations.push(ActivationOperation {
331                id: if dedicated_op_span {
332                    op.span_id.clone()
333                } else {
334                    format!("{}/op/{op_index}", op.span_id)
335                },
336                span_id: op.span_id.clone(),
337                op_index,
338                name: op.op_name.clone(),
339                output_tensor_id: op.output.clone(),
340                shape: op.shape.clone(),
341                dtype: op.dtype.clone(),
342                device: op.device.clone(),
343                observed_host_duration_ns: op.duration_ns,
344                device_timings,
345                dense_output_bytes: resolve_dense_tensor_bytes(
346                    op.output_dense_bytes,
347                    &op.shape,
348                    &op.dtype,
349                ),
350                logical_allocated_bytes,
351                logical_storage_count,
352                logical_storage_live_duration_ns,
353                logical_byte_nanoseconds,
354                logical_retained_bytes_at_measured_end,
355                classification_sources: sources.into_iter().collect(),
356            });
357        }
358
359        Self {
360            definition: "An activation-producing operation is an in-measured-subtree OpEvent whose output is category-linked as activation tensor metadata or uniquely attributable activation logical storage. Host time, per-clock device time, dense footprint, allocated logical bytes, and logical byte-nanoseconds remain separate evidence planes.".into(),
361            coverage: CapabilityState::default(),
362            host_time: CapabilityState::default(),
363            device_time: CapabilityState::default(),
364            dense_output: CapabilityState::default(),
365            logical_memory: CapabilityState::default(),
366            physical_memory_attribution: CapabilityState::unavailable(
367                "physical device-memory samples have no operation or activation identity",
368            ),
369            unattributed_activation_storages,
370            operations,
371        }
372    }
373
374    pub(crate) fn qualify(&mut self, capabilities: &EvidenceCapabilities) {
375        self.coverage = capabilities.activation_coverage.clone();
376        self.host_time = combine_capabilities(
377            "activation host-time ranking",
378            &[&self.coverage, &capabilities.nested_host_time],
379        );
380
381        self.dense_output = self.coverage.clone();
382        let missing_dense = self
383            .operations
384            .iter()
385            .filter(|operation| operation.dense_output_bytes.is_none())
386            .count();
387        if missing_dense > 0 {
388            downgrade(
389                &mut self.dense_output,
390                format!(
391                    "{missing_dense} activation operations have unknown dense output footprint"
392                ),
393            );
394        }
395
396        self.logical_memory = combine_capabilities(
397            "activation logical-memory ranking",
398            &[&self.coverage, &capabilities.logical_memory_coverage],
399        );
400        if self.unattributed_activation_storages > 0 {
401            downgrade(
402                &mut self.logical_memory,
403                format!(
404                    "{} activation storages could not be attributed uniquely to an operation",
405                    self.unattributed_activation_storages
406                ),
407            );
408        }
409        let missing_logical = self
410            .operations
411            .iter()
412            .filter(|operation| operation.logical_allocated_bytes.is_none())
413            .count();
414        if missing_logical > 0 {
415            downgrade(
416                &mut self.logical_memory,
417                format!(
418                    "{missing_logical} activation operations have unknown logical allocation bytes"
419                ),
420            );
421        }
422
423        let mut device_base = combine_capabilities(
424            "activation device-time ranking",
425            &[&self.coverage, &capabilities.nested_device_time],
426        );
427        if matches!(device_base.level, CapabilityLevel::Invalid) {
428            self.device_time = device_base;
429            return;
430        }
431        let device_operations = self
432            .operations
433            .iter()
434            .filter(|operation| !is_host_device(&operation.device))
435            .collect::<Vec<_>>();
436        if device_operations.is_empty() {
437            self.device_time = CapabilityState::unavailable(
438                "no activation-producing operations were observed on a non-host device",
439            );
440            return;
441        }
442        let attributed = device_operations
443            .iter()
444            .filter(|operation| !operation.device_timings.is_empty())
445            .count();
446        if attributed == 0 {
447            self.device_time = CapabilityState::unavailable(
448                "no activation operation has device timing on a dedicated operation span",
449            );
450            return;
451        }
452        if attributed < device_operations.len() {
453            downgrade(
454                &mut device_base,
455                format!(
456                    "device timing is attributable for {attributed} of {} non-host activation operations; use one OpEvent per SpanKind::Op span",
457                    device_operations.len()
458                ),
459            );
460        }
461        self.device_time = device_base;
462    }
463
464    /// Largest operation by `key`. Ties resolve to the lexicographically smallest `id`, so every
465    /// headline agrees with the query rankings derived from [`ActivationProfile::ranked_by`].
466    pub fn top_by<K: Ord>(
467        &self,
468        key: impl Fn(&ActivationOperation) -> Option<K>,
469    ) -> Option<&ActivationOperation> {
470        self.ranked_by(key).into_iter().next()
471    }
472
473    /// Operations carrying the metric, ranked descending by `key`; ties resolve to the
474    /// lexicographically smallest `id`. Operations without the metric are excluded.
475    pub fn ranked_by<K: Ord>(
476        &self,
477        key: impl Fn(&ActivationOperation) -> Option<K>,
478    ) -> Vec<&ActivationOperation> {
479        let mut rows = self
480            .operations
481            .iter()
482            .filter_map(|operation| key(operation).map(|value| (value, operation)))
483            .collect::<Vec<_>>();
484        rows.sort_by(|(left_value, left), (right_value, right)| {
485            right_value
486                .cmp(left_value)
487                .then_with(|| left.id.cmp(&right.id))
488        });
489        rows.into_iter().map(|(_, operation)| operation).collect()
490    }
491}
492
493fn measured_subtree_ids(document: &TraceDocument) -> HashSet<String> {
494    let mut children = HashMap::<&str, Vec<&str>>::new();
495    for span in &document.spans {
496        if let Some(parent) = span.parent_id.as_deref() {
497            children.entry(parent).or_default().push(span.id.as_str());
498        }
499    }
500    let mut ids = HashSet::new();
501    let mut queue = document
502        .spans
503        .iter()
504        .filter(|span| span.measured)
505        .map(|span| span.id.as_str())
506        .collect::<Vec<_>>();
507    while let Some(id) = queue.pop() {
508        if ids.insert(id.to_owned()) {
509            if let Some(child_ids) = children.get(id) {
510                queue.extend(child_ids.iter().copied());
511            }
512        }
513    }
514    ids
515}
516
517fn is_host_device(device: &str) -> bool {
518    device.eq_ignore_ascii_case("cpu")
519        || device
520            .get(..4)
521            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cpu:"))
522}
523
524fn combine_capabilities(label: &str, states: &[&CapabilityState]) -> CapabilityState {
525    if let Some(invalid) = states
526        .iter()
527        .find(|state| state.level == CapabilityLevel::Invalid)
528    {
529        return CapabilityState::invalid(
530            invalid.source.clone(),
531            format!("{label} is invalid: {}", invalid.reason),
532        );
533    }
534    if let Some(unavailable) = states
535        .iter()
536        .find(|state| state.level == CapabilityLevel::Unavailable)
537    {
538        return CapabilityState::unavailable(format!(
539            "{label} is unavailable: {}",
540            unavailable.reason
541        ));
542    }
543    let coverage = if states
544        .iter()
545        .all(|state| state.level == CapabilityLevel::Complete)
546    {
547        CoverageLevel::Complete
548    } else {
549        CoverageLevel::Partial
550    };
551    CapabilityState::from_coverage(
552        coverage,
553        states
554            .iter()
555            .map(|state| state.source.as_str())
556            .collect::<Vec<_>>()
557            .join(" + "),
558        format!("{label} keeps its required evidence planes separate"),
559    )
560}
561
562fn downgrade(state: &mut CapabilityState, reason: String) {
563    match state.level {
564        CapabilityLevel::Complete => state.level = CapabilityLevel::Partial,
565        CapabilityLevel::Invalid | CapabilityLevel::Unavailable | CapabilityLevel::Partial => {}
566    }
567    state.reason = format!("{}; {reason}", state.reason);
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::capability::{CaptureContract, MeasurementScope};
574    use crate::evidence::EvidencePacket;
575    use crate::nsight::NsightEvidence;
576    use crate::phase::ExecutionPhase;
577    use crate::trace::{
578        DeviceIntervalEvent, MemoryAction, MemoryEvent, OpEvent, RunOutcome, SpanRecord,
579        TensorEvent, TerminalEvent, TimingMode, TraceRunMeta, SCHEMA,
580    };
581
582    fn activation_document() -> TraceDocument {
583        let span = |id: &str, parent_id: Option<&str>, name: &str, start_ns, duration_ns, kind| {
584            SpanRecord {
585                id: id.into(),
586                parent_id: parent_id.map(str::to_owned),
587                name: name.into(),
588                kind,
589                measured: parent_id.is_none(),
590                start_ns,
591                closed: true,
592                duration_ns,
593                step: None,
594            }
595        };
596        let op = |span_id: &str,
597                  name: &str,
598                  output: &str,
599                  elements: usize,
600                  duration_ns: u64,
601                  timestamp_ns: u64| OpEvent {
602            span_id: span_id.into(),
603            op_name: name.into(),
604            inputs: Vec::new(),
605            output: Some(output.into()),
606            shape: vec![elements],
607            dtype: "f32".into(),
608            device: "cuda:0".into(),
609            duration_ns,
610            timestamp_ns,
611            output_dense_bytes: None,
612            input_dense_bytes: 0,
613        };
614        let tensor = |span_id: &str, tensor_id: &str, elements: usize| TensorEvent {
615            span_id: span_id.into(),
616            tensor_id: tensor_id.into(),
617            label: None,
618            shape: vec![elements],
619            dtype: "f32".into(),
620            device: "cuda:0".into(),
621            requires_grad: false,
622            dense_bytes: None,
623            category: MemoryCategory::Activation,
624        };
625        let memory =
626            |timestamp_ns, span_id: &str, op_name: &str, tensor_id: &str, bytes, action| {
627                MemoryEvent {
628                    timestamp_ns,
629                    storage_id: format!("storage-{tensor_id}"),
630                    tensor_id: tensor_id.into(),
631                    span_id: span_id.into(),
632                    op_name: Some(op_name.into()),
633                    device: "cuda:0".into(),
634                    bytes,
635                    action,
636                    shape: vec![bytes as usize],
637                    dtype: "u8".into(),
638                    category: MemoryCategory::Activation,
639                }
640            };
641
642        TraceDocument {
643            schema: SCHEMA.into(),
644            run: TraceRunMeta {
645                run_id: "activation-hotspots".into(),
646                correlation_id: "infer/activation-hotspots".into(),
647                entrypoint: "model::forward".into(),
648                phase: ExecutionPhase::Infer,
649                timestamp: "2026-08-30T00:00:00Z".into(),
650                capture_step: 2,
651                warmup_steps: 1,
652                device: "cuda:0".into(),
653                measured_region_device_synchronized: true,
654                timing_mode: TimingMode::Host,
655                capture_contract: CaptureContract {
656                    measurement_scope: MeasurementScope::ProfiledWork,
657                    operations: CoverageLevel::Complete,
658                    activations: CoverageLevel::Complete,
659                    tensors: CoverageLevel::Partial,
660                    logical_memory: CoverageLevel::Complete,
661                    device_timing: CoverageLevel::Complete,
662                    ..CaptureContract::default()
663                },
664                comparison_identity: None,
665                tags: Default::default(),
666                candle_version: None,
667            },
668            spans: vec![
669                span("root", None, "forward", 0, 1_000, SpanKind::Function),
670                span("a", Some("root"), "wide", 10, 400, SpanKind::Op),
671                span("b", Some("root"), "slow-gpu", 500, 400, SpanKind::Op),
672            ],
673            ops: vec![
674                op("a", "wide", "ta", 100, 300, 10),
675                op("b", "slow-gpu", "tb", 200, 100, 500),
676            ],
677            tensors: vec![tensor("a", "ta", 100), tensor("b", "tb", 200)],
678            tensor_stats: Vec::new(),
679            memory: vec![
680                memory(20, "a", "wide", "ta", 1_000, MemoryAction::Alloc),
681                memory(400, "a", "wide", "ta", 1_000, MemoryAction::Free),
682                memory(520, "b", "slow-gpu", "tb", 500, MemoryAction::Alloc),
683                memory(800, "b", "slow-gpu", "tb", 500, MemoryAction::Free),
684            ],
685            device_memory: Vec::new(),
686            device_intervals: vec![
687                DeviceIntervalEvent {
688                    span_id: "a".into(),
689                    device: "cuda:0".into(),
690                    stream_id: "0".into(),
691                    clock_id: "cuda-event".into(),
692                    backend: "cuda-event".into(),
693                    start_ns: 0,
694                    duration_ns: 50,
695                },
696                DeviceIntervalEvent {
697                    span_id: "b".into(),
698                    device: "cuda:0".into(),
699                    stream_id: "0".into(),
700                    clock_id: "cuda-event".into(),
701                    backend: "cuda-event".into(),
702                    start_ns: 100,
703                    duration_ns: 200,
704                },
705            ],
706            gradients: Vec::new(),
707            edges: Vec::new(),
708            terminal: TerminalEvent {
709                outcome: RunOutcome::Complete,
710                timestamp_ns: 1_000,
711                reason: None,
712            },
713        }
714    }
715
716    #[test]
717    fn activation_metrics_remain_separate_and_capability_qualified() {
718        let packet = EvidencePacket::from_document(
719            activation_document(),
720            NsightEvidence::unavailable("not captured"),
721        )
722        .unwrap();
723        let profile = &packet.activations;
724        assert_eq!(profile.coverage.level, CapabilityLevel::Complete);
725        assert_eq!(profile.host_time.level, CapabilityLevel::Complete);
726        assert_eq!(profile.device_time.level, CapabilityLevel::Complete);
727        assert_eq!(profile.logical_memory.level, CapabilityLevel::Complete);
728        assert_eq!(profile.operations.len(), 2);
729
730        let wide = profile
731            .operations
732            .iter()
733            .find(|operation| operation.name == "wide")
734            .unwrap();
735        assert_eq!(wide.observed_host_duration_ns, 300);
736        assert_eq!(wide.device_timings[0].busy_ns, 50);
737        assert_eq!(wide.dense_output_bytes, Some(400));
738        assert_eq!(wide.logical_allocated_bytes, Some(1_000));
739        assert_eq!(wide.logical_storage_live_duration_ns, Some(380));
740        assert_eq!(
741            wide.logical_byte_nanoseconds,
742            Some(ByteNanoseconds(380_000))
743        );
744
745        let slow_gpu = profile
746            .operations
747            .iter()
748            .find(|operation| operation.name == "slow-gpu")
749            .unwrap();
750        assert_eq!(slow_gpu.observed_host_duration_ns, 100);
751        assert_eq!(slow_gpu.device_timings[0].busy_ns, 200);
752        assert_eq!(slow_gpu.dense_output_bytes, Some(800));
753        assert_eq!(slow_gpu.logical_allocated_bytes, Some(500));
754        assert_eq!(
755            slow_gpu.logical_byte_nanoseconds,
756            Some(ByteNanoseconds(140_000))
757        );
758        assert_eq!(
759            profile.physical_memory_attribution.level,
760            CapabilityLevel::Unavailable
761        );
762    }
763
764    fn push_duplicate_named_ops(document: &mut TraceDocument, storage_tensor_id: &str) {
765        document.spans.push(SpanRecord {
766            id: "c".into(),
767            parent_id: Some("root".into()),
768            name: "dup".into(),
769            kind: SpanKind::Function,
770            measured: false,
771            start_ns: 900,
772            closed: true,
773            duration_ns: 80,
774            step: None,
775        });
776        for (output, timestamp_ns) in [("tc1", 900), ("tc2", 910)] {
777            document.ops.push(OpEvent {
778                span_id: "c".into(),
779                op_name: "dup".into(),
780                inputs: Vec::new(),
781                output: Some(output.into()),
782                shape: vec![10],
783                dtype: "f32".into(),
784                device: "cuda:0".into(),
785                duration_ns: 5,
786                timestamp_ns,
787                output_dense_bytes: None,
788                input_dense_bytes: 0,
789            });
790            document.tensors.push(TensorEvent {
791                span_id: "c".into(),
792                tensor_id: output.into(),
793                label: None,
794                shape: vec![10],
795                dtype: "f32".into(),
796                device: "cuda:0".into(),
797                requires_grad: false,
798                dense_bytes: None,
799                category: MemoryCategory::Activation,
800            });
801        }
802        for (timestamp_ns, action) in [(905, MemoryAction::Alloc), (940, MemoryAction::Free)] {
803            document.memory.push(MemoryEvent {
804                timestamp_ns,
805                storage_id: format!("storage-{storage_tensor_id}"),
806                tensor_id: storage_tensor_id.into(),
807                span_id: "c".into(),
808                op_name: Some("dup".into()),
809                device: "cuda:0".into(),
810                bytes: 2_000,
811                action,
812                shape: vec![2_000],
813                dtype: "u8".into(),
814                category: MemoryCategory::Activation,
815            });
816        }
817    }
818
819    #[test]
820    fn unique_output_link_attributes_storage_despite_ambiguous_op_names() {
821        let mut document = activation_document();
822        // The storage's tensor id uniquely identifies the first `dup` op even though the
823        // recorded op name matches both ops in the span.
824        push_duplicate_named_ops(&mut document, "tc1");
825        let packet =
826            EvidencePacket::from_document(document, NsightEvidence::unavailable("not captured"))
827                .unwrap();
828        assert_eq!(packet.activations.unattributed_activation_storages, 0);
829        let first_dup = packet
830            .activations
831            .operations
832            .iter()
833            .find(|operation| operation.output_tensor_id.as_deref() == Some("tc1"))
834            .unwrap();
835        assert_eq!(first_dup.logical_allocated_bytes, Some(2_000));
836    }
837
838    #[test]
839    fn ambiguous_activation_storage_never_reports_a_confident_zero() {
840        let mut document = activation_document();
841        // The storage links to neither op output and the op name matches both ops, so it is
842        // unresolvable; the ops in that span must report unknown, not zero.
843        push_duplicate_named_ops(&mut document, "tc-other");
844        let packet =
845            EvidencePacket::from_document(document, NsightEvidence::unavailable("not captured"))
846                .unwrap();
847        assert_eq!(packet.activations.unattributed_activation_storages, 1);
848        for output in ["tc1", "tc2"] {
849            let operation = packet
850                .activations
851                .operations
852                .iter()
853                .find(|operation| operation.output_tensor_id.as_deref() == Some(output))
854                .unwrap();
855            assert!(operation.logical_allocated_bytes.is_none());
856            assert!(operation.logical_byte_nanoseconds.is_none());
857        }
858        assert_eq!(
859            packet.activations.logical_memory.level,
860            CapabilityLevel::Partial
861        );
862    }
863
864    #[test]
865    fn an_unclosed_measured_span_reports_unknown_not_zero_time_metrics() {
866        let mut document = activation_document();
867        document.spans[0].closed = false;
868        let packet =
869            EvidencePacket::from_document(document, NsightEvidence::unavailable("not captured"))
870                .unwrap();
871        let wide = packet
872            .activations
873            .operations
874            .iter()
875            .find(|operation| operation.name == "wide")
876            .unwrap();
877        assert_eq!(wide.logical_allocated_bytes, Some(1_000));
878        assert!(wide.logical_storage_live_duration_ns.is_none());
879        assert!(wide.logical_byte_nanoseconds.is_none());
880        assert!(wide.logical_retained_bytes_at_measured_end.is_none());
881    }
882
883    #[test]
884    fn complete_activation_contract_requires_operations_and_a_category_plane() {
885        let missing_operations = CaptureContract {
886            activations: CoverageLevel::Complete,
887            tensors: CoverageLevel::Partial,
888            ..CaptureContract::default()
889        };
890        assert!(missing_operations.validate().is_err());
891
892        let missing_categories = CaptureContract {
893            operations: CoverageLevel::Complete,
894            activations: CoverageLevel::Complete,
895            ..CaptureContract::default()
896        };
897        assert!(missing_categories.validate().is_err());
898    }
899
900    #[test]
901    fn activation_profile_excludes_operations_outside_the_measured_subtree() {
902        let mut document = activation_document();
903        document.spans[0].measured = false;
904        document.spans[0].parent_id = Some("session".into());
905        document.spans.insert(
906            0,
907            SpanRecord {
908                id: "session".into(),
909                parent_id: None,
910                name: "session".into(),
911                kind: SpanKind::Function,
912                measured: false,
913                start_ns: 0,
914                closed: true,
915                duration_ns: 2_000,
916                step: None,
917            },
918        );
919        document.spans[1].measured = true;
920        document.spans.push(SpanRecord {
921            id: "setup".into(),
922            parent_id: Some("session".into()),
923            name: "setup-activation".into(),
924            kind: SpanKind::Op,
925            measured: false,
926            start_ns: 1_200,
927            closed: true,
928            duration_ns: 100,
929            step: None,
930        });
931        document.ops.push(OpEvent {
932            span_id: "setup".into(),
933            op_name: "setup-activation".into(),
934            inputs: Vec::new(),
935            output: Some("setup-output".into()),
936            shape: vec![10_000],
937            dtype: "f32".into(),
938            device: "cuda:0".into(),
939            duration_ns: 90,
940            timestamp_ns: 1_200,
941            output_dense_bytes: None,
942            input_dense_bytes: 0,
943        });
944        document.tensors.push(TensorEvent {
945            span_id: "setup".into(),
946            tensor_id: "setup-output".into(),
947            label: None,
948            shape: vec![10_000],
949            dtype: "f32".into(),
950            device: "cuda:0".into(),
951            requires_grad: false,
952            dense_bytes: None,
953            category: MemoryCategory::Activation,
954        });
955        document.terminal.timestamp_ns = 2_000;
956
957        let packet =
958            EvidencePacket::from_document(document, NsightEvidence::unavailable("not captured"))
959                .unwrap();
960        assert_eq!(packet.activations.operations.len(), 2);
961        assert!(packet
962            .activations
963            .operations
964            .iter()
965            .all(|operation| operation.name != "setup-activation"));
966    }
967}