Files
project_6/cccl_upstream/ci/compile_time/summarize_events.py
muh-bot 2a7ca101d7 feat(cccl): integrate missing CCCL directories — python/, ci/, .agent/, docs/, test/
Sparse-checkout from NVIDIA/cccl main branch to complete cccl_upstream:

Added:
- python/cuda_cccl/ (226 files) — Python bindings for device-level algorithms
  Critical for muh toolchain: cuda.compute.reduce_into, scan, radix_sort, etc.
  Includes 204 .py files with full test coverage for all 27 algorithms
- ci/ (163 files) — Build/test infrastructure
  build_cub.sh, test_cub.sh, build_and_test_targets.sh, matrix.yaml
  Directly maps to our [INFRA-CI] and [INFRA-BUILD] items
- .agent/skills/ (7 files) — NVIDIA's own agent skills for CCCL
  cccl-style/SKILL.md, cccl-test/SKILL.md, sass-diff/SKILL.md
- docs/ (491 files) — Official CCCL documentation
  CI references, CMake guides, Python compute docs, libcudacxx PTX docs
- test/ (12 files) — Top-level integration tests (cuda_smoke, stdpar)
- Root configs: .clang-format, .clang-tidy, CONTRIBUTING.md, pyproject.toml
- CLAUDE.md symlink → AGENTS.md (NVIDIA's standard)

cccl_upstream now mirrors full NVIDIA/cccl structure:
  Before: 42M (cub + thrust + libcudacxx + cudax + c + examples + benchmarks)
  After:  53M (+python +ci +docs +.agent +test +configs)

This completes the CCCL base needed for:
- [muh-bench] items: ci/util/build_and_test_targets.sh for targeted builds
- [CCCL-verify] items: python/cuda_cccl/tests/ as reference implementations
- [CCCL-test] items: ci/test_cub.sh, ci/test_thrust.sh
- Agent workflow: .agent/skills/ for consistent style and test patterns
2026-08-07 02:34:33 +00:00

1719 lines
54 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import csv
import heapq
import json
import re
from collections import defaultdict
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Callable
DEFAULT_SCOPE_FILTER = r"(^|[^A-Za-z0-9_:])(?:::)?(?:cuda|thrust|cub|cccl)::"
SYMBOL_SCOPE_EVENT_NAMES = {
"Scanning Function Body",
"Instantiating Template Class",
"Instantiating Template Function",
"Generating Function IR",
"OptFunction",
}
ITANIUM_CV_QUALIFIERS = frozenset("KOVR")
@dataclass(frozen=True)
class FilterSpec:
label: str
description: str
matches: Callable[["TraceEvent"], bool]
default_exclusive_scope: str = "all"
@dataclass(frozen=True)
class ReportConfig:
slice_id: str
title: str
spec: FilterSpec
timing: str
exclusive_scope: str
sort_by: str
top_n: int
tag: str | None
threshold_us: float = 0.0
scope_filter: re.Pattern[str] | None = None
@dataclass
class TraceEvent:
name: str
detail: str
start_us: int
end_us: int
pid: int
tid: int
root_tu: str
synthetic: bool = False
children: list["TraceEvent"] = field(default_factory=list)
@property
def inclusive_us(self) -> int:
return self.end_us - self.start_us
def key(self, repo_root: Path) -> str:
if self.detail:
return normalize_detail(self.detail, repo_root)
return self.name
@dataclass
class EventStats:
event_name: str
event_key: str
event_count: int = 0
total_inclusive_us: int = 0
total_exclusive_us: int = 0
max_inclusive_us: int = 0
max_exclusive_us: int = 0
trace_paths: set[str] = field(default_factory=set)
root_tus: set[str] = field(default_factory=set)
@dataclass
class ComparisonStats:
event_name: str
event_key: str
baseline: EventStats
current: EventStats
matched_trace_paths: set[str] = field(default_factory=set)
@dataclass(frozen=True)
class ComparisonRow:
stats: ComparisonStats
baseline_impact_us: float
current_impact_us: float
baseline_metric_us: float
current_metric_us: float
impact_magnitude_us: float
@property
def impact_delta_us(self) -> float:
return self.current_impact_us - self.baseline_impact_us
@property
def selected_delta_us(self) -> float:
return self.current_metric_us - self.baseline_metric_us
@property
def selected_magnitude_us(self) -> float:
return abs(self.selected_delta_us)
@dataclass(frozen=True)
class ComparisonSide:
name: str
repo_root: Path
events: list[TraceEvent]
report_ids: set[tuple[str, str]]
child_ids: set[tuple[str, str]]
@dataclass(frozen=True)
class ComparisonInput:
name: str
trace_paths: dict[Path, Path]
repo_root: Path
@dataclass(frozen=True)
class ReportSide:
name: str
trace_paths: list[Path]
repo_root: Path
output_dir: Path
@dataclass(frozen=True)
class SliceRequest:
config: ReportConfig
filter_name: str
children: tuple["SliceRequest", ...] = ()
def merged_interval_duration(intervals: list[tuple[int, int]]) -> int:
if not intervals:
return 0
sorted_intervals = sorted(intervals)
total = 0
merged_start, merged_end = sorted_intervals[0]
for start, end in sorted_intervals[1:]:
if start > merged_end:
total += merged_end - merged_start
merged_start, merged_end = start, end
else:
merged_end = max(merged_end, end)
total += merged_end - merged_start
return total
def generated_tu_input(tu: str) -> str:
marker = "/headers/"
if marker not in tu:
return tu
rel = tu.split(marker, 1)[1]
parts = rel.split("/", 1)
if len(parts) != 2:
return tu
tu_input = parts[1]
for suffix in (".cu", ".cpp", ".cxx", ".cc", ".c"):
if tu_input.endswith(suffix):
return tu_input[: -len(suffix)]
return tu_input
def normalize_detail(detail: str, repo_root: Path) -> str:
detail_path = Path(detail)
if detail_path.is_absolute():
try:
detail = detail_path.resolve(strict=False).relative_to(repo_root).as_posix()
except ValueError:
pass
return detail
def normalize_project_file(detail: str, repo_root: Path) -> str | None:
detail_path = Path(detail)
if not detail_path.is_absolute():
return None
try:
detail = detail_path.resolve(strict=False).relative_to(repo_root).as_posix()
except ValueError:
return None
if detail.startswith("build/"):
return None
return detail
def general_event_identity(event: TraceEvent, repo_root: Path) -> tuple[str, str]:
return (event.name, event.key(repo_root))
def strip_angle_arguments(symbol: str) -> str:
stripped: list[str] = []
depth = 0
for char in symbol:
if char == "<":
depth += 1
continue
if char == ">" and depth:
depth -= 1
continue
if depth == 0:
stripped.append(char)
return "".join(stripped)
def symbol_name_prefix(symbol: str) -> str:
before_parameters = strip_angle_arguments(symbol).split("(", 1)[0].strip()
if not before_parameters:
return symbol
if "::operator" in before_parameters:
operator_scope = before_parameters.rfind("::operator")
prefix_start = before_parameters.rfind(" ", 0, operator_scope)
return before_parameters[prefix_start + 1 :]
return before_parameters.rsplit(None, 1)[-1]
def itanium_nested_scope_candidates(symbol: str) -> list[str]:
if not symbol.startswith("_Z"):
return []
candidates: list[str] = []
for nested_marker in (i for i, char in enumerate(symbol) if char == "N"):
index = nested_marker + 1
while index < len(symbol) and symbol[index] in ITANIUM_CV_QUALIFIERS:
index += 1
scopes: list[str] = []
while index < len(symbol) and symbol[index].isdigit():
length_start = index
while index < len(symbol) and symbol[index].isdigit():
index += 1
try:
component_length = int(symbol[length_start:index])
except ValueError:
break
component = symbol[index : index + component_length]
if len(component) != component_length:
break
scopes.append(component)
candidates.append("::".join(scopes) + "::")
index += component_length
return candidates
def symbol_scope_candidates(event: TraceEvent) -> list[str]:
if event.name not in SYMBOL_SCOPE_EVENT_NAMES or not event.detail:
return []
candidates = [symbol_name_prefix(event.detail)]
if " [" in event.detail:
_, bracketed_symbol = event.detail.split(" [", 1)
candidates.append(symbol_name_prefix(bracketed_symbol.rstrip("]")))
for candidate in list(candidates):
candidates.extend(itanium_nested_scope_candidates(candidate))
return candidates
def matches_scope_filter(
event: TraceEvent, scope_filter: re.Pattern[str] | None
) -> bool:
if scope_filter is None or event.name not in SYMBOL_SCOPE_EVENT_NAMES:
return True
return any(
scope_filter.search(candidate) for candidate in symbol_scope_candidates(event)
)
def matches_report_config(event: TraceEvent, config: ReportConfig) -> bool:
return config.spec.matches(event) and matches_scope_filter(
event, config.scope_filter
)
def report_event_identity(
event: TraceEvent, config: ReportConfig, repo_root: Path
) -> tuple[str, str] | None:
if not matches_report_config(event, config):
return None
if config.spec.label == "file-processing":
event_key = normalize_project_file(event.detail, repo_root)
if event_key is None:
return None
else:
event_key = event.key(repo_root)
return (event.name, event_key)
def filter_event_identity(
event: TraceEvent, config: ReportConfig, repo_root: Path
) -> tuple[str, str] | None:
if not matches_report_config(event, config):
return None
return general_event_identity(event, repo_root)
def trace_root_tu(trace: dict, trace_path: Path) -> str:
input_files = trace.get("otherData", {}).get("inputFiles", [])
if input_files:
return generated_tu_input(input_files[0])
return trace_path.as_posix()
def iter_trace_paths(trace_dir: Path) -> list[Path]:
return sorted(p for p in trace_dir.rglob("*.json") if p.is_file())
def iter_duration_events(trace_path: Path, repo_root: Path) -> list[TraceEvent]:
with trace_path.open(encoding="utf-8") as f:
trace = json.load(f)
root_tu = normalize_detail(trace_root_tu(trace, trace_path), repo_root)
events: list[TraceEvent] = []
for event in trace.get("traceEvents", []):
if event.get("ph") not in (None, "X"):
continue
if "ts" not in event or "dur" not in event:
continue
name = str(event.get("name", ""))
if not name:
continue
args = event.get("args", {})
detail = ""
if isinstance(args, dict):
detail = str(args.get("detail", "") or "")
start_us = int(event["ts"])
dur_us = int(event["dur"])
events.append(
TraceEvent(
name=name,
detail=detail,
start_us=start_us,
end_us=start_us + dur_us,
pid=int(event.get("pid", 0)),
tid=int(event.get("tid", 0)),
root_tu=root_tu,
)
)
if events:
trace_start_us = min(event.start_us for event in events)
trace_end_us = max(event.end_us for event in events)
events.append(
TraceEvent(
name="Total Compilation Time",
detail=root_tu,
start_us=trace_start_us,
end_us=trace_end_us,
pid=-1,
tid=-1,
root_tu=root_tu,
synthetic=True,
)
)
return events
def link_child_events(events: list[TraceEvent]) -> None:
grouped: dict[tuple[int, int], list[TraceEvent]] = defaultdict(list)
for event in events:
grouped[(event.pid, event.tid)].append(event)
for thread_events in grouped.values():
stack: list[TraceEvent] = []
for event in sorted(thread_events, key=lambda e: (e.start_us, -e.end_us)):
# Example: A=[0,10], B=[10,20]. A ended before B starts, so it is
# not B's parent.
while stack and stack[-1].end_us <= event.start_us:
stack.pop()
# Example: A=[0,100], B=[50,150]. B overlaps A but is not fully
# contained by A, so A is not B's parent.
while stack and not (
stack[-1].start_us <= event.start_us
and event.end_us <= stack[-1].end_us
):
stack.pop()
# Example: A=[0,100], B=[0,100]. Identical-span events are not
# treated as nested children of each other.
if stack and (
stack[-1].start_us != event.start_us or stack[-1].end_us != event.end_us
):
stack[-1].children.append(event)
stack.append(event)
def read_trace_events(trace_path: Path, repo_root: Path) -> list[TraceEvent]:
events = iter_duration_events(trace_path, repo_root)
link_child_events(events)
return events
def event_name_filter(
*, label: str, description: str, event_names: tuple[str, ...]
) -> FilterSpec:
name_set = set(event_names)
return FilterSpec(
label=label,
description=description,
matches=lambda event: event.name in name_set,
)
def any_event_filter() -> FilterSpec:
return FilterSpec(
label="all",
description="all raw duration events",
matches=lambda event: not event.synthetic,
)
def regex_filter(pattern: str) -> FilterSpec:
compiled = re.compile(pattern, re.IGNORECASE)
return FilterSpec(
label=f"regex-{slugify(pattern)}",
description=f"event name or detail matches /{pattern}/i",
matches=lambda event: (
bool(compiled.search(event.name)) or bool(compiled.search(event.detail))
)
and not event.synthetic,
)
def builtin_filters() -> dict[str, FilterSpec]:
filters: dict[str, FilterSpec] = {}
def add(spec: FilterSpec) -> None:
filters[spec.label] = spec
add(any_event_filter())
add(
FilterSpec(
label="file-processing",
description=(
"PHF trace events; exclusive time subtracts nested PHF events "
"to match the direct file-processing metric"
),
matches=lambda event: event.name == "Processing Header File",
default_exclusive_scope="same-filter",
),
)
add(
event_name_filter(
label="scanning-function-body",
description="Scanning Function Body events",
event_names=("Scanning Function Body",),
),
)
add(
event_name_filter(
label="template-instantiation",
description="template class/function instantiation events",
event_names=(
"Instantiating Template Class",
"Instantiating Template Function",
),
),
)
add(
event_name_filter(
label="template-class-instantiation",
description="template class instantiation events",
event_names=("Instantiating Template Class",),
),
)
add(
event_name_filter(
label="template-function-instantiation",
description="template function instantiation events",
event_names=("Instantiating Template Function",),
),
)
add(
event_name_filter(
label="pending-instantiations",
description="pending template instantiation phase events",
event_names=("Generating Needed Template Instantiations",),
),
)
add(
event_name_filter(
label="frontend",
description="front-end phase events",
event_names=(
"Front End Cleanup",
"CUDA C++ Front-End",
),
),
)
add(
event_name_filter(
label="host-compiler",
description="host compiler preprocessing and compiling events",
event_names=(
"g++ (preprocessing 1)",
"g++ (preprocessing 4)",
"g++ (compiling)",
"gcc (preprocessing 1)",
"gcc (preprocessing 4)",
"gcc (compiling)",
),
),
)
add(
event_name_filter(
label="code-generation",
description="code generation events",
event_names=(
"Generating Function IR",
"Generating NVVM IR",
"NVVM CodeGen",
),
),
)
add(
event_name_filter(
label="optimizer",
description="optimizer events",
event_names=(
"OptFunction",
"NVVM Optimizer",
),
),
)
add(
event_name_filter(
label="total-compilation",
description=(
"synthetic per-trace wall-clock span from first to last timed "
"trace event"
),
event_names=("Total Compilation Time",),
),
)
return filters
def resolve_filter(filter_name: str) -> FilterSpec:
filters = builtin_filters()
normalized = filter_name.strip().lower()
if normalized in filters:
return filters[normalized]
return regex_filter(filter_name)
def exclusive_child_events(event: TraceEvent, config: ReportConfig) -> list[TraceEvent]:
if config.exclusive_scope == "all":
return event.children
if config.exclusive_scope == "same-filter":
return [
child for child in event.children if matches_report_config(child, config)
]
raise ValueError(f"unknown exclusive scope: {config.exclusive_scope}")
def event_exclusive_us(event: TraceEvent, config: ReportConfig) -> int:
child_intervals = [
(child.start_us, child.end_us)
for child in exclusive_child_events(event, config)
]
return max(0, event.inclusive_us - merged_interval_duration(child_intervals))
def collect_stats(
trace_paths: list[Path],
repo_root: Path,
config: ReportConfig,
) -> dict[tuple[str, str], EventStats]:
stats: dict[tuple[str, str], EventStats] = {}
for trace_path in trace_paths:
events = read_trace_events(trace_path, repo_root)
collect_trace_stats(
stats,
events,
trace_path.as_posix(),
repo_root,
report_config=config,
exclusive_config=config,
)
return stats
def collect_trace_stats(
stats: dict[tuple[str, str], EventStats],
events: list[TraceEvent],
trace_path: str,
repo_root: Path,
*,
report_config: ReportConfig,
exclusive_config: ReportConfig,
) -> None:
for event in events:
identity = report_event_identity(event, report_config, repo_root)
if identity is None:
continue
add_event_stats(
stats,
identity,
event.inclusive_us,
event_exclusive_us(event, exclusive_config),
trace_path,
event.root_tu,
)
def add_event_stats(
stats: dict[tuple[str, str], EventStats],
identity: tuple[str, str],
inclusive_us: int,
exclusive_us: int,
trace_path: str,
root_tu: str,
) -> None:
event_name, event_key = identity
event_stats = stats.setdefault(
identity, EventStats(event_name=event_name, event_key=event_key)
)
merge_event_stats(
event_stats,
EventStats(
event_name=event_name,
event_key=event_key,
event_count=1,
total_inclusive_us=inclusive_us,
total_exclusive_us=exclusive_us,
max_inclusive_us=inclusive_us,
max_exclusive_us=exclusive_us,
trace_paths={trace_path},
root_tus={root_tu},
),
)
def selected_total_us(stats: EventStats, timing: str) -> int:
if timing == "inclusive":
return stats.total_inclusive_us
if timing == "exclusive":
return stats.total_exclusive_us
raise ValueError(f"unknown timing: {timing}")
def average_us(total_us: int, count: int) -> float:
if count == 0:
return 0.0
return total_us / count
def selected_avg_us(stats: EventStats, timing: str) -> float:
return average_us(selected_total_us(stats, timing), stats.event_count)
def selected_avg_per_root_tu_us(stats: EventStats, timing: str) -> float:
return average_us(selected_total_us(stats, timing), len(stats.root_tus))
def selected_max_us(stats: EventStats, timing: str) -> int:
if timing == "inclusive":
return stats.max_inclusive_us
if timing == "exclusive":
return stats.max_exclusive_us
raise ValueError(f"unknown timing: {timing}")
def selected_metric_us(stats: EventStats, timing: str, sort_by: str) -> float:
if sort_by == "total":
return float(selected_total_us(stats, timing))
if sort_by == "avg":
return selected_avg_us(stats, timing)
if sort_by == "avg-root-tu":
return selected_avg_per_root_tu_us(stats, timing)
if sort_by == "max":
return float(selected_max_us(stats, timing))
raise ValueError(f"unknown sort: {sort_by}")
def trace_paths_by_relative_root(trace_dir: Path) -> dict[Path, Path]:
return {path.relative_to(trace_dir): path for path in iter_trace_paths(trace_dir)}
def comparison_input(name: str, trace_dir: Path, repo_root: Path) -> ComparisonInput:
return ComparisonInput(
name=name,
trace_paths=trace_paths_by_relative_root(trace_dir),
repo_root=repo_root,
)
def comparable_child_identities(
events: list[TraceEvent],
repo_root: Path,
config: ReportConfig,
) -> set[tuple[str, str]]:
if config.exclusive_scope == "all":
return {general_event_identity(event, repo_root) for event in events}
if config.exclusive_scope == "same-filter":
return {
identity
for event in events
if (identity := filter_event_identity(event, config, repo_root)) is not None
}
raise ValueError(f"unknown exclusive scope: {config.exclusive_scope}")
def comparable_report_filter(
config: ReportConfig,
repo_root: Path,
comparable_report_ids: set[tuple[str, str]],
) -> FilterSpec:
def matches(event: TraceEvent) -> bool:
identity = report_event_identity(event, config, repo_root)
return identity is not None and identity in comparable_report_ids
return replace(config.spec, matches=matches)
def comparable_child_filter(
config: ReportConfig,
repo_root: Path,
comparable_child_ids: set[tuple[str, str]],
) -> FilterSpec:
def child_identity(event: TraceEvent) -> tuple[str, str] | None:
if config.exclusive_scope == "all":
return general_event_identity(event, repo_root)
if config.exclusive_scope == "same-filter":
return filter_event_identity(event, config, repo_root)
raise ValueError(f"unknown exclusive scope: {config.exclusive_scope}")
def matches(event: TraceEvent) -> bool:
identity = child_identity(event)
return identity is not None and identity in comparable_child_ids
return replace(config.spec, matches=matches)
def merge_event_stats(target: EventStats, source: EventStats) -> None:
target.event_count += source.event_count
target.total_inclusive_us += source.total_inclusive_us
target.total_exclusive_us += source.total_exclusive_us
target.max_inclusive_us = max(target.max_inclusive_us, source.max_inclusive_us)
target.max_exclusive_us = max(target.max_exclusive_us, source.max_exclusive_us)
target.trace_paths.update(source.trace_paths)
target.root_tus.update(source.root_tus)
def merge_comparison_side_stats(
comparison_stats: dict[tuple[str, str], ComparisonStats],
side_name: str,
side_stats: dict[tuple[str, str], EventStats],
) -> None:
for identity, source_stats in side_stats.items():
event_name, event_key = identity
comparison = comparison_stats.setdefault(
identity,
ComparisonStats(
event_name=event_name,
event_key=event_key,
baseline=EventStats(event_name, event_key),
current=EventStats(event_name, event_key),
),
)
target_stats = getattr(comparison, side_name)
merge_event_stats(target_stats, source_stats)
comparison.matched_trace_paths.update(source_stats.trace_paths)
def read_comparison_side(
name: str,
trace_path: Path,
repo_root: Path,
config: ReportConfig,
) -> ComparisonSide:
events = read_trace_events(trace_path, repo_root)
report_ids = {
identity
for event in events
if (identity := report_event_identity(event, config, repo_root)) is not None
}
child_ids = comparable_child_identities(events, repo_root, config)
return ComparisonSide(
name=name,
repo_root=repo_root,
events=events,
report_ids=report_ids,
child_ids=child_ids,
)
def collect_comparison_stats(
baseline_trace_dir: Path,
current_trace_dir: Path,
baseline_repo_root: Path,
current_repo_root: Path,
config: ReportConfig,
) -> tuple[dict[tuple[str, str], ComparisonStats], int]:
comparison_inputs = (
comparison_input("baseline", baseline_trace_dir, baseline_repo_root),
comparison_input("current", current_trace_dir, current_repo_root),
)
matched_rel_paths = sorted(
set.intersection(
*(
set(comparison_input.trace_paths)
for comparison_input in comparison_inputs
)
)
)
comparison_stats: dict[tuple[str, str], ComparisonStats] = {}
for rel_path in matched_rel_paths:
sides = tuple(
read_comparison_side(
comparison_input.name,
comparison_input.trace_paths[rel_path],
comparison_input.repo_root,
config,
)
for comparison_input in comparison_inputs
)
comparable_report_ids = set.intersection(*(side.report_ids for side in sides))
if not comparable_report_ids:
continue
comparable_child_ids = set.intersection(*(side.child_ids for side in sides))
rel_path_str = rel_path.as_posix()
for side in sides:
report_config = replace(
config,
spec=comparable_report_filter(
config, side.repo_root, comparable_report_ids
),
)
exclusive_config = replace(
config,
spec=comparable_child_filter(
config, side.repo_root, comparable_child_ids
),
exclusive_scope="same-filter",
scope_filter=(
config.scope_filter
if config.exclusive_scope == "same-filter"
else None
),
)
side_stats: dict[tuple[str, str], EventStats] = {}
collect_trace_stats(
side_stats,
side.events,
rel_path_str,
side.repo_root,
report_config=report_config,
exclusive_config=exclusive_config,
)
merge_comparison_side_stats(comparison_stats, side.name, side_stats)
return comparison_stats, len(matched_rel_paths)
def sorted_rows(
stats: dict[tuple[str, str], EventStats], config: ReportConfig
) -> list[EventStats]:
if config.sort_by not in ("total", "avg", "avg-root-tu", "max"):
raise ValueError(f"unknown sort: {config.sort_by}")
# Python has heapq.nsmallest rather than a C++-style partial_sort. The
# selected metric is negated here so "smallest" means "largest selected
# time", while the string tie-breakers keep their natural ascending order.
return heapq.nsmallest(
config.top_n,
stats.values(),
key=lambda item: (
-selected_metric_us(item, config.timing, config.sort_by),
item.event_name,
item.event_key,
),
)
def seconds(us: float | int) -> str:
return f"{us / 1_000_000.0:.6f}"
def slugify(value: str) -> str:
slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip()).strip("-").lower()
return slug or "report"
def default_output_path(
output_dir: Path,
config: ReportConfig,
) -> Path:
pieces = ["top", str(config.top_n), config.spec.label, config.timing]
if config.timing == "exclusive":
pieces.append(config.exclusive_scope)
pieces.append(f"by-{config.sort_by}")
if config.tag:
pieces.append(slugify(config.tag))
return output_dir / ("-".join(slugify(piece) for piece in pieces) + ".csv")
def comparison_output_path(
output_dir: Path,
config: ReportConfig,
direction: str,
) -> Path:
pieces = ["top", str(config.top_n), config.spec.label, config.timing]
if config.timing == "exclusive":
pieces.append(config.exclusive_scope)
pieces.extend([f"by-{config.sort_by}", direction])
if config.tag:
pieces.append(slugify(config.tag))
return output_dir / ("-".join(slugify(piece) for piece in pieces) + ".csv")
def event_stats_csv_row(rank: int, row: EventStats, timing: str) -> dict[str, object]:
root_tu_count = len(row.root_tus)
return {
"rank": rank,
"event_name": row.event_name,
"event_key": row.event_key,
"selected_total_s": seconds(selected_total_us(row, timing)),
"selected_avg_per_event_s": seconds(selected_avg_us(row, timing)),
"selected_avg_per_root_tu_s": seconds(selected_avg_per_root_tu_us(row, timing)),
"selected_max_s": seconds(selected_max_us(row, timing)),
"total_inclusive_s": seconds(row.total_inclusive_us),
"avg_inclusive_per_event_s": seconds(
average_us(row.total_inclusive_us, row.event_count)
),
"avg_inclusive_per_root_tu_s": seconds(
average_us(row.total_inclusive_us, root_tu_count)
),
"max_inclusive_s": seconds(row.max_inclusive_us),
"total_exclusive_s": seconds(row.total_exclusive_us),
"avg_exclusive_per_event_s": seconds(
average_us(row.total_exclusive_us, row.event_count)
),
"avg_exclusive_per_root_tu_s": seconds(
average_us(row.total_exclusive_us, root_tu_count)
),
"max_exclusive_s": seconds(row.max_exclusive_us),
"event_count": row.event_count,
"trace_count": len(row.trace_paths),
"root_tu_count": root_tu_count,
}
def write_csv(
output_csv: Path,
rows: list[EventStats],
timing: str,
) -> None:
output_csv.parent.mkdir(parents=True, exist_ok=True)
with output_csv.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(
f,
fieldnames=[
"rank",
"event_name",
"event_key",
"selected_total_s",
"selected_avg_per_event_s",
"selected_avg_per_root_tu_s",
"selected_max_s",
"total_inclusive_s",
"avg_inclusive_per_event_s",
"avg_inclusive_per_root_tu_s",
"max_inclusive_s",
"total_exclusive_s",
"avg_exclusive_per_event_s",
"avg_exclusive_per_root_tu_s",
"max_exclusive_s",
"event_count",
"trace_count",
"root_tu_count",
],
)
writer.writeheader()
for rank, row in enumerate(rows, start=1):
writer.writerow(event_stats_csv_row(rank, row, timing))
def comparison_row_dict(
rank: int, row: ComparisonRow, timing: str
) -> dict[str, object]:
stats = row.stats
return {
"rank": rank,
"event_name": stats.event_name,
"event_key": stats.event_key,
"baseline_impact_s": seconds(row.baseline_impact_us),
"current_impact_s": seconds(row.current_impact_us),
"impact_delta_s": seconds(row.impact_delta_us),
"impact_magnitude_s": seconds(row.impact_magnitude_us),
"baseline_selected_s": seconds(row.baseline_metric_us),
"current_selected_s": seconds(row.current_metric_us),
"selected_delta_s": seconds(row.selected_delta_us),
"selected_magnitude_s": seconds(row.selected_magnitude_us),
"baseline_total_inclusive_s": seconds(stats.baseline.total_inclusive_us),
"current_total_inclusive_s": seconds(stats.current.total_inclusive_us),
"baseline_total_exclusive_s": seconds(stats.baseline.total_exclusive_us),
"current_total_exclusive_s": seconds(stats.current.total_exclusive_us),
"baseline_event_count": stats.baseline.event_count,
"current_event_count": stats.current.event_count,
"matched_trace_count": len(stats.matched_trace_paths),
}
def write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, sort_keys=True)
def report_side(
name: str,
trace_dir: Path,
repo_root: Path,
output_dir: Path,
) -> ReportSide:
trace_paths = iter_trace_paths(trace_dir)
if not trace_paths:
raise SystemExit(f"no JSON traces found under {trace_dir}")
return ReportSide(
name=name,
trace_paths=trace_paths,
repo_root=repo_root,
output_dir=output_dir,
)
def write_side_report(
side: ReportSide,
config: ReportConfig,
) -> tuple[Path, int]:
stats = collect_stats(side.trace_paths, side.repo_root, config)
rows = sorted_rows(stats, config) if stats else []
output_csv = default_output_path(side.output_dir, config)
write_csv(output_csv, rows, config.timing)
return output_csv, len(rows)
def comparison_rows(
stats: dict[tuple[str, str], ComparisonStats],
config: ReportConfig,
direction: str,
) -> list[ComparisonRow]:
if direction == "worse":
multiplier = 1
elif direction == "better":
multiplier = -1
else:
raise ValueError(f"unknown comparison direction: {direction}")
rows: list[ComparisonRow] = []
for comparison in stats.values():
baseline_impact = float(selected_total_us(comparison.baseline, config.timing))
current_impact = float(selected_total_us(comparison.current, config.timing))
baseline_metric = selected_metric_us(
comparison.baseline, config.timing, config.sort_by
)
current_metric = selected_metric_us(
comparison.current, config.timing, config.sort_by
)
delta = current_impact - baseline_impact
magnitude = multiplier * delta
if magnitude <= config.threshold_us:
continue
rows.append(
ComparisonRow(
comparison,
baseline_impact,
current_impact,
baseline_metric,
current_metric,
magnitude,
)
)
# Python has heapq.nsmallest rather than a C++-style partial_sort. The
# total-impact change is negated here so "smallest" means "largest
# requested aggregate movement across matched traces", while the string
# tie-breakers keep their natural ascending order.
return heapq.nsmallest(
config.top_n,
rows,
key=lambda row: (
-row.impact_magnitude_us,
row.stats.event_name,
row.stats.event_key,
),
)
def write_comparison_csv(
output_csv: Path,
rows: list[ComparisonRow],
timing: str,
) -> None:
output_csv.parent.mkdir(parents=True, exist_ok=True)
with output_csv.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(
f,
fieldnames=[
"rank",
"event_name",
"event_key",
"baseline_impact_s",
"current_impact_s",
"impact_delta_s",
"impact_magnitude_s",
"baseline_selected_s",
"current_selected_s",
"selected_delta_s",
"selected_magnitude_s",
"baseline_total_inclusive_s",
"current_total_inclusive_s",
"baseline_total_exclusive_s",
"current_total_exclusive_s",
"baseline_event_count",
"current_event_count",
"matched_trace_count",
],
)
writer.writeheader()
for rank, row in enumerate(rows, start=1):
writer.writerow(comparison_row_dict(rank, row, timing))
def print_filters() -> None:
for name, spec in sorted(builtin_filters().items()):
print(f"{name:32} {spec.description}")
def compile_scope_filter(
pattern: str, parser: argparse.ArgumentParser
) -> re.Pattern[str] | None:
if not pattern:
return None
try:
return re.compile(pattern)
except re.error as e:
parser.error(f"invalid --scope-filter regex: {e}")
def report_config(
*,
slice_id: str,
title: str,
filter_name: str,
timing: str,
exclusive_scope: str,
sort_by: str,
top_n: int,
tag: str | None,
threshold_s: float,
scope_filter: re.Pattern[str] | None,
) -> ReportConfig:
spec = resolve_filter(filter_name)
resolved_exclusive_scope = (
spec.default_exclusive_scope if exclusive_scope == "auto" else exclusive_scope
)
return ReportConfig(
slice_id=slice_id,
title=title,
spec=spec,
timing=timing,
exclusive_scope=resolved_exclusive_scope,
sort_by=sort_by,
top_n=top_n,
tag=tag,
threshold_us=threshold_s * 1_000_000.0,
scope_filter=scope_filter,
)
def single_slice_request(
args: argparse.Namespace, parser: argparse.ArgumentParser
) -> SliceRequest:
config = report_config(
slice_id=slugify(args.tag or args.filter),
title=args.tag or resolve_filter(args.filter).description,
filter_name=args.filter,
timing=args.timing,
exclusive_scope=args.exclusive_scope,
sort_by=args.sort,
top_n=args.top,
tag=args.tag,
threshold_s=args.threshold,
scope_filter=compile_scope_filter(args.scope_filter, parser),
)
return SliceRequest(config=config, filter_name=args.filter)
def require_slice_field(slice_data: dict[str, Any], field_name: str, path: str) -> Any:
if field_name not in slice_data:
raise ValueError(f"{path}: missing required field '{field_name}'")
return slice_data[field_name]
def validate_slice_id(value: Any, path: str) -> str:
if not isinstance(value, str) or not re.fullmatch(r"[a-z0-9][a-z0-9_.-]*", value):
raise ValueError(f"{path}: id must match ^[a-z0-9][a-z0-9_.-]*$")
return value
def slice_request_from_json(
slice_data: dict[str, Any],
parser: argparse.ArgumentParser,
*,
path: str,
seen_ids: set[str],
) -> SliceRequest:
if not isinstance(slice_data, dict):
raise ValueError(f"{path}: slice entry must be an object")
slice_id = validate_slice_id(require_slice_field(slice_data, "id", path), path)
if slice_id in seen_ids:
raise ValueError(f"{path}: duplicate slice id '{slice_id}'")
seen_ids.add(slice_id)
title = require_slice_field(slice_data, "title", path)
filter_name = require_slice_field(slice_data, "filter", path)
timing = require_slice_field(slice_data, "timing", path)
sort_by = require_slice_field(slice_data, "sort", path)
top_n = require_slice_field(slice_data, "top", path)
threshold = require_slice_field(slice_data, "threshold", path)
exclusive_scope = slice_data.get("exclusive_scope", "auto")
scope_filter_pattern = slice_data.get("scope_filter", DEFAULT_SCOPE_FILTER)
if not isinstance(title, str) or not title:
raise ValueError(f"{path}: title must be a non-empty string")
if not isinstance(filter_name, str) or not filter_name:
raise ValueError(f"{path}: filter must be a non-empty string")
if timing not in ("inclusive", "exclusive"):
raise ValueError(f"{path}: timing must be 'inclusive' or 'exclusive'")
if sort_by not in ("total", "avg", "avg-root-tu", "max"):
raise ValueError(f"{path}: unsupported sort '{sort_by}'")
if exclusive_scope not in ("auto", "all", "same-filter"):
raise ValueError(f"{path}: unsupported exclusive_scope '{exclusive_scope}'")
if isinstance(top_n, bool) or not isinstance(top_n, int) or top_n <= 0:
raise ValueError(f"{path}: top must be a positive integer")
if (
isinstance(threshold, bool)
or not isinstance(threshold, (int, float))
or threshold < 0
):
raise ValueError(f"{path}: threshold must be a non-negative number")
if not isinstance(scope_filter_pattern, str):
raise ValueError(f"{path}: scope_filter must be a string")
children_data = slice_data.get("children", [])
if not isinstance(children_data, list):
raise ValueError(f"{path}: children must be a list")
config = report_config(
slice_id=slice_id,
title=title,
filter_name=filter_name,
timing=timing,
exclusive_scope=exclusive_scope,
sort_by=sort_by,
top_n=top_n,
tag=None,
threshold_s=float(threshold),
scope_filter=compile_scope_filter(scope_filter_pattern, parser),
)
children = tuple(
slice_request_from_json(
child,
parser,
path=f"{path}.children[{index}]",
seen_ids=seen_ids,
)
for index, child in enumerate(children_data)
)
return SliceRequest(config=config, filter_name=filter_name, children=children)
def read_slice_requests(
slices_path: Path, parser: argparse.ArgumentParser
) -> list[SliceRequest]:
try:
with slices_path.open(encoding="utf-8") as f:
payload = json.load(f)
except OSError as e:
parser.error(f"failed to read --slices file: {e}")
except json.JSONDecodeError as e:
parser.error(f"failed to parse --slices JSON: {e}")
slices_data = payload.get("slices") if isinstance(payload, dict) else payload
if not isinstance(slices_data, list) or not slices_data:
parser.error(
"--slices JSON must be a non-empty list or an object with a non-empty 'slices' list"
)
seen_ids: set[str] = set()
try:
return [
slice_request_from_json(
slice_data,
parser,
path=f"slices[{index}]",
seen_ids=seen_ids,
)
for index, slice_data in enumerate(slices_data)
]
except ValueError as e:
parser.error(str(e))
def run_slice_report(
request: SliceRequest,
*,
trace_dir: Path,
baseline_dir: Path | None,
repo_root: Path,
baseline_repo_root: Path,
output_dir: Path,
output_csv: Path | None,
allow_empty: bool,
) -> dict[str, Any]:
config = request.config
slice_output_dir = output_dir
manifest: dict[str, Any] = {
"id": config.slice_id,
"title": config.title,
"filter": request.filter_name,
"filter_label": config.spec.label,
"timing": config.timing,
"exclusive_scope": config.exclusive_scope,
"sort": config.sort_by,
"top": config.top_n,
"threshold_s": config.threshold_us / 1_000_000.0,
"output_dir": slice_output_dir.as_posix(),
"children": [],
}
if baseline_dir is not None:
comparison_output_dir = slice_output_dir / "comparison"
report_sides = (
report_side(
"baseline",
baseline_dir,
baseline_repo_root,
slice_output_dir / "baseline",
),
report_side("current", trace_dir, repo_root, slice_output_dir / "current"),
)
report_csvs: dict[str, tuple[Path, int]] = {}
for side in report_sides:
report_csvs[side.name] = write_side_report(side, config)
comparison_stats, matched_trace_count = collect_comparison_stats(
baseline_dir,
trace_dir,
baseline_repo_root,
repo_root,
config,
)
warnings: list[str] = []
for side_name, (_, row_count) in report_csvs.items():
if row_count == 0:
warnings.append(
f"{side_name} report matched no events for this slice; "
f"check filter '{request.filter_name}', scope filtering, "
"and trace format"
)
if matched_trace_count == 0:
warnings.append(
"baseline and current trace directories have no matching trace files"
)
elif not comparison_stats:
warnings.append(
"baseline and current traces have no comparable event keys for this slice"
)
comparison_manifest: dict[str, Any] = {
"matched_trace_count": matched_trace_count,
}
wrote: list[tuple[Path, int]] = []
for direction in ("worse", "better"):
rows = comparison_rows(comparison_stats, config, direction)
comparison_csv = comparison_output_path(
comparison_output_dir,
config,
direction,
)
write_comparison_csv(comparison_csv, rows, config.timing)
row_dicts = [
comparison_row_dict(rank, row, config.timing)
for rank, row in enumerate(rows, start=1)
]
comparison_manifest[direction] = {
"csv": comparison_csv.as_posix(),
"row_count": len(rows),
"rows": row_dicts,
}
wrote.append((comparison_csv, len(rows)))
manifest["reports"] = {
side: {"csv": path.as_posix(), "row_count": row_count}
for side, (path, row_count) in report_csvs.items()
}
manifest["comparison"] = comparison_manifest
if warnings:
manifest["warnings"] = warnings
trace_counts = ", ".join(
f"{len(side.trace_paths)} {side.name} trace(s)" for side in report_sides
)
print(
f"wrote slice '{config.slice_id}' baseline/current reports and "
f"comparison reports from {trace_counts}, "
f"{matched_trace_count} matched trace file(s):"
)
for side_name, (path, _) in report_csvs.items():
print(f" {side_name}: {path}")
for path, row_count in wrote:
print(f" comparison ({row_count} row(s)): {path}")
for warning in warnings:
print(f" warning: {warning}")
else:
side = report_side("current", trace_dir, repo_root, slice_output_dir)
stats = collect_stats(side.trace_paths, side.repo_root, config)
if not stats and not allow_empty:
raise SystemExit(
f"no events matched filter '{request.filter_name}' "
f"in {len(side.trace_paths)} trace(s)"
)
rows = sorted_rows(stats, config) if stats else []
report_csv = (
output_csv
if output_csv is not None
else default_output_path(slice_output_dir, config)
)
write_csv(report_csv, rows, config.timing)
manifest["reports"] = {
"current": {"csv": report_csv.as_posix(), "row_count": len(rows)}
}
if not rows:
manifest["warnings"] = [
f"current report matched no events for filter '{request.filter_name}'"
]
print(
f"wrote slice '{config.slice_id}' {len(rows)} row(s) "
f"from {len(side.trace_paths)} trace(s) to {report_csv}"
)
manifest["children"] = [
run_slice_report(
child,
trace_dir=trace_dir,
baseline_dir=baseline_dir,
repo_root=repo_root,
baseline_repo_root=baseline_repo_root,
output_dir=output_dir / child.config.slice_id,
output_csv=None,
allow_empty=allow_empty,
)
for child in request.children
]
return manifest
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Emit a top-N CSV report for events in NVCC --fdevice-time-trace JSON files."
)
)
parser.add_argument(
"trace_dir",
type=Path,
nargs="?",
help="directory containing device-time-trace JSON files",
)
parser.add_argument(
"-f",
"--filter",
default="file-processing",
help=(
"canonical event filter name or case-insensitive regex over event "
"name/detail (default: file-processing); use --list-filters to see "
"built-in filters"
),
)
timing = parser.add_mutually_exclusive_group()
timing.add_argument(
"-i",
"--inclusive",
action="store_const",
const="inclusive",
dest="timing",
help="rank by inclusive event time",
)
timing.add_argument(
"-e",
"--exclusive",
action="store_const",
const="exclusive",
dest="timing",
help="rank by exclusive event time",
)
parser.set_defaults(timing="inclusive")
parser.add_argument("-n", "--top", type=int, default=15, help="number of rows")
parser.add_argument(
"--sort",
choices=("total", "avg", "avg-root-tu", "max"),
default="total",
help=(
"sort selected timing by total contribution, average event cost, "
"average per root TU, or max event cost"
),
)
parser.add_argument(
"-o",
"--output-dir",
type=Path,
help="output directory (default: <trace-dir>/event_reports)",
)
parser.add_argument(
"--baseline-dir",
type=Path,
help=(
"optional baseline trace directory; writes baseline/current reports "
"and worse/better comparison CSVs under --output-dir"
),
)
parser.add_argument(
"--baseline-repo-root",
type=Path,
help=(
"repository root that produced --baseline-dir traces (default: --repo-root)"
),
)
parser.add_argument(
"--threshold",
type=float,
default=0.0,
help=(
"comparison-only minimum total-impact change, in seconds, "
"required for worse/better rows (default: 0)"
),
)
parser.add_argument(
"--output-csv",
type=Path,
help="exact output CSV path; overrides generated file name inside --output-dir",
)
parser.add_argument(
"--slices",
type=Path,
help=(
"JSON file describing multiple report slices; writes each slice under "
"--output-dir/<slice-id> and emits --output-dir/summary.json"
),
)
parser.add_argument(
"--exclusive-scope",
choices=("auto", "all", "same-filter"),
default="auto",
help=(
"exclusive timing scope; auto uses same-filter for file-processing "
"and all nested events for other filters"
),
)
parser.add_argument(
"--scope-filter",
default=DEFAULT_SCOPE_FILTER,
help=(
"case-sensitive regex for symbol-scope reports; applies to demangled "
"symbols and decoded Itanium-mangled namespace prefixes for symbol-like "
"events only; pass an empty string to disable (default: CCCL top-level "
"namespaces)"
),
)
parser.add_argument(
"--repo-root", default=Path(__file__).resolve().parents[2], type=Path
)
parser.add_argument(
"--tag",
help="optional suffix for the generated output filename",
)
parser.add_argument(
"--list-filters",
action="store_true",
help="print built-in filters and exit",
)
args = parser.parse_args()
if args.list_filters:
print_filters()
return
if args.trace_dir is None:
parser.error("trace_dir is required unless --list-filters is used")
if args.top <= 0:
parser.error("--top must be positive")
if args.threshold < 0:
parser.error("--threshold must be non-negative")
if args.slices is not None:
ignored_slice_options = (
(args.filter != parser.get_default("filter"), "--filter"),
(args.timing != parser.get_default("timing"), "--inclusive/--exclusive"),
(args.top != parser.get_default("top"), "--top"),
(args.sort != parser.get_default("sort"), "--sort"),
(args.threshold != parser.get_default("threshold"), "--threshold"),
(
args.exclusive_scope != parser.get_default("exclusive_scope"),
"--exclusive-scope",
),
(args.scope_filter != parser.get_default("scope_filter"), "--scope-filter"),
(args.tag is not None, "--tag"),
)
ignored_names = [name for changed, name in ignored_slice_options if changed]
if ignored_names:
parser.error(
"--slices cannot be combined with single-slice option(s): "
+ ", ".join(ignored_names)
)
if args.baseline_dir is None and args.threshold != 0:
parser.error("--threshold can only be used together with --baseline-dir")
if args.baseline_dir is not None and args.output_csv is not None:
parser.error("--output-csv cannot be used together with --baseline-dir")
if args.slices is not None and args.output_csv is not None:
parser.error("--output-csv cannot be used together with --slices")
trace_dir = args.trace_dir.resolve(strict=False)
baseline_dir = (
args.baseline_dir.resolve(strict=False) if args.baseline_dir else None
)
repo_root = args.repo_root.resolve(strict=False)
baseline_repo_root = (
args.baseline_repo_root.resolve(strict=False)
if args.baseline_repo_root
else repo_root
)
output_dir = (
args.output_dir.resolve(strict=False)
if args.output_dir
else trace_dir / "event_reports"
)
output_csv = args.output_csv.resolve(strict=False) if args.output_csv else None
multi_slice = args.slices is not None
requests = (
read_slice_requests(args.slices.resolve(strict=False), parser)
if args.slices is not None
else [single_slice_request(args, parser)]
)
manifest = {
"schema_version": 1,
"mode": "comparison" if baseline_dir is not None else "single",
"trace_dir": trace_dir.as_posix(),
"baseline_dir": baseline_dir.as_posix() if baseline_dir else None,
"repo_root": repo_root.as_posix(),
"baseline_repo_root": baseline_repo_root.as_posix(),
"slices": [],
}
for request in requests:
slice_output_dir = (
output_dir / request.config.slice_id if multi_slice else output_dir
)
manifest["slices"].append(
run_slice_report(
request,
trace_dir=trace_dir,
baseline_dir=baseline_dir,
repo_root=repo_root,
baseline_repo_root=baseline_repo_root,
output_dir=slice_output_dir,
output_csv=output_csv,
allow_empty=multi_slice,
)
)
summary_json = output_dir / "summary.json"
write_json(summary_json, manifest)
print(f"wrote summary manifest: {summary_json}")
if __name__ == "__main__":
main()