Skip to content

Commit 742466e

Browse files
committed
Add CUDA Graph annotation postprocessing
## Human Note ## Agent note Turn captured `mark_kernels` metadata into GPU annotation spans automatically for both Kineto and the CUPTI monitor backend. The postprocessor joins graph and node IDs to the live annotation registry, groups contiguous nodes with the same label, and emits boxes that the existing TrackEvent writer places on its dedicated GPU annotations track while preserving overlap lane assignment. The monitor keeps its native direct-export path when no graph annotations exist. Annotated traces use the JSON merge path so the shared postprocessors can run. On a representative 1.8K-event trace, annotation synthesis measured about 0.60 ms median; complete native TrackEvent export measured about 43 ms median. ## Test Plan ```bash ~/.venvs/nightly/bin/python -m pytest test/test_profiler.py test/test_perfetto.py -q uvx ruff check transformer_nuggets/utils/benchmark.py transformer_nuggets/utils/perfetto.py transformer_nuggets/utils/__init__.py test/test_profiler.py test/test_perfetto.py uvx ruff format --check transformer_nuggets/utils/benchmark.py transformer_nuggets/utils/perfetto.py transformer_nuggets/utils/__init__.py test/test_profiler.py test/test_perfetto.py prek ```
1 parent 531f361 commit 742466e

4 files changed

Lines changed: 242 additions & 11 deletions

File tree

test/test_perfetto.py

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55

