Skip to content

Commit 8ba35c6

Browse files
[graph_trainer] Add torch.no_grad() and graph-based SAC to traced execution
Execute traced graph under torch.no_grad() since it already contains explicit backward ops (from torch.autograd.grad traced by make_fx). Without this, PyTorch builds a redundant autograd graph keeping all forward intermediates alive via grad_fn references. Replace monkey-patched _CachingTorchDispatchMode AC approach with clean graph-based SAC: annotate_ac_regions before tracing, apply_sac_pass (which now skips backward-tagged nodes) for post-hoc tagging, then remat_using_tags_for_fwd_loss_bwd_graph for the remat transform. apply_ac_remat_pass now takes GraphModule and returns GraphModule, following the standard pass signature convention. Results on Llama 1B (H100): traced graph SAC uses 18.23 GB vs eager SAC 19.60 GB (0.93x ratio), with bitwise identical losses and gradients. ghstack-source-id: 157213f Pull Request resolved: #2766
1 parent 0610235 commit 8ba35c6

9 files changed

Lines changed: 298 additions & 126 deletions

File tree

.github/workflows/integration_test_8gpu_graph_trainer.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ jobs:
7878
# Run precompile unit tests
7979
pytest torchtitan/experiments/graph_trainer/tests/test_precompile.py -v
8080
81-
# Run bitwise deterministic guardrail test
81+
# Run bitwise deterministic and SAC peak-memory guardrail tests
8282
pytest torchtitan/experiments/graph_trainer/tests/test_bitwise_deterministic.py -v
83+
pytest torchtitan/experiments/graph_trainer/tests/test_sac_peak_memory.py -v
8384
8485
rm -rf $RUNNER_TEMP/artifacts-to-be-uploaded/*/checkpoint

.github/workflows/integration_test_8gpu_graph_trainer_h100.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ jobs:
6868
# Run the MoE numerics tests
6969
NCCL_NVLS_ENABLE=0 pytest torchtitan/experiments/graph_trainer/tests/test_numerics.py::TestGraphTrainerNumerics -v -k "moe"
7070
71-
# Run bitwise deterministic guardrail test (includes H100-only hardcoded-hash tests)
71+
# Run bitwise deterministic and SAC peak-memory guardrail tests
7272
pytest torchtitan/experiments/graph_trainer/tests/test_bitwise_deterministic.py -v
73+
pytest torchtitan/experiments/graph_trainer/tests/test_sac_peak_memory.py -v
7374
7475
rm -rf $RUNNER_TEMP/artifacts-to-be-uploaded/*/checkpoint

tests/integration_tests/run_tests.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,13 @@ def run_single_test(
9191
)
9292

9393

94-
def run_tests(args, test_list: list[OverrideDefinitions], module=None, config=None):
94+
def run_tests(
95+
args,
96+
test_list: list[OverrideDefinitions],
97+
module=None,
98+
config=None,
99+
):
95100
"""Run all integration tests to test the core features of TorchTitan"""
96-
97101
exclude_set = set()
98102
if hasattr(args, "exclude") and args.exclude:
99103
exclude_set = {name.strip() for name in args.exclude.split(",")}
@@ -123,7 +127,12 @@ def run_tests(args, test_list: list[OverrideDefinitions], module=None, config=No
123127
)
124128
else:
125129
try:
126-
run_single_test(test_flavor, args.output_dir, module, config)
130+
run_single_test(
131+
test_flavor,
132+
args.output_dir,
133+
module,
134+
config,
135+
)
127136
except Exception as e:
128137
logger.error(str(e))
129138
failed_tests.append((test_flavor.test_name, str(e)))

torchtitan/experiments/graph_trainer/make_fx_tracer.py

