Skip to content

Commit af19dbb

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 08da451 commit af19dbb

12 files changed

Lines changed: 722 additions & 6 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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,29 @@
1818
from torchtitan.tools.logging import logger
1919

2020
_AC_REGION_ID = "ac_region_id"
21+
_MODULE_FQN = "module_fqn"
22+
23+
24+
def annotate_module_fqns(model: nn.Module) -> None:
25+
"""Annotate all modules' forward with their fully-qualified names.
26+
27+
Every named submodule (excluding the root) gets its forward method wrapped
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.
32+
33+
.. note::
34+
35+
When traced via ``trace_train_step``, multiple instances of the
36+
**same class with no parameters** may all receive the first instance's
37+
FQN. This is because ``_reparametrize_module`` with an empty state
38+
dict causes ``make_fx`` to collapse the ``annotate_fn`` contexts for
39+
parameterless same-class instances.
40+
"""
41+
for fqn, submodule in model.named_modules():
42+
if fqn: # skip root module
43+
submodule.forward = annotate_fn({_MODULE_FQN: fqn})(submodule.forward)
2144

2245

2346
def annotate_ac_regions(model: nn.Module) -> None:

torchtitan/experiments/graph_trainer/configs.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def to_graph_trainer_config(
6969
from the graph_trainer model_registry. The compile field is removed and
7070
left as the GraphTrainerConfig default; callers should explicitly set it.
7171
"""
72+
from .cudagraph import cudagraph_annotate_trace_post_processor
7273
from .trainer import GraphTrainer
7374

7475
d = {f.name: getattr(base_config, f.name) for f in fields(base_config)}
@@ -96,4 +97,11 @@ def to_graph_trainer_config(
9697
if ac is not None and ac.mode != "none":
9798
d["activation_checkpoint"] = ActivationCheckpointConfig(mode="selective")
9899

100+
# Merge CUDA graph kernel annotations into profiler traces when profiling
101+
# is active. No-op otherwise (and no-op when requirements aren't met).
102+
# It's also a no-op if there is CUDA graph is not enabled.
103+
profiler = d.get("profiler")
104+
if profiler is not None:
105+
profiler.trace_post_processors.append(cudagraph_annotate_trace_post_processor())
106+
99107
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 ``ProfilingConfig.trace_post_processors`` 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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from torchtitan.distributed.tensor_parallel import maybe_enable_async_tp
2020
from torchtitan.experiments.graph_trainer.common_utils import (
2121
annotate_ac_regions,
22+
annotate_module_fqns,
2223
apply_graph_ac,
2324
)
2425
from torchtitan.experiments.graph_trainer.compile import apply_compile
@@ -42,6 +43,9 @@ def annotate_deepseekv3(model: GraphTrainerDeepSeekV3Model) -> None:
4243
- AC region annotation: Tags each transformer block's forward with a unique
4344
ac_region_id so that apply_sac_pass can assign per-block ac_graph_id
4445
boundaries for the min-cut partitioner.
46+
- Module FQN annotation: Tags each submodule's forward with its
47+
fully-qualified name so that insert_kernel_annotations_pass can label
48+
CUDA graph kernels with their originating module.
4549
4650
"""
4751
from torchtitan.models.common.moe import MoE
@@ -56,6 +60,7 @@ def annotate_deepseekv3(model: GraphTrainerDeepSeekV3Model) -> None:
5660
MoE.forward = annotate_fn({"EP": "compute"})(MoE.forward)
5761

5862
annotate_ac_regions(model)
63+
annotate_module_fqns(model)
5964

6065

6166
# Adapted from llama4/infra/parallelize.py

torchtitan/experiments/graph_trainer/llama3/parallelize.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from torchtitan.distributed.tensor_parallel import maybe_enable_async_tp
1717
from torchtitan.experiments.graph_trainer.common_utils import (
1818
annotate_ac_regions,
19+
annotate_module_fqns,
1920
apply_graph_ac,
2021
)
2122
from torchtitan.experiments.graph_trainer.compile import apply_compile
@@ -35,8 +36,12 @@ def annotate_llama(model: GraphTrainerLlama3Model) -> None:
3536
- AC region annotation: Tags each transformer block's forward with a unique
3637
ac_region_id so that apply_sac_pass can assign per-block ac_graph_id
3738
boundaries for the min-cut partitioner.
39+
- Module FQN annotation: Tags each submodule's forward with its
40+
fully-qualified name so that insert_kernel_annotations_pass can label
41+
CUDA graph kernels with their originating module.
3842
"""
3943
annotate_ac_regions(model)
44+
annotate_module_fqns(model)
4045

4146

4247
def parallelize_llama(

torchtitan/experiments/graph_trainer/passes.py

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,22 @@ def construct_default_graph_passes(
116116
from torchtitan.experiments.graph_trainer.cudagraph import is_cudagraph_compatible
117117

118118
passes: list[Callable] = []
119+
cudagraph_compatible = is_cudagraph_compatible(traced_result.gm)
120+
119121
if not precompiled:
120-
passes.extend(compile_time_passes(traced_result))
122+
pre_passes = compile_time_passes(traced_result)
123+
# insert_kernel_annotations must run before custom_codegen_pass
124+
# (which saves code to disk and replaces forward with a loaded
125+
# module) so that the inserted mark_kernels enter/exit calls end
126+
# up in the saved graph.
127+
if cudagraph_compatible:
128+
pre_passes = _insert_before_custom_codegen(
129+
pre_passes, insert_kernel_annotations_pass
130+
)
131+
passes.extend(pre_passes)
121132

122133
# cudagraph should be the last pass.
123-
if is_cudagraph_compatible(traced_result.gm):
134+
if cudagraph_compatible:
124135
static_input_indices = list(range(traced_result.num_static_inputs))
125136
passes.append(
126137
functools.partial(
@@ -133,6 +144,26 @@ def construct_default_graph_passes(
133144
return passes
134145

135146

147+
def _insert_before_custom_codegen(
148+
passes: list[Callable], new_pass: Callable
149+
) -> list[Callable]:
150+
"""Return a new pass list with ``new_pass`` inserted before custom_codegen_pass.
151+
152+
If custom_codegen_pass is not present, appends ``new_pass`` at the end.
153+
"""
154+
result: list[Callable] = []
155+
inserted = False
156+
for p in passes:
157+
name = getattr(p, "__name__", "")
158+
if not inserted and name == "custom_codegen_pass":
159+
result.append(new_pass)
160+
inserted = True
161+
result.append(p)
162+
if not inserted:
163+
result.append(new_pass)
164+
return result
165+
166+
136167
def apply_graph_passes(
137168
gm: torch.fx.GraphModule,
138169
example_inputs: tuple,
@@ -320,6 +351,96 @@ def _get_fake_mode_from_gm(gm: torch.fx.GraphModule):
320351
return gm
321352

322353

354+
def insert_kernel_annotations_pass(
355+
gm: torch.fx.GraphModule,
356+
example_inputs: tuple | None = None,
357+
) -> torch.fx.GraphModule:
358+
"""Insert mark_kernels() calls at module boundaries in the FX graph.
359+
360+
Reads ``node.meta["custom"]["module_fqn"]`` (set via
361+
``annotate_module_fqns``) and inserts enter/exit calls so that
362+
CUDA graph capture records the annotations.
363+
364+
Requires ``cuda-python`` package and CUDA toolkit/driver >= 13.1
365+
(or cuda-compat >= 13.1). Returns the graph unchanged when unavailable.
366+
367+
Also enables annotation capture on :class:`CUDAGraphWrapper` so that
368+
``enable_annotations=True`` is passed to ``torch.cuda.graph()``.
369+
370+
Alternative approaches:
371+
372+
1. **fx.Interpreter**: During cudagraph capture, run the graph via an
373+
``fx.Interpreter`` subclass that reads ``module_fqn`` metadata and
374+
calls ``mark_kernels`` enter/exit around each node — avoids mutating
375+
the graph.
376+
2. **Custom CodeGen**: Use a custom ``torch.fx.graph.CodeGen`` to emit
377+
enter/exit lines (or ``with`` blocks) directly in the generated
378+
Python code.
379+
380+
The current graph-pass approach is the least invasive.
381+
"""
382+
from torch.cuda._graph_annotations import _is_tools_id_unavailable
383+
384+
from torchtitan.experiments.graph_trainer.common_utils import _MODULE_FQN
385+
from torchtitan.experiments.graph_trainer.cudagraph import (
386+
enable_cudagraph_annotations,
387+
)
388+
389+
def _enter(annotation: dict) -> object:
390+
from torch.cuda._graph_annotations import mark_kernels
391+
392+
ctx = mark_kernels(annotation)
393+
ctx.__enter__()
394+
return ctx
395+
396+
def _exit(ctx: object) -> None:
397+
ctx.__exit__(None, None, None) # type: ignore[union-attr]
398+
399+
if _is_tools_id_unavailable():
400+
return gm
401+
402+
enable_cudagraph_annotations()
403+
404+
graph = gm.graph
405+
current_fqn: str | None = None
406+
current_ctx_node = None
407+
408+
for node in list(graph.nodes):
409+
fqn = (node.meta.get("custom") or {}).get(_MODULE_FQN)
410+
411+
if fqn != current_fqn:
412+
# Close previous scope
413+
if current_ctx_node is not None:
414+
with graph.inserting_before(node):
415+
exit_node = graph.call_function(_exit, (current_ctx_node,))
416+
exit_node.meta["custom"] = {}
417+
current_ctx_node = None
418+
419+
# Open new scope
420+
if fqn is not None:
421+
with graph.inserting_before(node):
422+
enter_node = graph.call_function(
423+
_enter,
424+
({_MODULE_FQN: fqn},),
425+
)
426+
enter_node.meta["custom"] = {}
427+
current_ctx_node = enter_node
428+
429+
current_fqn = fqn
430+
431+
# Close any trailing scope (before output/return)
432+
if current_ctx_node is not None:
433+
output_nodes = [n for n in graph.nodes if n.op == "output"]
434+
if output_nodes:
435+
with graph.inserting_before(output_nodes[0]):
436+
exit_node = graph.call_function(_exit, (current_ctx_node,))
437+
exit_node.meta["custom"] = {}
438+
439+
graph.lint()
440+
gm.recompile()
441+
return gm
442+
443+
323444
def cudagraph_pass(
324445
gm: torch.fx.GraphModule,
325446
example_inputs: tuple,

0 commit comments

Comments
 (0)