#!/usr/bin/env python3 """Compare two nsys .nsys-rep profiles for >20% per-epoch wall-time regression. Plan 5 Task 3 (A.4.1) — perf-regression detector. V0 implementation: total GPU wall-time / epoch_count. The training loop does not yet emit explicit NVTX `epoch=N` ranges that nsys could bucket on, so we treat the median of (total_gpu_time / num_epochs) as the per-epoch estimate. This is a coarser measure than per-epoch ranges but is robust against trace parsing edge cases and is the same comparison the multi-seed wall-time sanity check would emit. A V1 follow-up (Plan 5 Task 5) can add NVTX push/pop ranges in `training_loop.rs` and switch this script to `nsys stats --report nvtx_pushpop_trace` for true per-epoch granularity. Usage: compare-nsys-profiles.py BASELINE.nsys-rep CURRENT.nsys-rep [--epochs N] Exit codes: 0 — within 20% of baseline (PASS) 1 — >20% per-epoch regression (FAIL) 2 — invalid input / nsys failure (ERROR) """ from __future__ import annotations import argparse import csv import io import sys from pathlib import Path from subprocess import CalledProcessError, run REGRESSION_THRESHOLD = 1.20 # 20% slowdown trips FAIL. def extract_total_gpu_time_ms(nsys_rep: Path) -> float: """Return total GPU kernel wall-time in ms across the entire trace. Parses `nsys stats --report cuda_gpu_kern_sum --format csv` and sums the Total Time column. nsys emits time in ns by default; we normalise to ms so the comparison is invariant to nsys's display-unit quirks. """ if not nsys_rep.exists(): raise FileNotFoundError(f"nsys-rep not found: {nsys_rep}") try: proc = run( [ "nsys", "stats", "--report", "cuda_gpu_kern_sum", "--format", "csv", str(nsys_rep), ], capture_output=True, text=True, check=True, ) except FileNotFoundError as exc: raise RuntimeError( "nsys binary not found in PATH — install nsight-systems-cli" ) from exc except CalledProcessError as exc: raise RuntimeError( f"nsys stats failed for {nsys_rep}: {exc.stderr.strip()}" ) from exc # nsys emits header + comment lines before the CSV. Find the header row # ("Time (%),Total Time...") and parse the rest. csv_lines: list[str] = [] seen_header = False for line in proc.stdout.splitlines(): if not seen_header: if line.startswith('"Time (%)"') or line.startswith("Time (%)"): seen_header = True csv_lines.append(line) continue csv_lines.append(line) if not csv_lines: raise RuntimeError( f"could not locate CSV header in nsys stats output for {nsys_rep}" ) reader = csv.DictReader(io.StringIO("\n".join(csv_lines))) total_ns = 0.0 rows_parsed = 0 for row in reader: # Column key tolerates both "Total Time" and "Total Time (ns)" forms # across nsys versions. for key in row: if key and key.startswith("Total Time"): try: total_ns += float(row[key].replace(",", "")) rows_parsed += 1 except (ValueError, AttributeError): continue break if rows_parsed == 0: raise RuntimeError( f"no Total Time rows parsed from {nsys_rep} — empty trace?" ) return total_ns / 1_000_000.0 # ns -> ms def per_epoch_ms(nsys_rep: Path, epochs: int) -> float: """Per-epoch wall-time estimate (V0: total / epochs).""" if epochs <= 0: raise ValueError(f"epochs must be > 0, got {epochs}") return extract_total_gpu_time_ms(nsys_rep) / epochs def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description=( "Compare per-epoch GPU wall-time between two nsys profiles; " "fail on >20% regression." ) ) parser.add_argument("baseline", type=Path, help="Baseline .nsys-rep path") parser.add_argument("current", type=Path, help="Current .nsys-rep path") parser.add_argument( "--epochs", type=int, default=5, help=( "Epoch count both profiles ran (default 5 — matches " "multi_fold_convergence smoke). Both runs MUST use the same " "epoch count — comparing different epoch budgets is meaningless." ), ) args = parser.parse_args(argv) try: b_per_epoch = per_epoch_ms(args.baseline, args.epochs) c_per_epoch = per_epoch_ms(args.current, args.epochs) except (FileNotFoundError, RuntimeError, ValueError) as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 ratio = c_per_epoch / b_per_epoch if b_per_epoch > 0 else float("inf") summary = ( f"baseline={b_per_epoch:.1f}ms/epoch " f"current={c_per_epoch:.1f}ms/epoch " f"ratio={ratio:.3f}x " f"(epochs={args.epochs})" ) if ratio > REGRESSION_THRESHOLD: print( f"FAIL: per-epoch regression {ratio:.2f}x exceeds " f"{REGRESSION_THRESHOLD:.2f}x threshold | {summary}" ) return 1 print(f"PASS: per-epoch within {REGRESSION_THRESHOLD:.2f}x | {summary}") return 0 if __name__ == "__main__": sys.exit(main())