Lines changed: 19 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@
1212
import torch
1313
import torch.nn as nn
1414
import torch.utils._pytree as pytree
15-
from torch._functorch._aot_autograd.logging_utils import (
16-
setup_stacktrace_preservation_hooks,
17-
)
1815
from torch._guards import tracing, TracingContext
1916
from torch._subclasses import FakeTensorMode
2017
from torch.fx.experimental.proxy_tensor import make_fx
@@ -200,52 +197,6 @@ def _remove_cpu_shadow_chains(gm: torch.fx.GraphModule) -> None:
200197
gm.recompile()
201198

202199

203-
@contextmanager
204-
def _patch_engine_run_backward() -> Generator[None, None, None]:
205-
"""Patch _engine_run_backward to install stacktrace preservation hooks.
206-
207-
Why this is needed:
208-
When make_fx traces a function that calls loss.backward(), the backward
209-
pass is decomposed into primitive ATen ops. Normally (in eager autograd),
210-
``setup_stacktrace_preservation_hooks`` is called by the autograd engine
211-
to propagate ``seq_nr`` from forward ops to their corresponding backward
212-
ops. Under make_fx tracing, this hook setup doesn't happen automatically
213-
because the engine path differs, so backward FX nodes end up without
214-
``seq_nr`` metadata. Without ``seq_nr``, we can't correlate backward
215-
nodes back to their forward counterparts (needed by
216-
``_copy_fwd_metadata_to_bw_nodes``).
217-
218-
This context manager patches ``_engine_run_backward`` to call
219-
``setup_stacktrace_preservation_hooks`` before the autograd engine runs,
220-
restoring ``seq_nr`` propagation during tracing.
221-
222-
We must patch the name in both modules since ``torch.autograd.__init__``
223-
imports it via ``from .graph import``.
224-
"""
225-
import torch.autograd
226-
import torch.autograd.graph
227-
228-
_orig_fn = torch.autograd.graph._engine_run_backward
229-
230-
def _patched(t_outputs, *args, **kwargs): # type: ignore[no-untyped-def]
231-
roots = [
232-
t.grad_fn
233-
for t in t_outputs
234-
if isinstance(t, torch.Tensor) and t.grad_fn is not None
235-
]
236-
if roots:
237-
setup_stacktrace_preservation_hooks(roots)
238-
return _orig_fn(t_outputs, *args, **kwargs)
239-
240-
torch.autograd.graph._engine_run_backward = _patched # type: ignore[assignment]
241-
torch.autograd._engine_run_backward = _patched # type: ignore[assignment]
242-
try:
243-
yield
244-
finally:
245-
torch.autograd.graph._engine_run_backward = _orig_fn # type: ignore[assignment]
246-
torch.autograd._engine_run_backward = _orig_fn # type: ignore[assignment]
247-
248-
249200
def _copy_fwd_metadata_to_bw_nodes(fx_g: torch.fx.GraphModule) -> None:
250201
"""Copy forward node metadata (custom) to later nodes sharing the same seq_nr.
251202
@@ -401,18 +352,27 @@ def fn_with_subclass_handling(*plain_args: Any) -> list:
401352

402353
state_for_fn = dict(zip(state_fqns, state_wrapped, strict=True))
403354
user_list = pytree.tree_unflatten(list(user_flat), user_args_spec)
404-
405-
with _patch_engine_run_backward():
355+
with torch.compiler._patch_autograd_grad():
406356
result = fn(state_for_fn, *user_list)
407-
408357
flat_outs, output_spec = pytree.tree_flatten(result)
409358
num_flat_outputs = len(flat_outs)
410359
unwrapped_outs, output_layouts = _unwrap_subclasses(flat_outs)
411360
return unwrapped_outs
412361

413362
ctx = TracingContext(fake_mode)
414363
# preserve_node_meta propagates fx.traceback.annotate metadata to traced nodes
415-
with fake_mode, tracing(ctx), preserve_node_meta(), _skip_nested_compile():
364+
# _skip_nested_compile lets this tracer run when an outer dynamo trace
365+
# reaches torch.compile'd FlexAttention kernels.
366+
# _non_strict_tracing_context is required by _patch_autograd_grad() and
367+
# marks this make_fx pass as the non-strict tracing flow, distinct from
368+
# other make_fx-based entry points such as non-strict export.
369+
with (
370+
fake_mode,
371+
tracing(ctx),
372+
preserve_node_meta(),
373+
_skip_nested_compile(),
374+
torch.compiler._non_strict_tracing_context(),
375+
):
416376
traced = make_fx(
417377
fn_with_subclass_handling,
418378
record_stack_traces=True,
@@ -450,6 +410,10 @@ def run_traced(
450410
This is a reference implementation of traced-graph execution. It keeps the
451411
state handling, subclass unwrapping, and output reconstruction logic
452412
explicit instead of baking those semantics into ``TracedResult`` itself.
413+
Runs under ``torch.no_grad()`` because the graph already contains explicit
414+
backward ops (from ``torch.autograd.grad`` traced by make_fx). Without
415+
this, PyTorch would build a redundant autograd graph on top, keeping all
416+
forward intermediates alive via ``grad_fn`` references.
453417
"""
454418
state_flat = list(state.values())
455419
user_args_flat, _ = pytree.tree_flatten(list(args))
@@ -461,7 +425,8 @@ def run_traced(
461425
all_args = list(state_flat) + list(user_args_flat)
462426
flat_inputs, _ = _unwrap_subclasses(all_args)
463427

464-
flat_outputs = traced_result.gm(*flat_inputs)
428+
with torch.no_grad():
429+
flat_outputs = traced_result.gm(*flat_inputs)
465430
wrapped = _wrap_subclasses(
466431
flat_outputs,
467432
traced_result.num_flat_outputs,

torchtitan/experiments/graph_trainer/passes.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@
4949
from torchtitan.tools.logging import logger
5050

5151

52+
def _is_backward_node(node: torch.fx.Node) -> bool:
53+
return node.meta.get("autograd_backward", False)
54+
55+
5256
def construct_default_graph_passes(
5357
traced_result: "TracedResult",
5458
) -> list[Callable]:
@@ -68,6 +72,7 @@ def construct_default_graph_passes(
6872

6973
passes: list[Callable] = [
7074
functools.partial(tlparse_log_graph_pass, graph_name="make_fx_graph_traced"),
75+
apply_ac_on_fwd_bwd_graph,
7176
remove_detach_pass,
7277
remove_identity_view_pass,
7378
remove_identity_slice_pass,
@@ -409,6 +414,11 @@ def apply_sac_pass(
409414
if node.op != "call_function":
410415
continue
411416

417+
# Skip backward nodes — they must not carry recompute tags,
418+
# otherwise the remat pass would try to duplicate backward ops.
419+
if _is_backward_node(node):
420+
continue
421+
412422
if node.target in (
413423
operator.getitem,
414424
torch.ops._c10d_functional.wait_tensor.default,
@@ -426,7 +436,6 @@ def apply_sac_pass(
426436
node.meta["recompute"] = parent.meta["recompute"]
427437
node.meta["ac_graph_id"] = parent.meta.get("ac_graph_id", 0)
428438
continue
429-
430439
custom_meta = node.meta.get("custom", {})
431440
ac_region_id = custom_meta.get(_AC_REGION_ID, 0)
432441
node.meta["ac_graph_id"] = ac_region_id
@@ -461,6 +470,27 @@ def apply_sac_pass(
461470
return gm
462471

463472

473+
def apply_ac_on_fwd_bwd_graph(
474+
gm: torch.fx.GraphModule, example_inputs: tuple | None = None
475+
) -> torch.fx.GraphModule:
476+
"""Apply graph-based SAC to a traced fwd+loss+bwd graph.
477+
478+
Tags forward nodes with recompute policy via apply_sac_pass (backward
479+
nodes are skipped automatically via ``node.meta["autograd_backward"]``), then
480+
applies remat_using_tags_for_fwd_loss_bwd_graph to duplicate
481+
PREFER_RECOMPUTE forward ops before backward and DCE originals.
482+
483+
The model must have been annotated with annotate_ac_regions before
484+
tracing so that nodes have custom["ac_region_id"] metadata.
485+
"""
486+
from torch._functorch._activation_checkpointing.remat_using_tags_for_fwd_loss_bwd_graph_pass import (
487+
remat_using_tags_for_fwd_loss_bwd_graph,
488+
)
489+
490+
apply_sac_pass(gm)
491+
return remat_using_tags_for_fwd_loss_bwd_graph(gm)
492+
493+
464494
# Apply activation checkpointing on joint graph before partitioner
465495
def fsdp_reshard_after_fwd_pass(
466496
gm: torch.fx.GraphModule,
@@ -561,7 +591,7 @@ def inductor_decomposition_pass(
561591
f"Placeholder count mismatch: {len(orig_placeholders)} vs {len(decomp_placeholders)}"
562592
)
563593

564-
for orig, decomp in zip(orig_placeholders, decomp_placeholders):
594+
for orig, decomp in zip(orig_placeholders, decomp_placeholders, strict=True):
565595
# Copy all metadata from original to decomposed
566596
for key, value in orig.meta.items():
567597
if key not in decomp.meta:
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
from types import SimpleNamespace
8+
9+
import torch
10+
import torch.nn as nn
11+
12+
from torchtitan.components.loss import cross_entropy_loss
13+
from torchtitan.config import ActivationCheckpointConfig
14+
from torchtitan.distributed.utils import get_train_context
15+
from torchtitan.experiments.graph_trainer.trainer import GraphTrainer
16+
from torchtitan.trainer import Trainer
17+
18+
19+
def build_minimal_trainer(
20+
model: nn.Module,
21+
model_config,
22+
trainer_cls: type[Trainer],
23+
*,
24+
activation_checkpoint_mode: str = "none",
25+
compile_enable_passes: bool = True,
26+
compile_passes: list[str] | None = None,
27+
compile_joint_passes: list[str] | None = None,
28+
tokenizer=None,
29+
) -> Trainer:
30+
"""Build the minimal Trainer/GraphTrainer needed for single-GPU test steps."""
31+
trainer = object.__new__(trainer_cls)
32+
trainer.model_parts = [model]
33+
trainer.loss_fn = cross_entropy_loss
34+
trainer.parallel_dims = SimpleNamespace(pp_enabled=False, cp_enabled=False)
35+
trainer.train_context = get_train_context(False)
36+
trainer.model_config = model_config
37+
trainer.device = torch.device("cuda")
38+
trainer.tokenizer = tokenizer
39+
40+
if trainer_cls is GraphTrainer:
41+
trainer.config = SimpleNamespace(
42+
compile=SimpleNamespace(
43+
mode="aot_fx_trace",
44+
enable_passes=compile_enable_passes,
45+
passes=[] if compile_passes is None else list(compile_passes),
46+
joint_passes=[]
47+
if compile_joint_passes is None
48+
else list(compile_joint_passes),
49+
),
50+
activation_checkpoint=ActivationCheckpointConfig(
51+
mode=activation_checkpoint_mode
52+
),
53+
)
54+
trainer._fwd_bwd_step_module = None
55+
trainer._traced_step = None
56+
else:
57+
trainer.config = SimpleNamespace()
58+
59+
return trainer

torchtitan/experiments/graph_trainer/tests/test_bitwise_deterministic.py

Lines changed: 9 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,13 @@
1616
import copy
1717
import unittest
1818
from collections.abc import Callable
19-
from types import SimpleNamespace
2019

2120
import torch
2221
import torch.nn as nn
2322
from expecttest import assert_expected_inline
2423
from tests.utils import hash_gradient, hash_model
2524

26-
from torchtitan.components.loss import cross_entropy_loss
2725
from torchtitan.components.tokenizer import HuggingFaceTokenizer
28-
from torchtitan.distributed.utils import get_train_context
2926
from torchtitan.experiments.graph_trainer.deepseek_v3 import (
3027
model_registry as dsv3_model_registry,
3128
)
@@ -36,6 +33,9 @@
3633
model_registry as llama3_model_registry,
3734
)
3835
from torchtitan.experiments.graph_trainer.llama3.parallelize import annotate_llama
36+
from torchtitan.experiments.graph_trainer.tests._trainer_test_utils import (
37+
build_minimal_trainer,
38+
)
3939
from torchtitan.experiments.graph_trainer.trainer import GraphTrainer
4040
from torchtitan.tools.utils import has_cuda_capability
4141
from torchtitan.trainer import Trainer
@@ -57,42 +57,6 @@ def _set_deterministic(seed: int = SEED) -> None:
5757
_TOKENIZER_PATH = "./tests/assets/tokenizer"
5858

5959

60-
def _build_trainer(
61-
model: nn.Module,
62-
model_config,
63-
trainer_cls: type,
64-
*,
65-
enable_passes: bool = True,
66-
) -> Trainer:
67-
"""Build a minimal Trainer/GraphTrainer for single-GPU non-distributed testing.
68-
69-
Uses object.__new__ to bypass __init__ because the full Trainer constructor
70-
requires a distributed environment, job config, and checkpoint manager that
71-
are unnecessary for single-GPU numerical verification. The attributes set
72-
below are the minimal set required by forward_backward_step().
73-
"""
74-
trainer = object.__new__(trainer_cls)
75-
trainer.model_parts = [model]
76-
trainer.loss_fn = cross_entropy_loss
77-
trainer.parallel_dims = SimpleNamespace(pp_enabled=False, cp_enabled=False)
78-
trainer.train_context = get_train_context(False)
79-
trainer.model_config = model_config
80-
trainer.device = torch.device("cuda")
81-
trainer.tokenizer = HuggingFaceTokenizer(tokenizer_path=_TOKENIZER_PATH)
82-
83-
if trainer_cls is GraphTrainer:
84-
trainer.config = SimpleNamespace(
85-
compile=SimpleNamespace(
86-
mode="aot_fx_trace",
87-
enable_passes=enable_passes,
88-
)
89-
)
90-
trainer._fwd_bwd_step_module = None
91-
trainer._traced_step = None
92-
93-
return trainer
94-
95-
9660
@unittest.skipIf(not torch.cuda.is_available(), "CUDA not available")
9761
class BitwiseDeterministicBase(unittest.TestCase):
9862
"""Base class for bitwise determinism tests.
@@ -131,8 +95,12 @@ def _run_steps(
13195
# Annotate after deepcopy: annotate_fn wrappers capture bound methods
13296
# that don't rebind correctly through copy.deepcopy.
13397
self.annotate_model(model)
134-
trainer = _build_trainer(
135-
model, self.model_config, trainer_cls, enable_passes=enable_passes
98+
trainer = build_minimal_trainer(
99+
model,
100+
self.model_config,
101+
trainer_cls,
102+
compile_enable_passes=enable_passes,
103+
tokenizer=HuggingFaceTokenizer(tokenizer_path=_TOKENIZER_PATH),
136104
)
137105
global_valid_tokens = torch.tensor(
138106
BATCH_SIZE * SEQ_LEN, dtype=torch.float, device="cuda"
@@ -318,11 +286,6 @@ def test_eager_self_deterministic(self):
318286
"""8bb6e647c3edaa229cc65872086ccc5c4e1b7f1647bb01da4506ab777a64a0db""",
319287
)
320288

321-
# TODO: OOMs during flex_attention compilation on A100 GPUs.
322-
# Revisit when GraphTrainer addresses peak memory during compilation.
323-
@unittest.skipUnless(
324-
has_cuda_capability(9, 0), "OOMs during flex_attention compilation on A100"
325-
)
326289
def test_aot_fx_trace_vs_eager(self):
327290
"""aot_fx_trace with passes and eager produce bitwise identical results."""
328291
run_eager = self._run_steps(copy.deepcopy(self.model), Trainer)

0 commit comments

Comments
 (0)