Skip to content

Commit 4d2b30e

Browse files
committed
[graph_trainer] Add CUDA graph kernel annotation pass
Adds `insert_kernel_annotations_pass` that labels CUDA graph kernels with their originating nn.Module path in profiler traces. How it works: 1. `annotate_module_fqns(model)` wraps each submodule's forward with `torch.fx.traceback.annotate_fn({"module_fqn": fqn})`, setting `node.meta["custom"]["module_fqn"]` on FX nodes during tracing. 2. `insert_kernel_annotations_pass` reads the `module_fqn` metadata and inserts `mark_kernels` enter/exit calls at module boundaries. Bundled with the cudagraph pass. 3. `CUDAGraphWrapper` captures annotations during graph recording when the pass is active. 4. `Profiler.Config.trace_post_processors` (new field) runs `cudagraph_annotate_trace_post_processor()` on each exported trace so profiler traces automatically carry `module_fqn` fields on graphed kernel events. Requires PyTorch with `torch.cuda._graph_annotations` and the `enable_annotations` kwarg on `torch.cuda.graph()`. Falls back gracefully when unavailable (pass is a no-op). Authored with Claude.
1 parent 627f4a3 commit 4d2b30e

12 files changed

Lines changed: 682 additions & 21 deletions

File tree

.github/workflows/integration_test_8gpu_graph_trainer.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ jobs:
6969
# Run the graph passes unit tests
7070
torchrun --nproc-per-node=8 -m pytest torchtitan/experiments/graph_trainer/tests/test_passes.py::TestReassignToPgPass -v
7171
pytest torchtitan/experiments/graph_trainer/tests/test_passes.py::TestApplySACPass -v
72+
pytest torchtitan/experiments/graph_trainer/tests/test_passes.py::TestAnnotateModuleFqns -v
73+
74+
# Run kernel annotation profiler tests (skips if cuda-compat < 13.1)
75+
pytest torchtitan/experiments/graph_trainer/tests/test_profiler.py -v
7276
7377
# Run the make_fx tracer unit tests
7478
pytest torchtitan/experiments/graph_trainer/tests/test_trace_module.py -v -k "TestTraceModule or TestTraceDTensor or TestMetadataPropagation"

torchtitan/experiments/graph_trainer/.claude/CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,19 @@ This verifies that the aot_fx_trace path produces bitwise identical losses
208208
and gradients across runs, and matches eager numerics exactly. Any change
209209
that breaks this test must be investigated and fixed before proceeding with
210210
other tests.
211+
212+
### CUDA Graph Kernel Annotations
213+
214+
The `insert_kernel_annotations_pass` labels CUDA graph kernels with their
215+
originating `nn.Module` path in profiler traces. It runs automatically in the
216+
`aot_fx_trace` path (bundled with the cudagraph pass). The post-processor
217+
is attached via ``Profiler.Config.trace_post_processors`` (see
218+
``cudagraph_annotate_trace_post_processor``) so exported traces are
219+
annotated automatically — no manual post-processing is needed.
220+
221+
Requirements: `cuda-python` package and CUDA toolkit/driver >= 13.1
222+
(or `cuda-compat >= 13.1` on `LD_LIBRARY_PATH`). The pass is a no-op when
223+
these are unavailable.
224+
225+
To view annotated traces, open the exported JSON in https://ui.perfetto.dev.
226+
Kernel events will have `module_fqn` fields like `layers.0.attention.wq`.

torchtitan/experiments/graph_trainer/common_utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ def annotate_module_fqns(model: nn.Module) -> None:
2525
"""Annotate all modules' forward with their fully-qualified names.
2626
2727
Every named submodule (excluding the root) gets its forward method wrapped
28-
with annotate_fn so that FX nodes carry the module_fqn metadata.
28+
with ``annotate_fn`` so that FX nodes carry ``module_fqn`` in
29+
``node.meta["custom"]``.
30+
31+
Call once after model construction, before tracing/compilation.
2932
"""
3033
for fqn, submodule in model.named_modules():
3134
if fqn: # skip root module

