1use 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#[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#[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#[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 pub busy_ns: u64,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ActivationOperation {
65 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 pub observed_host_duration_ns: u64,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub device_timings: Vec<ActivationDeviceTiming>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub dense_output_bytes: Option<u64>,
84 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub logical_storage_live_duration_ns: Option<u64>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub logical_byte_nanoseconds: Option<ByteNanoseconds>,
95 #[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#[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 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 ([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 (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 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 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 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 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}