1use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::fs;
5use std::io::Read;
6use std::path::{Component, Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11const SUMMARY_DISPLAY_LIMIT: usize = 100;
12const TIMELINE_DISPLAY_LIMIT: usize = 500;
13const CAPTURE_MANIFEST: &str = "capture-manifest.json";
14pub const CAPTURE_MANIFEST_SCHEMA: &str = "candle-graph/nsight-capture/1";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum GpuEvidenceStatus {
19 Available,
20 Unavailable,
21 Failed,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ProvenanceBindingState {
27 Bound,
28 Partial,
29 Mismatch,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct NsightArtifact {
34 pub path: String,
35 pub size_bytes: u64,
36 pub sha256: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct CaptureManifest {
41 pub schema: String,
42 pub run: CaptureRun,
43 pub correlation: CaptureCorrelation,
44 pub tool: CaptureTool,
45 pub commands: Vec<String>,
46 pub hardware: CaptureHardware,
47 pub source_revisions: BTreeMap<String, String>,
48 pub required_semantic_labels: Vec<String>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub gpu_expected_semantic_labels: Vec<String>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub cpu_only_semantic_labels: Vec<String>,
56 pub artifacts: Vec<ManifestArtifact>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct CaptureRun {
61 pub id: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub started_at: Option<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct CaptureCorrelation {
68 pub id: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct CaptureTool {
73 pub name: String,
74 pub version: String,
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct CaptureHardware {
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub host: Option<String>,
81 #[serde(default)]
82 pub devices: Vec<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct ManifestArtifact {
87 pub path: String,
88 pub size_bytes: u64,
89 pub sha256: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct NsightProvenance {
94 pub binding: ProvenanceBindingState,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub manifest: Option<CaptureManifest>,
97 #[serde(default)]
98 pub diagnostics: Vec<String>,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
102pub struct NsightSummaryRow {
103 pub name: String,
104 pub total_ns: u64,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub count: Option<u64>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub average_ns: Option<u64>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub minimum_ns: Option<u64>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub maximum_ns: Option<u64>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub category: Option<String>,
115}
116
117#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
118pub struct NsightTimelineRow {
119 pub name: String,
120 pub kind: String,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub device: Option<String>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub context: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub stream: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub correlation_id: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub semantic_key: Option<String>,
131 pub start_ns: u64,
132 pub duration_ns: u64,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub projected_start_ns: Option<u64>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub projected_duration_ns: Option<u64>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub gpu_operations: Option<u64>,
139}
140
141#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
142pub struct NsightCoverage {
143 pub kernel_summary: bool,
144 pub runtime_summary: bool,
145 pub memory_summary: bool,
146 pub nvtx_projection: bool,
147 pub gpu_timeline: bool,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct CorrelationDuplicate {
152 pub semantic_key: String,
153 pub occurrences: usize,
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct NsightCorrelationLedger {
158 pub expected: Vec<String>,
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub cpu_only: Vec<String>,
161 pub observed: Vec<String>,
162 pub matched: Vec<String>,
163 pub missing_expected: Vec<String>,
164 pub unexpected_observed: Vec<String>,
165 #[serde(default, skip_serializing_if = "Vec::is_empty")]
166 pub unexpected_cpu_only: Vec<String>,
167 pub duplicates: Vec<CorrelationDuplicate>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct NsightCorrelation {
172 pub mode: String,
173 pub clock_aligned: bool,
174 pub complete: bool,
175 pub ledger: NsightCorrelationLedger,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub reason: Option<String>,
178}
179
180#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
181pub struct ReportLimit {
182 pub total_rows: usize,
183 pub displayed_rows: usize,
184 pub truncated: bool,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct PhaseGpuAttribution {
189 pub semantic_key: String,
190 pub clock_aligned: bool,
193 pub projected_start_ns: u64,
194 pub projected_duration_ns: u64,
195 pub gpu_busy_ns: u64,
196 pub gpu_operation_count: usize,
197 pub join_keys: Vec<String>,
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct NsightEvidence {
202 pub status: GpuEvidenceStatus,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub reason: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub raw_report: Option<NsightArtifact>,
207 #[serde(default)]
208 pub source_csv: Vec<NsightArtifact>,
209 pub provenance: NsightProvenance,
210 pub coverage: NsightCoverage,
211 pub correlation: NsightCorrelation,
212 #[serde(default)]
213 pub diagnostics: Vec<String>,
214 #[serde(default)]
215 pub limits: BTreeMap<String, ReportLimit>,
216 #[serde(default)]
217 pub kernels: Vec<NsightSummaryRow>,
218 #[serde(default)]
219 pub runtime_calls: Vec<NsightSummaryRow>,
220 #[serde(default)]
221 pub memory_operations: Vec<NsightSummaryRow>,
222 #[serde(default)]
223 pub nvtx_ranges: Vec<NsightTimelineRow>,
224 #[serde(default)]
225 pub gpu_timeline: Vec<NsightTimelineRow>,
226 #[serde(default)]
227 pub phase_attribution: Vec<PhaseGpuAttribution>,
228}
229
230impl NsightEvidence {
231 pub fn bind_to_trace(&mut self, run_id: &str, correlation_id: &str) {
233 let Some(manifest) = self.provenance.manifest.as_ref() else {
234 self.provenance.binding = ProvenanceBindingState::Partial;
235 self.provenance
236 .diagnostics
237 .push("Trace binding cannot be verified without capture-manifest.json".into());
238 return;
239 };
240 let mut mismatches = Vec::new();
241 if manifest.run.id != run_id {
242 mismatches.push(format!(
243 "manifest run `{}` does not match trace run `{run_id}`",
244 manifest.run.id
245 ));
246 }
247 if manifest.correlation.id != correlation_id {
248 mismatches.push(format!(
249 "manifest correlation `{}` does not match trace correlation `{correlation_id}`",
250 manifest.correlation.id
251 ));
252 }
253 if mismatches.is_empty() && self.provenance.binding != ProvenanceBindingState::Mismatch {
254 self.provenance.binding = ProvenanceBindingState::Bound;
255 } else if !mismatches.is_empty() {
256 self.provenance.binding = ProvenanceBindingState::Mismatch;
257 self.provenance.diagnostics.extend(mismatches);
258 }
259 }
260
261 pub fn unavailable(reason: impl Into<String>) -> Self {
262 Self {
263 status: GpuEvidenceStatus::Unavailable,
264 reason: Some(reason.into()),
265 raw_report: None,
266 source_csv: Vec::new(),
267 provenance: NsightProvenance {
268 binding: ProvenanceBindingState::Partial,
269 manifest: None,
270 diagnostics: vec!["No capture manifest was loaded".into()],
271 },
272 coverage: NsightCoverage::default(),
273 correlation: empty_correlation(),
274 diagnostics: Vec::new(),
275 limits: BTreeMap::new(),
276 kernels: Vec::new(),
277 runtime_calls: Vec::new(),
278 memory_operations: Vec::new(),
279 nvtx_ranges: Vec::new(),
280 gpu_timeline: Vec::new(),
281 phase_attribution: Vec::new(),
282 }
283 }
284
285 pub fn load_optional(dir: Option<&Path>, expected_semantic_keys: &[String]) -> Self {
288 Self::load_optional_with_semantic_contract(
289 dir,
290 expected_semantic_keys,
291 expected_semantic_keys,
292 &[],
293 )
294 }
295
296 pub fn load_optional_with_semantic_contract(
297 dir: Option<&Path>,
298 required_application_labels: &[String],
299 gpu_expected_semantic_keys: &[String],
300 cpu_only_semantic_keys: &[String],
301 ) -> Self {
302 let Some(dir) = dir else {
303 return Self::unavailable("Nsight capture was not requested");
304 };
305 match Self::load_with_semantic_contract(
306 dir,
307 required_application_labels,
308 gpu_expected_semantic_keys,
309 cpu_only_semantic_keys,
310 ) {
311 Ok(evidence) => evidence,
312 Err(error) => Self {
313 status: GpuEvidenceStatus::Failed,
314 reason: Some(error.to_string()),
315 ..Self::unavailable("Nsight report normalization failed")
316 },
317 }
318 }
319
320 pub fn load(dir: &Path, expected_semantic_keys: &[String]) -> anyhow::Result<Self> {
321 Self::load_with_semantic_contract(dir, expected_semantic_keys, expected_semantic_keys, &[])
322 }
323
324 pub fn load_with_semantic_contract(
325 dir: &Path,
326 required_application_labels: &[String],
327 gpu_expected_semantic_keys: &[String],
328 cpu_only_semantic_keys: &[String],
329 ) -> anyhow::Result<Self> {
330 anyhow::ensure!(
331 dir.is_dir(),
332 "Nsight report directory does not exist: {}",
333 dir.display()
334 );
335 let mut files: Vec<PathBuf> = fs::read_dir(dir)?
336 .filter_map(Result::ok)
337 .map(|entry| entry.path())
338 .filter(|path| path.is_file())
339 .collect();
340 files.sort();
341
342 let raw_report_path = files.iter().find(|path| extension(path) == "nsys-rep");
343 let raw_report = raw_report_path
344 .map(|path| artifact_metadata(dir, path))
345 .transpose()?;
346 let mut result = Self {
347 status: GpuEvidenceStatus::Unavailable,
348 reason: None,
349 raw_report,
350 source_csv: Vec::new(),
351 provenance: load_provenance(dir),
352 coverage: NsightCoverage::default(),
353 correlation: empty_correlation(),
354 diagnostics: Vec::new(),
355 limits: BTreeMap::new(),
356 kernels: Vec::new(),
357 runtime_calls: Vec::new(),
358 memory_operations: Vec::new(),
359 nvtx_ranges: Vec::new(),
360 gpu_timeline: Vec::new(),
361 phase_attribution: Vec::new(),
362 };
363
364 let mut all_nvtx_ranges = Vec::new();
365 let mut all_gpu_timeline = Vec::new();
366 for path in files.iter().filter(|path| extension(path) == "csv") {
367 let name = path
368 .file_name()
369 .and_then(|x| x.to_str())
370 .unwrap_or_default();
371 let parsed: anyhow::Result<Option<ReportLimit>> = if name.contains("cuda_gpu_kern_sum")
372 {
373 parse_summary(path).map(|rows| {
374 let (rows, limit) = truncate_rows(rows, SUMMARY_DISPLAY_LIMIT);
375 result.kernels.extend(rows);
376 result.coverage.kernel_summary = true;
377 Some(limit)
378 })
379 } else if name.contains("cuda_api_sum") {
380 parse_summary(path).map(|rows| {
381 let (rows, limit) = truncate_rows(rows, SUMMARY_DISPLAY_LIMIT);
382 result.runtime_calls.extend(rows);
383 result.coverage.runtime_summary = true;
384 Some(limit)
385 })
386 } else if name.contains("cuda_gpu_mem_time_sum") {
387 parse_summary(path).map(|rows| {
388 let (rows, limit) = truncate_rows(rows, SUMMARY_DISPLAY_LIMIT);
389 result.memory_operations.extend(rows);
390 result.coverage.memory_summary = true;
391 Some(limit)
392 })
393 } else if name.contains("nvtx_gpu_proj_trace") {
394 parse_timeline(path, "nvtx_range", true).map(|rows| {
395 let limit = report_limit(rows.len(), TIMELINE_DISPLAY_LIMIT);
396 all_nvtx_ranges.extend(rows);
397 result.coverage.nvtx_projection = true;
398 Some(limit)
399 })
400 } else if name.contains("cuda_gpu_trace") {
401 parse_timeline(path, "gpu_operation", false).map(|rows| {
402 let limit = report_limit(rows.len(), TIMELINE_DISPLAY_LIMIT);
403 all_gpu_timeline.extend(rows);
404 result.coverage.gpu_timeline = true;
405 Some(limit)
406 })
407 } else {
408 Ok(None)
409 };
410 match parsed {
411 Ok(Some(limit)) => {
412 result.source_csv.push(artifact_metadata(dir, path)?);
413 result.limits.insert(name.to_string(), limit);
414 }
415 Ok(None) => {}
416 Err(error) => result
417 .diagnostics
418 .push(format!("{}: {error}", path.display())),
419 }
420 }
421
422 result.correlation = build_correlation(
423 gpu_expected_semantic_keys,
424 cpu_only_semantic_keys,
425 &all_nvtx_ranges,
426 &all_gpu_timeline,
427 );
428 result.phase_attribution = attribute_gpu_phases(&all_nvtx_ranges, &all_gpu_timeline);
429 all_nvtx_ranges.sort_by(|left, right| {
430 left.start_ns
431 .cmp(&right.start_ns)
432 .then_with(|| left.name.cmp(&right.name))
433 });
434 all_gpu_timeline.sort_by(|left, right| {
435 left.start_ns
436 .cmp(&right.start_ns)
437 .then_with(|| left.name.cmp(&right.name))
438 });
439 all_nvtx_ranges.truncate(TIMELINE_DISPLAY_LIMIT);
440 all_gpu_timeline.truncate(TIMELINE_DISPLAY_LIMIT);
441 result.nvtx_ranges = all_nvtx_ranges;
442 result.gpu_timeline = all_gpu_timeline;
443
444 validate_provenance(
445 dir,
446 required_application_labels,
447 gpu_expected_semantic_keys,
448 cpu_only_semantic_keys,
449 &result.source_csv,
450 result.raw_report.as_ref(),
451 &mut result.provenance,
452 );
453 let useful_rows = result.kernels.len()
454 + result.runtime_calls.len()
455 + result.memory_operations.len()
456 + result.nvtx_ranges.len()
457 + result.gpu_timeline.len();
458 if useful_rows == 0 {
459 result.reason = status_reason(dir)
460 .or_else(|| Some("No supported nsys stats CSV reports were found".into()));
461 } else {
462 result.status = GpuEvidenceStatus::Available;
463 }
464 Ok(result)
465 }
466}
467
468fn empty_correlation() -> NsightCorrelation {
469 NsightCorrelation {
470 mode: "none".into(),
471 clock_aligned: false,
472 complete: false,
473 ledger: NsightCorrelationLedger::default(),
474 reason: Some("No projected NVTX ranges were normalized".into()),
475 }
476}
477
478fn load_provenance(dir: &Path) -> NsightProvenance {
479 let path = dir.join(CAPTURE_MANIFEST);
480 match fs::read_to_string(&path) {
481 Ok(json) => match serde_json::from_str::<CaptureManifest>(&json) {
482 Ok(manifest) => NsightProvenance {
483 binding: ProvenanceBindingState::Partial,
484 manifest: Some(manifest),
485 diagnostics: Vec::new(),
486 },
487 Err(error) => NsightProvenance {
488 binding: ProvenanceBindingState::Partial,
489 manifest: None,
490 diagnostics: vec![format!("Invalid {}: {error}", path.display())],
491 },
492 },
493 Err(error) if error.kind() == std::io::ErrorKind::NotFound => NsightProvenance {
494 binding: ProvenanceBindingState::Partial,
495 manifest: None,
496 diagnostics: vec![format!("{} is absent", path.display())],
497 },
498 Err(error) => NsightProvenance {
499 binding: ProvenanceBindingState::Partial,
500 manifest: None,
501 diagnostics: vec![format!("Could not read {}: {error}", path.display())],
502 },
503 }
504}
505
506fn validate_provenance(
507 dir: &Path,
508 required_application_labels: &[String],
509 gpu_expected_semantic_keys: &[String],
510 cpu_only_semantic_keys: &[String],
511 csv: &[NsightArtifact],
512 raw_report: Option<&NsightArtifact>,
513 provenance: &mut NsightProvenance,
514) {
515 let Some(manifest) = provenance.manifest.as_ref() else {
516 return;
517 };
518 let mut mismatches = Vec::new();
519 if manifest.schema != CAPTURE_MANIFEST_SCHEMA {
520 mismatches.push(format!(
521 "manifest schema `{}` is unsupported; expected `{CAPTURE_MANIFEST_SCHEMA}`",
522 manifest.schema
523 ));
524 }
525 if raw_report.is_none() {
526 mismatches.push("a bound capture requires a retained raw .nsys-rep artifact".into());
527 }
528 let mut retained = csv
529 .iter()
530 .chain(raw_report)
531 .cloned()
532 .map(|artifact| (artifact.path.clone(), artifact))
533 .collect::<HashMap<_, _>>();
534 let mut declared = BTreeSet::new();
535
536 let manifest_labels = sorted_unique(manifest.required_semantic_labels.iter().cloned());
537 if manifest_labels.len() != manifest.required_semantic_labels.len() {
538 mismatches.push("manifest required application labels contain duplicates".into());
539 }
540 let expected_labels = sorted_unique(required_application_labels.iter().cloned());
541 if manifest_labels != expected_labels {
542 mismatches.push(format!(
543 "manifest required application labels do not match the application expectation: expected {expected_labels:?}, declared {manifest_labels:?}"
544 ));
545 }
546 let manifest_uses_legacy_semantics = manifest.gpu_expected_semantic_labels.is_empty()
547 && manifest.cpu_only_semantic_labels.is_empty();
548 let manifest_gpu_labels = if manifest_uses_legacy_semantics {
549 manifest_labels.clone()
550 } else {
551 sorted_unique(manifest.gpu_expected_semantic_labels.iter().cloned())
552 };
553 let manifest_cpu_labels = sorted_unique(manifest.cpu_only_semantic_labels.iter().cloned());
554 if !manifest_uses_legacy_semantics
555 && manifest_gpu_labels.len() != manifest.gpu_expected_semantic_labels.len()
556 {
557 mismatches.push("manifest GPU-expected labels contain duplicates".into());
558 }
559 if manifest_cpu_labels.len() != manifest.cpu_only_semantic_labels.len() {
560 mismatches.push("manifest CPU-only labels contain duplicates".into());
561 }
562 let expected_gpu_labels = sorted_unique(gpu_expected_semantic_keys.iter().cloned());
563 let expected_cpu_labels = sorted_unique(cpu_only_semantic_keys.iter().cloned());
564 if manifest_gpu_labels != expected_gpu_labels {
565 mismatches.push(format!(
566 "manifest GPU-expected labels do not match the application expectation: expected {expected_gpu_labels:?}, declared {manifest_gpu_labels:?}"
567 ));
568 }
569 if manifest_cpu_labels != expected_cpu_labels {
570 mismatches.push(format!(
571 "manifest CPU-only labels do not match the application expectation: expected {expected_cpu_labels:?}, declared {manifest_cpu_labels:?}"
572 ));
573 }
574
575 for artifact in &manifest.artifacts {
576 if !declared.insert(artifact.path.as_str()) {
577 mismatches.push(format!(
578 "manifest declares `{}` more than once",
579 artifact.path
580 ));
581 continue;
582 }
583 if !is_safe_relative_path(&artifact.path) {
584 mismatches.push(format!(
585 "manifest artifact path `{}` is not a safe relative path",
586 artifact.path
587 ));
588 continue;
589 }
590 if !retained.contains_key(&artifact.path) {
591 let path = dir.join(&artifact.path);
592 if path.is_file() {
593 match artifact_metadata(dir, &path) {
594 Ok(metadata) => {
595 retained.insert(artifact.path.clone(), metadata);
596 }
597 Err(error) => mismatches.push(format!(
598 "manifest artifact `{}` could not be hashed: {error}",
599 artifact.path
600 )),
601 }
602 }
603 }
604 let actual = retained.get(&artifact.path);
605 match actual {
606 None => mismatches.push(format!("manifest artifact `{}` is missing", artifact.path)),
607 Some(actual) => {
608 if actual.size_bytes != artifact.size_bytes {
609 mismatches.push(format!(
610 "manifest artifact `{}` size mismatch: expected {}, observed {}",
611 artifact.path, artifact.size_bytes, actual.size_bytes
612 ));
613 }
614 if !actual.sha256.eq_ignore_ascii_case(&artifact.sha256) {
615 mismatches.push(format!(
616 "manifest artifact `{}` SHA-256 mismatch",
617 artifact.path
618 ));
619 }
620 }
621 }
622 }
623 for artifact in csv.iter().chain(raw_report) {
624 if !declared.contains(artifact.path.as_str()) {
625 mismatches.push(format!(
626 "retained artifact `{}` is absent from the manifest",
627 artifact.path
628 ));
629 }
630 }
631 if !mismatches.is_empty() {
632 provenance.binding = ProvenanceBindingState::Mismatch;
633 provenance.diagnostics.extend(mismatches);
634 }
635}
636
637fn is_safe_relative_path(value: &str) -> bool {
638 let path = Path::new(value);
639 !path.as_os_str().is_empty()
640 && !path.is_absolute()
641 && path
642 .components()
643 .all(|component| matches!(component, Component::Normal(_)))
644}
645
646fn artifact_metadata(root: &Path, path: &Path) -> anyhow::Result<NsightArtifact> {
647 let relative = path.strip_prefix(root).unwrap_or(path);
648 let mut file = fs::File::open(path)?;
649 let size_bytes = file.metadata()?.len();
650 let mut hasher = Sha256::new();
651 let mut buffer = [0_u8; 64 * 1024];
652 loop {
653 let read = file.read(&mut buffer)?;
654 if read == 0 {
655 break;
656 }
657 hasher.update(&buffer[..read]);
658 }
659 Ok(NsightArtifact {
660 path: relative.to_string_lossy().replace('\\', "/"),
661 size_bytes,
662 sha256: format!("{:x}", hasher.finalize()),
663 })
664}
665
666fn build_correlation(
667 expected: &[String],
668 cpu_only: &[String],
669 ranges: &[NsightTimelineRow],
670 gpu_timeline: &[NsightTimelineRow],
671) -> NsightCorrelation {
672 if ranges.is_empty() {
673 let mut correlation = empty_correlation();
674 correlation.ledger.expected = sorted_unique(expected.iter().cloned());
675 correlation.ledger.cpu_only = sorted_unique(cpu_only.iter().cloned());
676 correlation.ledger.missing_expected = correlation.ledger.expected.clone();
677 return correlation;
678 }
679
680 let expected = sorted_unique(expected.iter().cloned());
681 let cpu_only = sorted_unique(cpu_only.iter().cloned());
682 let mut counts = BTreeMap::<String, usize>::new();
683 for key in ranges.iter().filter_map(|row| row.semantic_key.as_ref()) {
684 *counts.entry(key.clone()).or_default() += 1;
685 }
686 let observed = counts.keys().cloned().collect::<Vec<_>>();
687 let expected_set = expected.iter().map(String::as_str).collect::<BTreeSet<_>>();
688 let cpu_only_set = cpu_only.iter().map(String::as_str).collect::<BTreeSet<_>>();
689 let observed_set = observed.iter().map(String::as_str).collect::<BTreeSet<_>>();
690 let matched = expected_set
691 .intersection(&observed_set)
692 .map(|key| (*key).to_string())
693 .collect();
694 let missing_expected = expected_set
695 .difference(&observed_set)
696 .map(|key| (*key).to_string())
697 .collect();
698 let known_application_set = expected_set
699 .union(&cpu_only_set)
700 .copied()
701 .collect::<BTreeSet<_>>();
702 let unexpected_observed = observed_set
703 .difference(&known_application_set)
704 .map(|key| (*key).to_string())
705 .collect();
706 let unexpected_cpu_only = observed_set
707 .intersection(&cpu_only_set)
708 .map(|key| (*key).to_string())
709 .collect();
710 let duplicates = counts
711 .into_iter()
712 .filter(|(_, occurrences)| *occurrences > 1)
713 .map(|(semantic_key, occurrences)| CorrelationDuplicate {
714 semantic_key,
715 occurrences,
716 })
717 .collect::<Vec<_>>();
718 let mut joinable_count_checks = 0_usize;
719 let mut unjoined_projection_rows = 0_usize;
720 let all_ranges_qualified = ranges
721 .iter()
722 .filter(|row| {
723 row.semantic_key
724 .as_ref()
725 .is_some_and(|key| expected_set.contains(key.as_str()))
726 })
727 .all(|row| match row.gpu_operations {
728 Some(0) => false,
729 Some(expected_count)
730 if !gpu_timeline.is_empty() && !phase_join_keys(row).is_empty() =>
731 {
732 joinable_count_checks += 1;
733 matching_gpu_operations(row, gpu_timeline)
734 .is_some_and(|operations| operations.len() as u64 == expected_count)
735 }
736 _ => {
737 unjoined_projection_rows += 1;
738 true
739 }
740 });
741 let ledger = NsightCorrelationLedger {
742 expected,
743 cpu_only,
744 observed,
745 matched,
746 missing_expected,
747 unexpected_observed,
748 unexpected_cpu_only,
749 duplicates,
750 };
751 let complete = !ledger.expected.is_empty()
752 && ledger.missing_expected.is_empty()
753 && ledger.unexpected_observed.is_empty()
754 && ledger.unexpected_cpu_only.is_empty()
755 && ledger.duplicates.is_empty()
756 && all_ranges_qualified;
757 let reason = if complete {
758 format!(
759 "GPU-expected and observed semantic labels match exactly; {joinable_count_checks} joinable GPU operation count checks passed and {unjoined_projection_rows} projected rows rely on the official projection report; Candle and Nsight clocks remain separate"
760 )
761 } else {
762 format!(
763 "Correlation is incomplete: {} missing GPU-expected, {} unexpected observed, {} CPU-only projected, {} duplicate labels, joinable GPU operation counts complete={all_ranges_qualified}; clocks remain separate",
764 ledger.missing_expected.len(),
765 ledger.unexpected_observed.len(),
766 ledger.unexpected_cpu_only.len(),
767 ledger.duplicates.len()
768 )
769 };
770 NsightCorrelation {
771 mode: "nvtx_projected_range".into(),
772 clock_aligned: false,
773 complete,
774 ledger,
775 reason: Some(reason),
776 }
777}
778
779fn sorted_unique(values: impl Iterator<Item = String>) -> Vec<String> {
780 values.collect::<BTreeSet<_>>().into_iter().collect()
781}
782
783fn attribute_gpu_phases(
784 ranges: &[NsightTimelineRow],
785 gpu_timeline: &[NsightTimelineRow],
786) -> Vec<PhaseGpuAttribution> {
787 ranges
788 .iter()
789 .filter_map(|range| {
790 let semantic_key = range.semantic_key.clone()?;
791 let start = range.projected_start_ns?;
792 let duration = range.projected_duration_ns?;
793 let end = start.saturating_add(duration);
794 let operations = matching_gpu_operations(range, gpu_timeline)?;
795 if range.gpu_operations? != operations.len() as u64 {
796 return None;
797 }
798 let mut intersections = Vec::new();
799 for operation in &operations {
800 let operation_end = operation.start_ns.saturating_add(operation.duration_ns);
801 let overlap_start = start.max(operation.start_ns);
802 let overlap_end = end.min(operation_end);
803 if overlap_start < overlap_end {
804 intersections.push((overlap_start, overlap_end));
805 }
806 }
807 Some(PhaseGpuAttribution {
808 semantic_key,
809 clock_aligned: false,
810 projected_start_ns: start,
811 projected_duration_ns: duration,
812 gpu_busy_ns: interval_union_duration(&mut intersections),
813 gpu_operation_count: operations.len(),
814 join_keys: phase_join_keys(range),
815 })
816 })
817 .collect()
818}
819
820fn matching_gpu_operations<'a>(
821 range: &NsightTimelineRow,
822 gpu_timeline: &'a [NsightTimelineRow],
823) -> Option<Vec<&'a NsightTimelineRow>> {
824 let join_keys = phase_join_keys(range);
825 if join_keys.is_empty() {
826 return None;
827 }
828 let start = range.projected_start_ns?;
829 let end = start.saturating_add(range.projected_duration_ns?);
830 Some(
831 gpu_timeline
832 .iter()
833 .filter(|operation| {
834 range
835 .correlation_id
836 .as_ref()
837 .is_none_or(|value| operation.correlation_id.as_ref() == Some(value))
838 && range
839 .device
840 .as_ref()
841 .is_none_or(|value| operation.device.as_ref() == Some(value))
842 && range
843 .context
844 .as_ref()
845 .is_none_or(|value| operation.context.as_ref() == Some(value))
846 && range
847 .stream
848 .as_ref()
849 .is_none_or(|value| operation.stream.as_ref() == Some(value))
850 && operation.start_ns < end
851 && operation.start_ns.saturating_add(operation.duration_ns) > start
852 })
853 .collect(),
854 )
855}
856
857fn phase_join_keys(range: &NsightTimelineRow) -> Vec<String> {
858 [
859 ("correlation_id", range.correlation_id.as_ref()),
860 ("device", range.device.as_ref()),
861 ("context", range.context.as_ref()),
862 ("stream", range.stream.as_ref()),
863 ]
864 .into_iter()
865 .filter_map(|(name, value)| value.map(|_| name.to_string()))
866 .collect()
867}
868
869fn interval_union_duration(intervals: &mut [(u64, u64)]) -> u64 {
870 intervals.sort_unstable();
871 let mut total = 0_u64;
872 let mut current: Option<(u64, u64)> = None;
873 for &(start, end) in intervals.iter() {
874 current = match current {
875 None => Some((start, end)),
876 Some((current_start, current_end)) if start <= current_end => {
877 Some((current_start, current_end.max(end)))
878 }
879 Some((current_start, current_end)) => {
880 total = total.saturating_add(current_end.saturating_sub(current_start));
881 Some((start, end))
882 }
883 };
884 }
885 if let Some((start, end)) = current {
886 total = total.saturating_add(end.saturating_sub(start));
887 }
888 total
889}
890
891fn parse_summary(path: &Path) -> anyhow::Result<Vec<NsightSummaryRow>> {
892 let mut reader = csv::ReaderBuilder::new().flexible(true).from_path(path)?;
893 let headers = normalized_headers(reader.headers()?);
894 anyhow::ensure!(
895 has_header(&headers, &["name", "operation", "range", "kernel_name"]),
896 "missing operation/name column"
897 );
898 anyhow::ensure!(
899 has_header(&headers, &["total_time_ns", "total_ns"]),
900 "missing total-time nanoseconds column"
901 );
902 let mut rows = Vec::new();
903 for record in reader.records() {
904 let record = record?;
905 let name = field(
906 &headers,
907 &record,
908 &["name", "operation", "range", "kernel_name"],
909 )
910 .unwrap_or_default()
911 .trim()
912 .to_string();
913 if name.is_empty() {
914 continue;
915 }
916 rows.push(NsightSummaryRow {
917 name,
918 total_ns: required_number(
919 field(&headers, &record, &["total_time_ns", "total_ns"]),
920 "total_ns",
921 )?,
922 count: optional_number(
923 field(
924 &headers,
925 &record,
926 &["instances", "num_calls", "operations", "count"],
927 ),
928 "count",
929 )?,
930 average_ns: optional_number(
931 field(&headers, &record, &["avg_ns", "average_ns"]),
932 "average_ns",
933 )?,
934 minimum_ns: optional_number(
935 field(&headers, &record, &["min_ns", "minimum_ns"]),
936 "minimum_ns",
937 )?,
938 maximum_ns: optional_number(
939 field(&headers, &record, &["max_ns", "maximum_ns"]),
940 "maximum_ns",
941 )?,
942 category: field(&headers, &record, &["category"]).map(str::to_string),
943 });
944 }
945 rows.sort_by_key(|row| std::cmp::Reverse(row.total_ns));
946 Ok(rows)
947}
948
949fn parse_timeline(
950 path: &Path,
951 kind: &str,
952 projected: bool,
953) -> anyhow::Result<Vec<NsightTimelineRow>> {
954 let mut reader = csv::ReaderBuilder::new().flexible(true).from_path(path)?;
955 let headers = normalized_headers(reader.headers()?);
956 anyhow::ensure!(
957 has_header(&headers, &["name", "operation", "range", "kernel_name"]),
958 "missing operation/name column"
959 );
960 let start_headers: &[&str] = if projected {
961 &["orig_start_ns", "orig_start", "start_ns", "start"]
962 } else {
963 &["start_ns", "start"]
964 };
965 let duration_headers: &[&str] = if projected {
966 &[
967 "orig_duration_ns",
968 "orig_duration",
969 "duration_ns",
970 "duration",
971 "dur_ns",
972 ]
973 } else {
974 &["duration_ns", "duration", "dur_ns"]
975 };
976 anyhow::ensure!(
977 has_header(&headers, start_headers) && has_header(&headers, duration_headers),
978 "missing start/duration nanoseconds columns"
979 );
980 if projected {
981 anyhow::ensure!(
982 has_header(
983 &headers,
984 &["projected_start_ns", "projected_start", "proj_start_ns"]
985 ) && has_header(
986 &headers,
987 &["projected_duration_ns", "projected_duration", "proj_dur_ns"]
988 ),
989 "missing projected start/duration nanoseconds columns"
990 );
991 }
992 let mut rows = Vec::new();
993 for record in reader.records() {
994 let record = record?;
995 let name = field(
996 &headers,
997 &record,
998 &["name", "operation", "range", "kernel_name"],
999 )
1000 .unwrap_or_default()
1001 .trim()
1002 .to_string();
1003 if name.is_empty() {
1004 continue;
1005 }
1006 let start_ns = required_number(field(&headers, &record, start_headers), "start_ns")?;
1007 let duration_ns =
1008 required_number(field(&headers, &record, duration_headers), "duration_ns")?;
1009 anyhow::ensure!(duration_ns > 0, "timeline row `{name}` has zero duration");
1010 let projected_start_ns = optional_number(
1011 field(
1012 &headers,
1013 &record,
1014 &["projected_start_ns", "projected_start", "proj_start_ns"],
1015 ),
1016 "projected_start_ns",
1017 )?;
1018 let projected_duration_ns = optional_number(
1019 field(
1020 &headers,
1021 &record,
1022 &["projected_duration_ns", "projected_duration", "proj_dur_ns"],
1023 ),
1024 "projected_duration_ns",
1025 )?;
1026 if projected {
1027 anyhow::ensure!(
1028 projected_start_ns.is_some()
1029 && projected_duration_ns.is_some_and(|duration| duration > 0),
1030 "projected timeline row `{name}` has invalid projected timing"
1031 );
1032 }
1033 rows.push(NsightTimelineRow {
1034 semantic_key: (kind == "nvtx_range").then(|| name.clone()),
1035 name,
1036 kind: kind.into(),
1037 device: field(&headers, &record, &["device", "device_id"]).map(str::to_string),
1038 context: field(&headers, &record, &["context", "context_id", "ctx"])
1039 .map(str::to_string),
1040 stream: field(&headers, &record, &["stream", "stream_id", "strm"]).map(str::to_string),
1041 correlation_id: field(&headers, &record, &["correlation_id", "corrid", "corr_id"])
1042 .map(str::to_string),
1043 start_ns,
1044 duration_ns,
1045 projected_start_ns,
1046 projected_duration_ns,
1047 gpu_operations: optional_number(
1048 field(
1049 &headers,
1050 &record,
1051 &["num_gpu_ops", "numgpuops", "gpu_operations"],
1052 ),
1053 "gpu_operations",
1054 )?,
1055 });
1056 }
1057 rows.sort_by_key(|row| row.start_ns);
1058 Ok(rows)
1059}
1060
1061fn truncate_rows<T>(mut rows: Vec<T>, limit: usize) -> (Vec<T>, ReportLimit) {
1062 let report_limit = report_limit(rows.len(), limit);
1063 rows.truncate(limit);
1064 (rows, report_limit)
1065}
1066
1067fn report_limit(total_rows: usize, limit: usize) -> ReportLimit {
1068 ReportLimit {
1069 total_rows,
1070 displayed_rows: total_rows.min(limit),
1071 truncated: total_rows > limit,
1072 }
1073}
1074
1075fn has_header(headers: &[String], names: &[&str]) -> bool {
1076 names
1077 .iter()
1078 .any(|name| headers.iter().any(|header| header == name))
1079}
1080
1081fn normalized_headers(headers: &csv::StringRecord) -> Vec<String> {
1082 headers
1083 .iter()
1084 .map(|header| {
1085 header
1086 .trim()
1087 .to_ascii_lowercase()
1088 .replace(['(', ')', '%'], "")
1089 .replace([' ', '-', '/'], "_")
1090 .trim_matches('_')
1091 .to_string()
1092 })
1093 .collect()
1094}
1095
1096fn field<'a>(headers: &[String], record: &'a csv::StringRecord, names: &[&str]) -> Option<&'a str> {
1097 names.iter().find_map(|name| {
1098 headers
1099 .iter()
1100 .position(|header| header == name)
1101 .and_then(|index| record.get(index))
1102 })
1103}
1104
1105fn required_number(value: Option<&str>, label: &str) -> anyhow::Result<u64> {
1106 optional_number(value, label)?.ok_or_else(|| anyhow::anyhow!("missing required {label} value"))
1107}
1108
1109fn optional_number(value: Option<&str>, label: &str) -> anyhow::Result<Option<u64>> {
1110 let Some(value) = value else {
1111 return Ok(None);
1112 };
1113 let cleaned = value.trim().replace(',', "");
1114 if cleaned.is_empty() {
1115 return Ok(None);
1116 }
1117 if let Ok(value) = cleaned.parse::<u64>() {
1118 return Ok(Some(value));
1119 }
1120 let value = cleaned
1121 .parse::<f64>()
1122 .map_err(|_| anyhow::anyhow!("invalid {label} value {cleaned:?}"))?;
1123 anyhow::ensure!(value.is_finite(), "non-finite {label} value {cleaned:?}");
1124 anyhow::ensure!(
1125 !value.is_sign_negative(),
1126 "negative {label} value {cleaned:?}"
1127 );
1128 anyhow::ensure!(
1130 value < 18_446_744_073_709_551_616.0,
1131 "overflowing {label} value {cleaned:?}"
1132 );
1133 Ok(Some(value as u64))
1134}
1135
1136fn extension(path: &Path) -> &str {
1137 path.extension()
1138 .and_then(|value| value.to_str())
1139 .unwrap_or_default()
1140}
1141
1142fn status_reason(dir: &Path) -> Option<String> {
1143 let content = fs::read_to_string(dir.join("status.txt")).ok()?;
1144 content
1145 .lines()
1146 .find_map(|line| line.strip_prefix("reason=").map(str::to_string))
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151 use super::*;
1152
1153 fn temp_dir(label: &str) -> PathBuf {
1154 let dir = std::env::temp_dir().join(format!(
1155 "candle-graph-nsys-{label}-{}-{:?}",
1156 std::process::id(),
1157 std::thread::current().id()
1158 ));
1159 let _ = fs::remove_dir_all(&dir);
1160 fs::create_dir_all(&dir).unwrap();
1161 dir
1162 }
1163
1164 #[test]
1165 fn normalizes_official_summary_headers_and_hashes_source() {
1166 let dir = temp_dir("summary");
1167 let path = dir.join("sample_cuda_gpu_kern_sum.csv");
1168 fs::write(&path, "Time (%),Total Time (ns),Instances,Avg (ns),Min (ns),Max (ns),Name\n50.0,1200,3,400,200,600,gemm\n").unwrap();
1169 let evidence = NsightEvidence::load(&dir, &[]).unwrap();
1170 assert_eq!(evidence.status, GpuEvidenceStatus::Available);
1171 assert_eq!(evidence.kernels[0].name, "gemm");
1172 assert_eq!(evidence.kernels[0].total_ns, 1200);
1173 assert_eq!(evidence.source_csv[0].sha256.len(), 64);
1174 assert_eq!(
1175 evidence.source_csv[0].size_bytes,
1176 fs::metadata(path).unwrap().len()
1177 );
1178 assert_eq!(evidence.provenance.binding, ProvenanceBindingState::Partial);
1179 let _ = fs::remove_dir_all(dir);
1180 }
1181
1182 #[test]
1183 fn malformed_report_is_diagnostic_not_available() {
1184 let dir = temp_dir("bad");
1185 fs::write(dir.join("bad_cuda_api_sum.csv"), "Unknown,Value\nx,1\n").unwrap();
1186 let evidence = NsightEvidence::load(&dir, &[]).unwrap();
1187 assert_eq!(evidence.status, GpuEvidenceStatus::Unavailable);
1188 assert!(!evidence.diagnostics.is_empty());
1189 let _ = fs::remove_dir_all(dir);
1190 }
1191
1192 #[test]
1193 fn required_summary_numbers_reject_malformed_negative_non_finite_and_overflow() {
1194 let dir = temp_dir("strict-summary-numbers");
1195 let path = dir.join("sample_cuda_gpu_kern_sum.csv");
1196 for value in ["garbage", "-1", "NaN", "inf", "18446744073709551616"] {
1197 fs::write(
1198 &path,
1199 format!("Total Time (ns),Instances,Name\n{value},1,gemm\n"),
1200 )
1201 .unwrap();
1202 assert!(
1203 parse_summary(&path).is_err(),
1204 "summary value {value:?} must fail"
1205 );
1206 }
1207 fs::write(&path, "Total Time (ns),Instances,Name\n1,,gemm\n").unwrap();
1208 assert_eq!(parse_summary(&path).unwrap()[0].count, None);
1209 let _ = fs::remove_dir_all(dir);
1210 }
1211
1212 #[test]
1213 fn optional_timeline_numbers_are_none_only_when_missing_and_fail_when_malformed() {
1214 let dir = temp_dir("strict-optional-numbers");
1215 let path = dir.join("sample_cuda_gpu_trace.csv");
1216 fs::write(&path, "Name,Start (ns),Duration (ns)\ngemm,1,2\n").unwrap();
1217 let row = parse_timeline(&path, "gpu_operation", false)
1218 .unwrap()
1219 .remove(0);
1220 assert_eq!(row.gpu_operations, None);
1221 assert_eq!(row.projected_start_ns, None);
1222
1223 fs::write(
1224 &path,
1225 "Name,Start (ns),Duration (ns),Num GPU Ops\ngemm,1,2,NaN\n",
1226 )
1227 .unwrap();
1228 assert!(parse_timeline(&path, "gpu_operation", false).is_err());
1229 fs::write(
1230 &path,
1231 "Name,Start (ns),Duration (ns),Num GPU Ops\ngemm,1,2,-1\n",
1232 )
1233 .unwrap();
1234 assert!(parse_timeline(&path, "gpu_operation", false).is_err());
1235 fs::write(
1236 &path,
1237 "Name,Start (ns),Duration (ns),Num GPU Ops\ngemm,1,2,1e100\n",
1238 )
1239 .unwrap();
1240 assert!(parse_timeline(&path, "gpu_operation", false).is_err());
1241 let _ = fs::remove_dir_all(dir);
1242 }
1243
1244 #[test]
1245 fn parses_official_nvtx_projection_columns_without_gpu_join_identifiers() {
1246 let dir = temp_dir("official-nvtx-projection");
1247 fs::write(
1248 dir.join("sample_nvtx_gpu_proj_trace.csv"),
1249 include_str!("../tests/fixtures/nsight/nvtx_gpu_proj_trace.csv"),
1250 )
1251 .unwrap();
1252 let required = vec!["pipeline/gpu".into(), "pipeline/prepare".into()];
1253 let gpu_expected = vec!["pipeline/gpu".into()];
1254 let cpu_only = vec!["pipeline/prepare".into()];
1255 let evidence =
1256 NsightEvidence::load_with_semantic_contract(&dir, &required, &gpu_expected, &cpu_only)
1257 .unwrap();
1258
1259 assert_eq!(evidence.status, GpuEvidenceStatus::Available);
1260 assert!(evidence.correlation.complete);
1261 assert_eq!(evidence.correlation.ledger.expected, gpu_expected);
1262 assert_eq!(evidence.correlation.ledger.cpu_only, cpu_only);
1263 assert!(evidence.correlation.ledger.missing_expected.is_empty());
1264 assert!(evidence.correlation.ledger.unexpected_cpu_only.is_empty());
1265 assert_eq!(evidence.nvtx_ranges[0].start_ns, 700);
1266 assert_eq!(evidence.nvtx_ranges[0].duration_ns, 600);
1267 assert_eq!(evidence.nvtx_ranges[0].projected_start_ns, Some(1_000));
1268 assert_eq!(evidence.nvtx_ranges[0].projected_duration_ns, Some(250));
1269 assert_eq!(evidence.nvtx_ranges[0].gpu_operations, Some(3));
1270 assert_eq!(evidence.nvtx_ranges[0].correlation_id, None);
1271 assert_eq!(evidence.nvtx_ranges[0].device, None);
1272 assert!(evidence.phase_attribution.is_empty());
1273 let _ = fs::remove_dir_all(dir);
1274 }
1275
1276 #[test]
1277 fn recognizes_official_cuda_context_and_stream_aliases() {
1278 let dir = temp_dir("official-cuda-aliases");
1279 let path = dir.join("sample_cuda_gpu_trace.csv");
1280 fs::write(
1281 &path,
1282 include_str!("../tests/fixtures/nsight/cuda_gpu_trace.csv"),
1283 )
1284 .unwrap();
1285
1286 let row = parse_timeline(&path, "gpu_operation", false)
1287 .unwrap()
1288 .remove(0);
1289 assert_eq!(row.context.as_deref(), Some("7"));
1290 assert_eq!(row.stream.as_deref(), Some("13"));
1291 assert_eq!(row.correlation_id.as_deref(), Some("41"));
1292 let _ = fs::remove_dir_all(dir);
1293 }
1294
1295 #[test]
1296 fn legacy_capture_manifest_deserializes_with_all_required_labels_gpu_expected() {
1297 let json = serde_json::json!({
1298 "schema": CAPTURE_MANIFEST_SCHEMA,
1299 "run": { "id": "run-1" },
1300 "correlation": { "id": "update-1" },
1301 "tool": { "name": "nsys", "version": "test" },
1302 "commands": ["nsys profile app"],
1303 "hardware": {},
1304 "source_revisions": {},
1305 "required_semantic_labels": ["pipeline/gpu"],
1306 "artifacts": []
1307 });
1308 let manifest: CaptureManifest = serde_json::from_value(json).unwrap();
1309 assert!(manifest.gpu_expected_semantic_labels.is_empty());
1310 assert!(manifest.cpu_only_semantic_labels.is_empty());
1311 assert_eq!(
1312 manifest.required_semantic_labels,
1313 vec!["pipeline/gpu".to_string()]
1314 );
1315 }
1316
1317 #[test]
1318 fn gpu_projection_of_explicit_cpu_only_span_is_reported_and_incomplete() {
1319 let dir = temp_dir("cpu-only-projected");
1320 let mut csv = include_str!("../tests/fixtures/nsight/nvtx_gpu_proj_trace.csv").to_string();
1321 csv.push_str("pipeline/prepare,1300,40,1260,90,Push/Pop,4242,17,1,0,0,2,0,2\n");
1322 fs::write(dir.join("sample_nvtx_gpu_proj_trace.csv"), csv).unwrap();
1323 let required = vec!["pipeline/gpu".into(), "pipeline/prepare".into()];
1324 let evidence = NsightEvidence::load_with_semantic_contract(
1325 &dir,
1326 &required,
1327 &["pipeline/gpu".into()],
1328 &["pipeline/prepare".into()],
1329 )
1330 .unwrap();
1331
1332 assert!(!evidence.correlation.complete);
1333 assert_eq!(
1334 evidence.correlation.ledger.unexpected_cpu_only,
1335 vec!["pipeline/prepare".to_string()]
1336 );
1337 assert!(evidence.correlation.ledger.unexpected_observed.is_empty());
1338 assert!(evidence
1339 .correlation
1340 .reason
1341 .as_deref()
1342 .is_some_and(|reason| reason.contains("1 CPU-only projected")));
1343 let _ = fs::remove_dir_all(dir);
1344 }
1345
1346 #[test]
1347 fn correlation_reports_missing_expected_labels() {
1348 let dir = temp_dir("missing-label");
1349 fs::write(
1350 dir.join("sample_nvtx_gpu_proj_trace.csv"),
1351 "Name,Start (ns),Duration (ns),Projected Start (ns),Projected Duration (ns),Num GPU Ops\nrun/forward,1,10,2,8,3\n",
1352 )
1353 .unwrap();
1354 let evidence =
1355 NsightEvidence::load(&dir, &["run/forward".into(), "run/backward".into()]).unwrap();
1356 assert!(!evidence.correlation.complete);
1357 assert_eq!(
1358 evidence.correlation.ledger.missing_expected,
1359 vec!["run/backward"]
1360 );
1361 assert_eq!(evidence.correlation.ledger.matched, vec!["run/forward"]);
1362 let _ = fs::remove_dir_all(dir);
1363 }
1364
1365 #[test]
1366 fn correlation_completeness_is_computed_before_display_truncation() {
1367 let dir = temp_dir("pre-truncation");
1368 let mut csv = String::from(
1369 "Name,Start (ns),Duration (ns),Projected Start (ns),Projected Duration (ns),Num GPU Ops,CorrId\n",
1370 );
1371 let mut gpu_csv = String::from("Name,Start (ns),Duration (ns),CorrId\n");
1372 let mut expected = Vec::new();
1373 for index in 0..=TIMELINE_DISPLAY_LIMIT {
1374 let label = format!("phase/{index}");
1375 expected.push(label.clone());
1376 csv.push_str(&format!("{label},{index},1,{index},1,1,{index}\n"));
1377 gpu_csv.push_str(&format!("kernel/{index},{index},1,{index}\n"));
1378 }
1379 fs::write(dir.join("many_nvtx_gpu_proj_trace.csv"), csv).unwrap();
1380 fs::write(dir.join("many_cuda_gpu_trace.csv"), gpu_csv).unwrap();
1381 let evidence = NsightEvidence::load(&dir, &expected).unwrap();
1382 assert_eq!(evidence.nvtx_ranges.len(), TIMELINE_DISPLAY_LIMIT);
1383 assert_eq!(evidence.nvtx_ranges.first().unwrap().name, "phase/0");
1384 assert_eq!(
1385 evidence.nvtx_ranges.last().unwrap().name,
1386 format!("phase/{}", TIMELINE_DISPLAY_LIMIT - 1)
1387 );
1388 let omitted_label = format!("phase/{TIMELINE_DISPLAY_LIMIT}");
1389 assert!(!evidence
1390 .nvtx_ranges
1391 .iter()
1392 .any(|row| row.name == omitted_label));
1393 assert_eq!(
1394 evidence.correlation.ledger.observed.len(),
1395 TIMELINE_DISPLAY_LIMIT + 1
1396 );
1397 assert!(evidence.correlation.complete);
1398 let _ = fs::remove_dir_all(dir);
1399 }
1400
1401 #[test]
1402 fn manifest_hash_mismatch_does_not_discard_gpu_evidence() {
1403 let dir = temp_dir("manifest-mismatch");
1404 let csv_path = dir.join("sample_cuda_gpu_kern_sum.csv");
1405 fs::write(&csv_path, "Total Time (ns),Instances,Name\n1200,3,gemm\n").unwrap();
1406 let manifest = CaptureManifest {
1407 schema: CAPTURE_MANIFEST_SCHEMA.into(),
1408 run: CaptureRun {
1409 id: "run-1".into(),
1410 started_at: None,
1411 },
1412 correlation: CaptureCorrelation {
1413 id: "update-1".into(),
1414 },
1415 tool: CaptureTool {
1416 name: "nsys".into(),
1417 version: "test".into(),
1418 },
1419 commands: vec!["nsys profile app".into()],
1420 hardware: CaptureHardware::default(),
1421 source_revisions: BTreeMap::from([("app".into(), "abc123".into())]),
1422 required_semantic_labels: Vec::new(),
1423 gpu_expected_semantic_labels: Vec::new(),
1424 cpu_only_semantic_labels: Vec::new(),
1425 artifacts: vec![ManifestArtifact {
1426 path: "sample_cuda_gpu_kern_sum.csv".into(),
1427 size_bytes: fs::metadata(&csv_path).unwrap().len(),
1428 sha256: "0".repeat(64),
1429 }],
1430 };
1431 fs::write(
1432 dir.join(CAPTURE_MANIFEST),
1433 serde_json::to_vec_pretty(&manifest).unwrap(),
1434 )
1435 .unwrap();
1436 let evidence = NsightEvidence::load(&dir, &[]).unwrap();
1437 assert_eq!(evidence.status, GpuEvidenceStatus::Available);
1438 assert_eq!(
1439 evidence.provenance.binding,
1440 ProvenanceBindingState::Mismatch
1441 );
1442 assert!(evidence
1443 .provenance
1444 .diagnostics
1445 .iter()
1446 .any(|message| message.contains("SHA-256 mismatch")));
1447 let _ = fs::remove_dir_all(dir);
1448 }
1449
1450 #[test]
1451 fn valid_manifest_becomes_bound_only_after_trace_ids_match() {
1452 let dir = temp_dir("manifest-bound");
1453 let raw_path = dir.join("capture.nsys-rep");
1454 let csv_path = dir.join("sample_cuda_gpu_kern_sum.csv");
1455 fs::write(&raw_path, b"raw report").unwrap();
1456 fs::write(&csv_path, "Total Time (ns),Instances,Name\n1200,3,gemm\n").unwrap();
1457 let artifacts = [&raw_path, &csv_path]
1458 .into_iter()
1459 .map(|path| {
1460 let artifact = artifact_metadata(&dir, path).unwrap();
1461 ManifestArtifact {
1462 path: artifact.path,
1463 size_bytes: artifact.size_bytes,
1464 sha256: artifact.sha256,
1465 }
1466 })
1467 .collect();
1468 let manifest = CaptureManifest {
1469 schema: CAPTURE_MANIFEST_SCHEMA.into(),
1470 run: CaptureRun {
1471 id: "run-1".into(),
1472 started_at: None,
1473 },
1474 correlation: CaptureCorrelation {
1475 id: "update-1".into(),
1476 },
1477 tool: CaptureTool {
1478 name: "nsys".into(),
1479 version: "test".into(),
1480 },
1481 commands: vec!["nsys profile app".into()],
1482 hardware: CaptureHardware::default(),
1483 source_revisions: BTreeMap::new(),
1484 required_semantic_labels: vec![],
1485 gpu_expected_semantic_labels: Vec::new(),
1486 cpu_only_semantic_labels: Vec::new(),
1487 artifacts,
1488 };
1489 fs::write(
1490 dir.join(CAPTURE_MANIFEST),
1491 serde_json::to_vec_pretty(&manifest).unwrap(),
1492 )
1493 .unwrap();
1494
1495 let mut evidence = NsightEvidence::load(&dir, &[]).unwrap();
1496 assert_eq!(evidence.provenance.binding, ProvenanceBindingState::Partial);
1497 evidence.bind_to_trace("run-1", "update-1");
1498 assert_eq!(evidence.provenance.binding, ProvenanceBindingState::Bound);
1499 let _ = fs::remove_dir_all(dir);
1500 }
1501
1502 #[test]
1503 fn overlapping_gpu_operations_are_union_attributed_to_each_phase() {
1504 let dir = temp_dir("phase-overlap");
1505 fs::write(
1506 dir.join("sample_nvtx_gpu_proj_trace.csv"),
1507 "Name,Start (ns),Duration (ns),Projected Start (ns),Projected Duration (ns),Num GPU Ops,CorrId\nphase/a,1,100,0,100,2,1\nphase/b,2,50,25,50,2,2\n",
1508 )
1509 .unwrap();
1510 fs::write(
1511 dir.join("sample_cuda_gpu_trace.csv"),
1512 "Name,Start (ns),Duration (ns),Device,Stream,CorrId\nkernel/a1,0,70,0,1,1\nkernel/a2,40,60,0,2,1\nkernel/b1,0,70,0,1,2\nkernel/b2,40,60,0,2,2\nunrelated,30,20,0,3,3\n",
1513 )
1514 .unwrap();
1515 let evidence = NsightEvidence::load(&dir, &["phase/a".into(), "phase/b".into()]).unwrap();
1516 assert!(!evidence.correlation.clock_aligned);
1517 assert!(!evidence.phase_attribution[0].clock_aligned);
1518 assert_eq!(evidence.phase_attribution[0].gpu_busy_ns, 100);
1519 assert_eq!(evidence.phase_attribution[0].gpu_operation_count, 2);
1520 assert_eq!(evidence.phase_attribution[1].gpu_busy_ns, 50);
1521 assert_eq!(evidence.phase_attribution[1].gpu_operation_count, 2);
1522 assert!(evidence
1523 .phase_attribution
1524 .iter()
1525 .all(|phase| { phase.join_keys == vec!["correlation_id"] }));
1526 let _ = fs::remove_dir_all(dir);
1527 }
1528}