torchtitan/experiments/graph_trainer/configs.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def to_graph_trainer_config(
6262
from the graph_trainer model_registry. The compile field is removed and
6363
left as the GraphTrainer.Config default; callers should explicitly set it.
6464
"""
65+
from .cudagraph import cudagraph_annotate_trace_post_processor
6566
from .trainer import GraphTrainer
6667

6768
d = {f.name: getattr(base_config, f.name) for f in fields(base_config)}
@@ -89,4 +90,11 @@ def to_graph_trainer_config(
8990
if ac is not None and ac.mode != "none":
9091
d["activation_checkpoint"] = ActivationCheckpointConfig(mode="selective")
9192

93+
# Merge CUDA graph kernel annotations into profiler traces when profiling
94+
# is active. No-op otherwise (and no-op when requirements aren't met).
95+
# It's also a no-op if there is CUDA graph is not enabled.
96+
profiler = d.get("profiler")
97+
if profiler is not None:
98+
profiler.trace_post_processor = cudagraph_annotate_trace_post_processor()
99+
92100
return GraphTrainer.Config(**d)

torchtitan/experiments/graph_trainer/cudagraph.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
during compilation.
1212
"""
1313

14-
import logging
14+
import json
1515
import warnings
1616
from collections.abc import Callable, Sequence
1717
from typing import Any
@@ -21,7 +21,8 @@
2121
from torch._library.opaque_object import is_opaque_value
2222
from torch.utils._ordered_set import OrderedSet
2323

24-
logger = logging.getLogger(__name__)
24+
from torchtitan.config.function import Function
25+
from torchtitan.tools.logging import logger
2526

2627

2728
class _CUDAGraphManager:
@@ -31,6 +32,10 @@ def __init__(self) -> None:
3132
self._initialized = False
3233
self._cudagraph_wrappers: list["CUDAGraphWrapper"] = []
3334
self._teardown_called = False
35+
# toolsId (graph_id << 32 | node_id) -> list of annotation dicts
36+
# (e.g. [{"module_fqn": "layers.0.attention.wq"}]).
37+
self.all_annotations: dict[int, list] = {}
38+
self.enable_annotations: bool = False
3439

3540
def maybe_initialize(self) -> None:
3641
if self._initialized:
@@ -100,6 +105,54 @@ def cudagraph_teardown() -> None:
100105
_cg_manager.teardown()
101106

102107

108+
def get_cudagraph_annotations() -> dict[int, list]:
109+
"""Return all kernel annotations accumulated across CUDA graph captures."""
110+
return _cg_manager.all_annotations
111+
112+
113+
def enable_cudagraph_annotations() -> None:
114+
"""Enable kernel annotation capture on subsequent CUDA graph recordings."""
115+
_cg_manager.enable_annotations = True
116+
117+
118+
def cudagraph_annotate_trace_post_processor() -> Function.Config:
119+
"""Return a ``Function.Config`` that merges captured CUDA graph kernel
120+
annotations into a profiler trace file.
121+
122+
Attach this to ``Profiler.Config.trace_post_processor`` so that exported
123+
profiler traces automatically carry ``module_fqn`` fields on graphed kernel
124+
events.
125+
"""
126+
return Function.Config(fn=_cudagraph_annotate_trace_file)
127+
128+
129+
def _cudagraph_annotate_trace_file(trace_path: str) -> None:
130+
"""Post-process a profiler trace with CUDA graph kernel annotations."""
131+
annotations = _cg_manager.all_annotations
132+
if not annotations:
133+
return
134+
135+
try:
136+
from torch.cuda._annotate_cuda_graph_trace import ( # pyrefly: ignore[missing-import]
137+
annotate_trace,
138+
)
139+
except ImportError:
140+
logger.warning(
141+
"torch.cuda._annotate_cuda_graph_trace not available. "
142+
"Upgrade PyTorch to enable trace CUDA graph kernel annotation."
143+
)
144+
return
145+
146+
with open(trace_path) as f:
147+
trace = json.load(f)
148+
149+
count = annotate_trace(trace, annotations)
150+
if count > 0:
151+
with open(trace_path, "w") as f:
152+
json.dump(trace, f)
153+
logger.info(f"Annotated {count} CUDAGraph kernel event(s) in profiler trace")
154+
155+
103156
class CUDAGraphWrapper:
104157
"""Wraps a callable with cudagraph. It warms up the callable, records cudagraph,
105158
and replays cudagraph during runtime. It also handles static input tensors, which
@@ -219,10 +272,16 @@ def __call__(self, *args):
219272
self._cudagraph,
220273
pool=_cg_manager.graph_pool,
221274
stream=_cg_manager.stream,
275+
enable_annotations=_cg_manager.enable_annotations,
222276
):
223277
# `output` is managed by pytorch's cudagraph pool
224278
self._output = self._runnable(*args)
225279

280+
if _cg_manager.enable_annotations:
281+
from torch.cuda._graph_annotations import get_kernel_annotations
282+
283+
_cg_manager.all_annotations.update(get_kernel_annotations())
284+
226285
if self._should_check_address:
227286
self._check_static_inputs_address()
228287

torchtitan/experiments/graph_trainer/deepseek_v3/parallelize.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ def annotate_deepseekv3(model: GraphTrainerDeepSeekV3Model) -> None:
4343
- AC region annotation: Tags each transformer block's forward with a unique
4444
ac_region_id so that apply_sac_pass can assign per-block ac_graph_id
4545
boundaries for the min-cut partitioner.
46-
46+
- Module FQN annotation: Tags each submodule's forward with its
47+
fully-qualified name for downstream passes.
4748
"""
4849
from torchtitan.models.common.moe import MoE
4950
from torchtitan.models.common.token_dispatcher import LocalTokenDispatcher

torchtitan/experiments/graph_trainer/llama3/parallelize.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ def annotate_llama(model: GraphTrainerLlama3Model) -> None:
3434
"""Attach annotations to FX graph nodes with ``torch.fx.traceback.annotate_fn``
3535
3636
- Module FQN annotation: Tags each submodule's forward with its
37-
fully-qualified name so that the transformer_block_bucketing pass
38-
can identify which graph nodes belong to which module.
37+
fully-qualified name for downstream passes.
3938
- AC region annotation: Tags each transformer block's forward with a unique
4039
ac_region_id so that apply_sac_pass can assign per-block ac_graph_id
4140
boundaries for the min-cut partitioner.

torchtitan/experiments/graph_trainer/passes.py

Lines changed: 115 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ def _is_backward_node(node: torch.fx.Node) -> bool:
6161

6262
def compile_time_passes(
6363
traced_result: "TracedResult",
64+
*,
65+
cudagraph_compatible: bool = False,
6466
) -> list[Callable]:
6567
"""Cleanup, FlexAttention annotation, and regional_inductor passes.
6668
@@ -76,7 +78,7 @@ def compile_time_passes(
7678
# )
7779
from torchtitan.models.common.attention import FlexAttention
7880

79-
return [
81+
passes: list[Callable] = [
8082
remove_detach_pass,
8183
remove_identity_view_pass,
8284
remove_identity_slice_pass,
@@ -95,17 +97,23 @@ def compile_time_passes(
9597
flex_compile_config=FlexAttention.inductor_configs,
9698
),
9799
regional_inductor_pass,
98-
# TODO: Switch to upstream PyTorch implementation when
99-
# https://github.com/pytorch/pytorch/pull/178246 lands.
100-
# custom_codegen_pass saves the FX graph to disk for:
101-
# 1. Debugging: inspect the generated graph code directly
102-
# 2. Profiling provenance: dual-path codegen with _RecordFunctionFast
103-
# gives fine-grained operator-level attribution in profiler traces
104-
# 3. User-editable codegen: users can directly modify the generated
105-
# program on disk for fine-grain scheduling optimizations, with
106-
# hot-reload picking up changes at runtime
107-
custom_codegen_pass,
108100
]
101+
if cudagraph_compatible:
102+
# Must run before custom_codegen_pass (last in pre_passes)
103+
# which replaces the GraphModule's forward().
104+
# Also must run before cudagraph_pass.
105+
passes.append(insert_kernel_annotations_pass)
106+
# TODO: Switch to upstream PyTorch implementation when
107+
# https://github.com/pytorch/pytorch/pull/178246 lands.
108+
# custom_codegen_pass saves the FX graph to disk for:
109+
# 1. Debugging: inspect the generated graph code directly
110+
# 2. Profiling provenance: dual-path codegen with _RecordFunctionFast
111+
# gives fine-grained operator-level attribution in profiler traces
112+
# 3. User-editable codegen: users can directly modify the generated
113+
# program on disk for fine-grain scheduling optimizations, with
114+
# hot-reload picking up changes at runtime
115+
passes.append(custom_codegen_pass)
116+
return passes
109117

110118

111119
def construct_default_graph_passes(
@@ -124,11 +132,14 @@ def construct_default_graph_passes(
124132
from torchtitan.experiments.graph_trainer.cudagraph import is_cudagraph_compatible
125133

126134
passes: list[Callable] = []
135+
cudagraph_compatible = is_cudagraph_compatible(traced_result.gm)
136+
127137
if not precompiled:
128-
passes.extend(compile_time_passes(traced_result))
138+
passes.extend(
139+
compile_time_passes(traced_result, cudagraph_compatible=cudagraph_compatible)
140+
)
129141

130-
# cudagraph should be the last pass.
131-
if is_cudagraph_compatible(traced_result.gm):
142+
if cudagraph_compatible:
132143
static_input_indices = list(range(traced_result.num_static_inputs))
133144
passes.append(
134145
functools.partial(
@@ -380,6 +391,96 @@ def _get_fake_mode_from_gm(gm: torch.fx.GraphModule):
380391
return gm
381392

382393

394+
def insert_kernel_annotations_pass(
395+
gm: torch.fx.GraphModule,
396+
example_inputs: tuple | None = None,
397+
) -> torch.fx.GraphModule:
398+
"""Insert mark_kernels() calls at module boundaries in the FX graph.
399+
400+
Reads ``node.meta["custom"]["module_fqn"]`` (set via
401+
``annotate_module_fqns``) and inserts enter/exit calls so that
402+
CUDA graph capture records the annotations.
403+
404+
Requires ``cuda-python`` package and CUDA toolkit/driver >= 13.1
405+
(or cuda-compat >= 13.1). Returns the graph unchanged when unavailable.
406+
407+
Also enables annotation capture on :class:`CUDAGraphWrapper` so that
408+
``enable_annotations=True`` is passed to ``torch.cuda.graph()``.
409+
410+
Alternative approaches:
411+
412+
1. **fx.Interpreter**: During cudagraph capture, run the graph via an
413+
``fx.Interpreter`` subclass that reads ``module_fqn`` metadata and
414+
calls ``mark_kernels`` enter/exit around each node — avoids mutating
415+
the graph.
416+
2. **Custom CodeGen**: Use a custom ``torch.fx.graph.CodeGen`` to emit
417+
enter/exit lines (or ``with`` blocks) directly in the generated
418+
Python code.
419+
420+
The current graph-pass approach is the least invasive.
421+
"""
422+
from torch.cuda._graph_annotations import _is_tools_id_unavailable
423+
424+
from torchtitan.experiments.graph_trainer.common_utils import _MODULE_FQN
425+
from torchtitan.experiments.graph_trainer.cudagraph import (
426+
enable_cudagraph_annotations,
427+
)
428+
429+
def _enter(annotation: dict) -> object:
430+
from torch.cuda._graph_annotations import mark_kernels
431+
432+
ctx = mark_kernels(annotation)
433+
ctx.__enter__()
434+
return ctx
435+
436+
def _exit(ctx: object) -> None:
437+
ctx.__exit__(None, None, None) # type: ignore[union-attr]
438+
439+
if _is_tools_id_unavailable():
440+
return gm
441+
442+
enable_cudagraph_annotations()
443+
444+
graph = gm.graph
445+
current_fqn: str | None = None
446+
current_ctx_node = None
447+
448+
for node in list(graph.nodes):
449+
fqn = (node.meta.get("custom") or {}).get(_MODULE_FQN)
450+
451+
if fqn != current_fqn:
452+
# Close previous scope
453+
if current_ctx_node is not None:
454+
with graph.inserting_before(node):
455+
exit_node = graph.call_function(_exit, (current_ctx_node,))
456+
exit_node.meta["custom"] = {}
457+
current_ctx_node = None
458+
459+
# Open new scope
460+
if fqn is not None:
461+
with graph.inserting_before(node):
462+
enter_node = graph.call_function(
463+
_enter,
464+
({_MODULE_FQN: fqn},),
465+
)
466+
enter_node.meta["custom"] = {}
467+
current_ctx_node = enter_node
468+
469+
current_fqn = fqn
470+
471+
# Close any trailing scope (before output/return)
472+
if current_ctx_node is not None:
473+
output_nodes = [n for n in graph.nodes if n.op == "output"]
474+
if output_nodes:
475+
with graph.inserting_before(output_nodes[0]):
476+
exit_node = graph.call_function(_exit, (current_ctx_node,))
477+
exit_node.meta["custom"] = {}
478+
479+
graph.lint()
480+
gm.recompile()
481+
return gm
482+
483+
383484
def cudagraph_pass(
384485
gm: torch.fx.GraphModule,
385486
example_inputs: tuple,

torchtitan/experiments/graph_trainer/qwen3/parallelize.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def annotate_qwen3(model: GraphTrainerQwen3Model) -> None:
4343
- AC region annotation: Tags each transformer block's forward with a unique
4444
ac_region_id so that apply_sac_pass can assign per-block ac_graph_id
4545
boundaries for the min-cut partitioner.
46+
- Module FQN annotation: Tags each submodule's forward with its
47+
fully-qualified name for downstream passes.
4648
"""
4749
# Annotate MoE EP regions if any layer has MoE enabled
4850
if any(layer.moe is not None for layer in model.config.layers):

0 commit comments

Comments
 (0)