Skip to content

Commit 3e95097

Browse files
authored
fix(cuda-graphs): validate results after full-iteration replay (#7774)
Signed-off-by: Jiangfei Duan <jiangfeid@nvidia.com>
1 parent e7c9336 commit 3e95097

5 files changed

Lines changed: 217 additions & 17 deletions

File tree

‎docs/user-guide/features/cuda_graph.md‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,16 @@ This implementation does not create inference CUDA graphs. For inference, use
122122

123123
### Requirements
124124

125-
- `--no-check-for-nan-in-loss-and-grad` is required: NaN checks involve CPU-GPU synchronization
126-
which cannot run inside a CUDA graph.
125+
- Loss and gradient checks remain enabled. Values passed to the rerun state machine during
126+
capture are snapshotted at their validation sites. Host-side checks run after each replay,
127+
before the training loop decides whether to rerun or update the optimizer.
127128
- `--cuda-graph-modules` must be omitted (or left empty): per-module selection has no meaning
128129
when the entire iteration is captured as a single graph.
129130

130131
### Example
131132

132133
```bash
133-
--cuda-graph-impl full_iteration \
134-
--no-check-for-nan-in-loss-and-grad
134+
--cuda-graph-impl full_iteration
135135
```
136136

137137
---
@@ -152,8 +152,7 @@ All three implementations work for dense models:
152152
# equivalent: --cuda-graph-impl transformer_engine --cuda-graph-modules attn mlp
153153

154154
# Full-iteration
155-
--cuda-graph-impl full_iteration \
156-
--no-check-for-nan-in-loss-and-grad
155+
--cuda-graph-impl full_iteration
157156
```
158157

159158
### MoE Model Training
@@ -174,7 +173,6 @@ graphs to be used on MoE models as well:
174173

175174
```bash
176175
--cuda-graph-impl full_iteration \
177-
--no-check-for-nan-in-loss-and-grad \
178176
--moe-flex-dispatcher-backend hybridep \
179177
--use-transformer-engine-op-fuser \
180178
--moe-expert-rank-capacity-factor <float> \

‎megatron/core/full_cuda_graph.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import torch
99

10+
from megatron.core.rerun_state_machine import get_rerun_state_machine
1011
from megatron.core.tensor_parallel.random import (
1112
cudagraph_needs_generator_registration,
1213
get_all_rng_states,
@@ -228,11 +229,15 @@ def __call__(self, *args, **kwargs):
228229
FullCudaGraphWrapper.cuda_graph[training_str].register_generator_state(state)
229230
torch.cuda.synchronize()
230231
capture_stream = get_shared_capture_stream()
231-
with torch.cuda.graph(
232-
FullCudaGraphWrapper.cuda_graph[training_str],
233-
stream=capture_stream,
234-
pool=get_graph_pool(self.use_single_mempool),
235-
capture_error_mode="thread_local",
232+
rerun_state_machine = get_rerun_state_machine()
233+
with (
234+
torch.cuda.graph(
235+
FullCudaGraphWrapper.cuda_graph[training_str],
236+
stream=capture_stream,
237+
pool=get_graph_pool(self.use_single_mempool),
238+
capture_error_mode="thread_local",
239+
),
240+
rerun_state_machine.capture_deferred_validations(training_str),
236241
):
237242
# Must precede all other captured work; see the helper's docstring.
238243
prime_cuda_rng_states_for_graph_capture()
@@ -247,6 +252,8 @@ def __call__(self, *args, **kwargs):
247252
else:
248253
FullCudaGraphWrapper.cuda_graph[training_str].replay()
249254
self.next_iter(training_str)
255+
# Validate before returning to the training loop's rerun/update decision.
256+
get_rerun_state_machine().run_deferred_validations(training_str)
250257
return FullCudaGraphWrapper.result[training_str]
251258

252259
def curr_iter(self, stage):
@@ -265,10 +272,12 @@ def reset_cuda_graph(self, stage=None):
265272
FullCudaGraphWrapper.cuda_graph['training'] = None
266273
FullCudaGraphWrapper.result['training'] = None
267274
FullCudaGraphWrapper.curr_iteration['training'] = 0
275+
get_rerun_state_machine().clear_deferred_validations("training")
268276
if stage is None or stage == 'validation':
269277
if FullCudaGraphWrapper.cuda_graph['validation'] is not None:
270278
del FullCudaGraphWrapper.cuda_graph['validation']
271279
FullCudaGraphWrapper.cuda_graph['validation'] = None
272280
FullCudaGraphWrapper.result['validation'] = None
273281
FullCudaGraphWrapper.curr_iteration['validation'] = 0
282+
get_rerun_state_machine().clear_deferred_validations("validation")
274283
gc.collect()

‎megatron/core/rerun_state_machine.py‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
import random
99
import re
1010
from collections import defaultdict
11+
from contextlib import contextmanager
1112
from enum import Enum
12-
from typing import Any, Callable, Iterable, List, NamedTuple, Optional, Set, Tuple, Union
13+
from typing import Any, Callable, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, Union
1314

1415
import numpy as np
1516
import torch
@@ -206,6 +207,11 @@ def __init__(
206207
# Request to resume normal execution when no HW fault was detected.
207208
self.continue_requested: bool = False
208209
self.logged_sdc_enabled: bool = False
210+
# None means validation runs immediately. During CUDA graph capture, a list
211+
# (initially empty) records tensor snapshots and validation arguments for
212+
# host-side validation after replay.
213+
self._captured_validation_calls: Optional[list[dict]] = None
214+
self._deferred_validations: dict[str, list[dict]] = {}
209215

210216
self.error_injector: RerunErrorInjector = error_injector or RerunErrorInjector()
211217
self.validation_counts: dict[Caller, int] = defaultdict(int)
@@ -461,6 +467,35 @@ def train_step(data_iterator, ...):
461467
return False, True, EXIT_CODE_RESUME_TO_DISAMBIGUATE
462468
return False, False, 0
463469

470+
@contextmanager
471+
def capture_deferred_validations(self, key: str) -> Iterator[None]:
472+
"""Record validations for a graph, replacing any previous capture under ``key``.
473+
474+
Capture the value at its original validation site, before later in-place
475+
operations can change it. Call ``run_deferred_validations(key)`` after each
476+
replay, before deciding whether to rerun or update the optimizer.
477+
"""
478+
if self._captured_validation_calls is not None:
479+
raise RuntimeError("Result-validation capture cannot be nested")
480+
self._deferred_validations[key] = []
481+
self._captured_validation_calls = self._deferred_validations[key]
482+
try:
483+
yield
484+
except BaseException:
485+
self.clear_deferred_validations(key)
486+
raise
487+
finally:
488+
self._captured_validation_calls = None
489+
490+
def run_deferred_validations(self, key: str) -> None:
491+
"""Validate replayed snapshots without allowing later replays to overwrite saved results."""
492+
for validation in self._deferred_validations.get(key, []):
493+
self.validate_result(**{**validation, "result": validation["result"].clone()})
494+
495+
def clear_deferred_validations(self, key: str) -> None:
496+
"""Release the snapshots and callbacks owned by a graph that is being reset."""
497+
self._deferred_validations.pop(key, None)
498+
464499
def validate_result(
465500
self,
466501
result: Any,
@@ -514,6 +549,21 @@ def train_step(data_iterator, ...):
514549
verifying the result is the same.
515550
"""
516551

552+
if self._captured_validation_calls is not None:
553+
if not isinstance(result, torch.Tensor) or not result.is_cuda:
554+
raise TypeError("Full-graph result validation requires a CUDA tensor result")
555+
self._captured_validation_calls.append(
556+
dict(
557+
result=result.detach().clone(),
558+
rejection_func=rejection_func,
559+
message=message,
560+
comparison_func=comparison_func,
561+
tolerance=tolerance,
562+
fatal=fatal,
563+
)
564+
)
565+
return
566+
517567
# If reruns are disabled, still validate the result and throw a RuntimeError if it is
518568
# rejected when fatal. This is a backward-compatible behavior for infs and NaNs.
519569
if self.mode == RerunMode.DISABLED:

‎megatron/training/arguments.py‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,10 +1149,6 @@ def validate_args(args, defaults={}):
11491149
"--accumulate-allreduce-grads-in-fp32 already reduces in fp32"
11501150
)
11511151

1152-
if args.cuda_graph_impl == "full_iteration":
1153-
assert not args.check_for_nan_in_loss_and_grad, \
1154-
"--no-check-for-nan-in-loss-and-grad should be set with --cuda-graph-impl=full_iteration for training."
1155-
11561152
if args.inference_cuda_graph_scope == InferenceCudaGraphScope.block:
11571153
if args.fp8 is not None:
11581154
assert args.transformer_impl == "inference_optimized", \
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
3+
from unittest.mock import Mock, patch
4+
5+
import pytest
6+
import torch
7+
8+
from megatron.core.full_cuda_graph import FullCudaGraphWrapper
9+
from megatron.core.rerun_state_machine import (
10+
EXIT_CODE_FAILED_ON_RESULT_VALIDATION,
11+
EXIT_CODE_RESUME_TO_DISAMBIGUATE,
12+
RerunMode,
13+
RerunState,
14+
RerunStateMachine,
15+
)
16+
from tests.unit_tests.test_utilities import Utils
17+
18+
19+
@pytest.mark.parametrize(
20+
"bad_value,rejection", [(float("nan"), torch.isnan), (float("inf"), torch.isinf)]
21+
)
22+
@pytest.mark.parametrize("mode", [RerunMode.DISABLED, RerunMode.VALIDATE_RESULTS])
23+
@pytest.mark.parametrize("reproduced", [False, True])
24+
def test_full_cuda_graph_validates_current_replay_before_optimizer(
25+
bad_value, rejection, mode, reproduced
26+
):
27+
"""Replay failures reach rerun decisions without overwriting the retained failed result."""
28+
Utils.initialize_model_parallel(tensor_model_parallel_size=1, pipeline_model_parallel_size=1)
29+
machine = RerunStateMachine(mode=mode)
30+
source = torch.ones((), device="cuda")
31+
32+
def forward_backward_func(**_):
33+
intermediate = source.clone()
34+
machine.validate_result(intermediate, rejection, "captured local norm", fatal=True)
35+
# A reference without a capture-time snapshot would lose the invalid value.
36+
intermediate.zero_()
37+
return [{"loss": intermediate}]
38+
39+
wrapper = FullCudaGraphWrapper(forward_backward_func, cuda_graph_warmup_steps=1)
40+
kwargs = dict(
41+
model=[torch.nn.Identity()],
42+
data_iterator=None,
43+
num_microbatches=1,
44+
seq_length=1,
45+
forward_only=False,
46+
)
47+
with (
48+
patch("megatron.core.full_cuda_graph.get_all_rng_states", return_value={}),
49+
patch("megatron.core.full_cuda_graph.get_rerun_state_machine", return_value=machine),
50+
):
51+
wrapper.reset_cuda_graph()
52+
try:
53+
# Complete a healthy warmup step, then capture and replay another step.
54+
for _ in range(2):
55+
assert machine.should_run_forward_backward(None)
56+
wrapper(**kwargs)
57+
assert not machine.should_run_forward_backward(None)
58+
assert machine.should_checkpoint_and_exit() == (False, False, 0)
59+
60+
source.fill_(bad_value)
61+
assert machine.should_run_forward_backward(None)
62+
if mode == RerunMode.DISABLED:
63+
with pytest.raises(RuntimeError, match="captured local norm"):
64+
wrapper(**kwargs)
65+
return
66+
67+
wrapper(**kwargs)
68+
assert machine.rerun_requested
69+
assert rejection(machine.initial_result)
70+
assert machine.should_run_forward_backward(None)
71+
assert machine.state == RerunState.RERUNNING_IN_PLACE
72+
73+
source.fill_(bad_value if reproduced else 2.0)
74+
wrapper(**kwargs)
75+
assert rejection(machine.initial_result), "Replay overwrote saved failed result"
76+
assert not machine.should_run_forward_backward(None)
77+
if reproduced:
78+
assert machine.state == RerunState.WILL_RERUN_FROM_CHECKPOINT
79+
assert machine.should_checkpoint_and_exit() == (
80+
True,
81+
True,
82+
EXIT_CODE_RESUME_TO_DISAMBIGUATE,
83+
)
84+
else:
85+
assert machine.state == RerunState.RERUNNING_IN_PLACE
86+
assert machine.should_checkpoint_and_exit() == (
87+
False,
88+
True,
89+
EXIT_CODE_FAILED_ON_RESULT_VALIDATION,
90+
)
91+
finally:
92+
wrapper.reset_cuda_graph()
93+
# Reset must remove callbacks as well as the CUDA graph itself.
94+
with patch.object(machine, "validate_result") as validate:
95+
machine.run_deferred_validations("training")
96+
validate.assert_not_called()
97+
Utils.destroy_model_parallel()
98+
99+
100+
def test_deferred_validations_are_replaced_and_cleared_per_graph():
101+
"""Recapture replaces one graph's checks, and reset preserves the other graph's checks."""
102+
machine = RerunStateMachine(mode=RerunMode.DISABLED)
103+
reject = Mock(return_value=False)
104+
for key, value in [("training", 1.0), ("validation", 2.0), ("training", 3.0)]:
105+
with machine.capture_deferred_validations(key):
106+
machine.validate_result(torch.tensor(value, device="cuda"), reject, key)
107+
reject.assert_not_called()
108+
machine.run_deferred_validations("training")
109+
reject.assert_called_once()
110+
assert reject.call_args.args[0].item() == 3.0
111+
112+
wrapper = FullCudaGraphWrapper(lambda **_: None)
113+
with patch("megatron.core.full_cuda_graph.get_rerun_state_machine", return_value=machine):
114+
wrapper.reset_cuda_graph("training")
115+
machine.run_deferred_validations("training")
116+
reject.assert_called_once()
117+
machine.run_deferred_validations("validation")
118+
assert reject.call_count == 2
119+
assert reject.call_args.args[0].item() == 2.0
120+
wrapper.reset_cuda_graph()
121+
machine.run_deferred_validations("validation")
122+
assert reject.call_count == 2
123+
124+
125+
def test_validation_capture_restores_state_after_error():
126+
"""A failed capture must discard partial checks and restore immediate validation."""
127+
machine = RerunStateMachine(mode=RerunMode.DISABLED)
128+
reject = Mock(return_value=False)
129+
with pytest.raises(RuntimeError, match="cannot be nested"):
130+
with machine.capture_deferred_validations("training"):
131+
machine.validate_result(torch.ones((), device="cuda"), reject, "partial capture")
132+
with machine.capture_deferred_validations("validation"):
133+
pass
134+
machine.run_deferred_validations("training")
135+
reject.assert_not_called()
136+
with machine.capture_deferred_validations("training"):
137+
pass
138+
with pytest.raises(RuntimeError, match="eager NaN"):
139+
machine.validate_result(torch.tensor(float("nan")), torch.isnan, "eager NaN")
140+
141+
142+
def test_validation_capture_rejects_host_results():
143+
"""Host values cannot reflect updates made by graph replay."""
144+
machine = RerunStateMachine(mode=RerunMode.DISABLED)
145+
with machine.capture_deferred_validations("training"):
146+
with pytest.raises(TypeError, match="CUDA tensor"):
147+
machine.validate_result(torch.tensor(1.0), torch.isnan, "host result")

0 commit comments

Comments
 (0)