66
from transformer_nuggets.utils.perfetto import (
7+
add_cuda_graph_annotation_boxes,
78
default_trace_path,
89
default_track_event_path,
910
read_trace,
@@ -17,6 +18,87 @@ def _duration_events(trace):
1718
return [event for event in trace["traceEvents"] if event.get("ph") == "X"]
1819

1920

21+
def test_cuda_graph_annotations_become_contiguous_gpu_boxes():
22+
graph_id = 2
23+
annotations = {
24+
(graph_id << 32) | 1: [{"name": "attention"}],
25+
(graph_id << 32) | 2: [{"name": "attention"}],
26+
(graph_id << 32) | 3: [{"name": "loss"}],
27+
}
28+
trace = {
29+
"traceEvents": [
30+
{
31+
"ph": "X",
32+
"cat": "kernel",
33+
"name": "kernel_a",
34+
"pid": 0,
35+
"tid": 7,
36+
"ts": 10,
37+
"dur": 3,
38+
"args": {"graph id": graph_id, "graph node id": 1},
39+
},
40+
{
41+
"ph": "X",
42+
"cat": "kernel",
43+
"name": "kernel_b",
44+
"pid": 0,
45+
"tid": 7,
46+
"ts": 13,
47+
"dur": 5,
48+
"args": {"graph id": graph_id, "graph node id": 2},
49+
},
50+
{
51+
"ph": "X",
52+
"cat": "kernel",
53+
"name": "kernel_c",
54+
"pid": 0,
55+
"tid": 7,
56+
"ts": 18,
57+
"dur": 2,
58+
"args": {"graph id": graph_id, "graph node id": 3},
59+
},
60+
]
61+
}
62+
63+
processed = add_cuda_graph_annotation_boxes(trace, annotations)
64+
boxes = [
65+
event for event in processed["traceEvents"] if event.get("cat") == "gpu_user_annotation"
66+
]
67+
68+
assert [(box["name"], box["ts"], box["dur"]) for box in boxes] == [
69+
("attention", 10.0, 8.0),
70+
("loss", 18.0, 2.0),
71+
]
72+
assert all(box["tid"] == 7 for box in boxes)
73+
assert len(trace["traceEvents"]) == 3
74+
75+
76+
def test_cuda_graph_annotation_boxes_accept_monitor_embedded_metadata():
77+
trace = {
78+
"traceEvents": [
79+
{
80+
"ph": "X",
81+
"cat": "kernel",
82+
"name": "kernel",
83+
"pid": 0,
84+
"tid": 7,
85+
"ts": 10,
86+
"dur": 3,
87+
"args": {
88+
"graph id": 2,
89+
"graph node id": 1,
90+
"annotation": '[{"name": "attention"}]',
91+
},
92+
}
93+
]
94+
}
95+
96+
processed = add_cuda_graph_annotation_boxes(trace)
97+
98+
assert processed["traceEvents"][-1]["name"] == "attention"
99+
assert processed["traceEvents"][-1]["cat"] == "gpu_user_annotation"
100+
101+
20102
def test_split_overlapping_slices_creates_adjacent_lanes():
21103
trace = {
22104
"traceEvents": [
@@ -163,7 +245,7 @@ def test_default_track_event_path_uses_native_perfetto_suffix():
163245

164246

165247
def test_track_event_conversion_preserves_instants_counters_and_warns_on_unsupported():
166-
from perfetto.protos.perfetto.trace.perfetto_trace_pb2 import TrackEvent, Trace
248+
from perfetto.protos.perfetto.trace.perfetto_trace_pb2 import Trace, TrackEvent
167249

168250
trace = {
169251
"traceEvents": [
@@ -187,7 +269,7 @@ def test_track_event_conversion_preserves_instants_counters_and_warns_on_unsuppo
187269

188270

189271
def test_track_event_conversion_puts_gpu_annotations_on_separate_track():
190-
from perfetto.protos.perfetto.trace.perfetto_trace_pb2 import TrackEvent, Trace
272+
from perfetto.protos.perfetto.trace.perfetto_trace_pb2 import Trace, TrackEvent
191273

192274
trace = {
193275
"traceEvents": [
@@ -224,7 +306,7 @@ def test_track_event_conversion_puts_gpu_annotations_on_separate_track():
224306

225307

226308
def test_track_event_conversion_attaches_paired_flows_to_slices():
227-
from perfetto.protos.perfetto.trace.perfetto_trace_pb2 import TrackEvent, Trace
309+
from perfetto.protos.perfetto.trace.perfetto_trace_pb2 import Trace, TrackEvent
228310

229311
trace = {
230312
"traceEvents": [
@@ -255,9 +337,9 @@ def test_track_event_conversion_attaches_paired_flows_to_slices():
255337

256338
def test_track_event_conversion_splits_crossing_slices_and_keeps_nested_slices():
257339
from perfetto.protos.perfetto.trace.perfetto_trace_pb2 import (
340+
Trace,
258341
TrackDescriptor,
259342
TrackEvent,
260-
Trace,
261343
)
262344

263345
trace = {
@@ -302,7 +384,7 @@ def test_track_event_conversion_splits_crossing_slices_and_keeps_nested_slices()
302384

303385

304386
def test_track_event_conversion_keeps_back_to_back_slices_separate():
305-
from perfetto.protos.perfetto.trace.perfetto_trace_pb2 import TrackEvent, Trace
387+
from perfetto.protos.perfetto.trace.perfetto_trace_pb2 import Trace, TrackEvent
306388

307389
trace = {
308390
"traceEvents": [

transformer_nuggets/utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from transformer_nuggets.utils.merge_traces import merge_traces
2121
from transformer_nuggets.utils.perfetto import (
2222
TraceFormat,
23+
add_cuda_graph_annotation_boxes,
2324
default_trace_path,
2425
default_track_event_path,
2526
perfetto_trace_path,

transformer_nuggets/utils/benchmark.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from torch.utils import benchmark
2222

2323
from transformer_nuggets.utils.perfetto import (
24+
add_cuda_graph_annotation_boxes,
2425
perfetto_trace_path,
2526
read_trace,
2627
write_perfetto_trace,
@@ -551,6 +552,15 @@ def benchmark_cuda_function_in_microseconds_triton(func: Callable, *args, **kwar
551552
return do_bench(no_args) * 1e3
552553

553554

555+
def _cuda_graph_annotations() -> dict[int, list[object]]:
556+
"""Return captured CUDA Graph annotations when the prototype API is available."""
557+
try:
558+
from torch.cuda.graph_annotations import get_kernel_annotations
559+
except ImportError:
560+
return {}
561+
return get_kernel_annotations()
562+
563+
554564
def _write_profiler_trace(
555565
prof: torch.profiler.profile,
556566
trace_path: Path,
@@ -561,8 +571,12 @@ def _write_profiler_trace(
561571
gzip_trace: bool = False,
562572
) -> None:
563573
"""Export a torch profiler trace through the canonical Perfetto writer."""
574+
annotations = _cuda_graph_annotations()
564575
can_export_direct_json = (
565-
trace_format == "chrome_json" and not split_overlaps and trace_path.suffix != ".gz"
576+
trace_format == "chrome_json"
577+
and not split_overlaps
578+
and trace_path.suffix != ".gz"
579+
and not annotations
566580
)
567581
if can_export_direct_json:
568582
prof.export_chrome_trace(str(trace_path))
@@ -573,7 +587,7 @@ def _write_profiler_trace(
573587
prof.export_chrome_trace(str(export_path))
574588
write_perfetto_trace(
575589
trace_path,
576-
read_trace(export_path),
590+
add_cuda_graph_annotation_boxes(read_trace(export_path), annotations),
577591
trace_format=trace_format,
578592
split_overlaps=split_overlaps,
579593
track_pattern=track_pattern,
@@ -605,8 +619,37 @@ def _write_cupti_monitor_trace(
605619
trace_path: Path,
606620
*,
607621
trace_format: Literal["chrome_json", "track_event"],
622+
split_overlaps: bool = True,
623+
track_pattern: str | None = "stream.*",
624+
gzip_trace: bool = False,
608625
) -> None:
609-
"""Export a monitor trace while hiding its unconditional ``.gz`` suffix."""
626+
"""Export a monitor trace and apply graph-aware postprocessing when needed."""
627+
annotations = _cuda_graph_annotations()
628+
if annotations:
629+
export_path = trace_path.with_name(f"{trace_path.stem}.monitor-export.json")
630+
monitor_path = Path(f"{export_path}.gz")
631+
export_path.unlink(missing_ok=True)
632+
monitor_path.unlink(missing_ok=True)
633+
try:
634+
prof.export_chrome_trace(str(export_path))
635+
generated_path = export_path if export_path.exists() else monitor_path
636+
if not generated_path.exists():
637+
raise FileNotFoundError(
638+
f"CUPTI monitor export produced neither {export_path} nor {monitor_path}"
639+
)
640+
write_perfetto_trace(
641+
trace_path,
642+
add_cuda_graph_annotation_boxes(read_trace(generated_path), annotations),
643+
trace_format=trace_format,
644+
split_overlaps=split_overlaps,
645+
track_pattern=track_pattern,
646+
gzip_trace=gzip_trace,
647+
)
648+
finally:
649+
export_path.unlink(missing_ok=True)
650+
monitor_path.unlink(missing_ok=True)
651+
return
652+
610653
export_path = trace_path.with_name(f"{trace_path.name}.monitor-export")
611654
monitor_path = Path(f"{export_path}.gz")
612655
export_path.unlink(missing_ok=True)
@@ -1008,7 +1051,14 @@ def profiler(
10081051

10091052
def trace_handler(prof) -> None:
10101053
if use_cupti_monitor:
1011-
_write_cupti_monitor_trace(prof, path, trace_format=trace_format)
1054+
_write_cupti_monitor_trace(
1055+
prof,
1056+
path,
1057+
trace_format=trace_format,
1058+
split_overlaps=fix_overlapping_events,
1059+
track_pattern=overlap_track_pattern,
1060+
gzip_trace=gzip_trace,
1061+
)
10121062
return
10131063
_write_profiler_trace(
10141064
prof,

transformer_nuggets/utils/perfetto.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
import re
2323
import zlib
2424
from collections import defaultdict
25+
from collections.abc import Iterator, Mapping, Sequence
2526
from contextlib import contextmanager
2627
from dataclasses import dataclass
27-
from collections.abc import Iterator
2828
from pathlib import Path
2929
from re import Pattern
3030
from typing import Any, Literal
@@ -34,7 +34,6 @@
3434
write_track_event_trace,
3535
)
3636

37-
3837
TraceFormat = Literal["chrome_json", "track_event"]
3938
"""Perfetto-compatible output format selector."""
4039

@@ -67,6 +66,105 @@ def write_trace(path: str | Path, trace: dict[str, Any], *, indent: int | None =
6766
json.dump(trace, f, indent=indent)
6867

6968

69+
def _annotation_name(entries: Sequence[Any] | None) -> str | None:
70+
name = None
71+
for annotation in entries or ():
72+
if isinstance(annotation, dict) and "name" in annotation:
73+
name = str(annotation["name"])
74+
elif isinstance(annotation, str):
75+
name = annotation
76+
return name
77+
78+
79+
def _graph_annotation_box(
80+
name: str,
81+
pid: Any,
82+
tid: Any,
83+
start: float,
84+
end: float,
85+
) -> dict[str, Any]:
86+
return {
87+
"ph": "X",
88+
"cat": "gpu_user_annotation",
89+
"name": name,
90+
"pid": pid,
91+
"tid": tid,
92+
"ts": start,
93+
"dur": end - start,
94+
"args": {"transformer_nuggets.graph_annotation": True},
95+
}
96+
97+
98+
def add_cuda_graph_annotation_boxes(
99+
trace: dict[str, Any],
100+
annotations: Mapping[int, Sequence[Any]] | None = None,
101+
) -> dict[str, Any]:
102+
"""Add GPU annotation spans for contiguous CUDA Graph regions.
103+
104+
Graph annotation metadata is joined by ``(graph id, graph node id)`` when an
105+
annotation registry is supplied. CUPTI monitor JSON traces can also carry the
106+
annotation list directly in each GPU event's ``annotation`` argument.
107+
"""
108+
events = list(trace.get("traceEvents", []))
109+
if any(event.get("args", {}).get("transformer_nuggets.graph_annotation") for event in events):
110+
return trace.copy()
111+
112+
annotated_work: dict[tuple[Any, Any], list[tuple[dict[str, Any], str]]] = defaultdict(list)
113+
for event in events:
114+
if event.get("ph") != "X" or event.get("cat") not in {
115+
"kernel",
116+
"gpu_memcpy",
117+
"gpu_memset",
118+
}:
119+
continue
120+
args = event.get("args", {})
121+
graph_id = args.get("graph id")
122+
graph_node_id = args.get("graph node id")
123+
if graph_id is None or not graph_node_id:
124+
continue
125+
126+
entries = None
127+
if annotations is not None:
128+
entries = annotations.get((int(graph_id) << 32) | int(graph_node_id))
129+
if entries is None and isinstance(args.get("annotation"), str):
130+
try:
131+
embedded = json.loads(args["annotation"])
132+
except json.JSONDecodeError:
133+
embedded = None
134+
if isinstance(embedded, list):
135+
entries = embedded
136+
name = _annotation_name(entries)
137+
if name is not None:
138+
annotated_work[(event.get("pid"), event.get("tid"))].append((event, name))
139+
140+
annotation_boxes: list[dict[str, Any]] = []
141+
for (pid, tid), stream_events in annotated_work.items():
142+
region_name = None
143+
region_start = 0.0
144+
region_end = 0.0
145+
for event, name in sorted(stream_events, key=lambda item: item[0]["ts"]):
146+
start = float(event["ts"])
147+
end = start + float(event.get("dur", 0.0))
148+
if name != region_name:
149+
if region_name is not None:
150+
annotation_boxes.append(
151+
_graph_annotation_box(region_name, pid, tid, region_start, region_end)
152+
)
153+
region_name = name
154+
region_start = start
155+
region_end = end
156+
else:
157+
region_end = max(region_end, end)
158+
if region_name is not None:
159+
annotation_boxes.append(
160+
_graph_annotation_box(region_name, pid, tid, region_start, region_end)
161+
)
162+
163+
processed = trace.copy()
164+
processed["traceEvents"] = [*events, *annotation_boxes]
165+
return processed
166+
167+
70168
def default_trace_path(file_path: str | Path, *, gzip_by_default: bool = True) -> Path:
71169
"""Return a Chrome JSON trace path, treating suffix-less paths as stems.
72170

0 commit comments

Comments
 (0)