Skip to content

Commit d16c754

Browse files
[PyTorch] Pair delayed-scaling FP8 recompute metadata per module (#3394)
* Pair FP8 recompute restores with their stashes The delayed-scaling stash and its two restore sites made independent decisions. Module mode changes between the original forward and checkpoint replay could therefore leak a stash or restore one that was never created. Track pending stashes per module and record whether each prepare_forward call swapped one in, so end_forward performs exactly the matching restore. Stash every delayed-scaling FP8 module encountered in checkpoint phase 1: in reentrant checkpointing the original forward runs under no_grad, so an eval module receiving an intermediate tensor has no module-local autograd signal even though backward will replay it. Tests cover training and eval modules, both checkpoint implementations, mode changes in both directions, repeated iterations, multi-module reentrant replay, and exact FIFO drainage. Signed-off-by: Nitin Vegesna <nvegesna@nvidia.com> * Test minimal FP8 eval recompute stash fix Signed-off-by: Nitin Vegesna <nvegesna@nvidia.com> * Bypass FP8 recompute bookkeeping without autograd Signed-off-by: Nitin Vegesna <nvegesna@nvidia.com> * Test explicit grad inside no-grad checkpoint Signed-off-by: Nitin Vegesna <nvegesna@nvidia.com> * Address FP8 recompute review feedback Signed-off-by: Nitin Vegesna <nvegesna@nvidia.com> * Guard FP8 recompute state by autograd eligibility Signed-off-by: Nitin Vegesna <nvegesna@nvidia.com> --------- Signed-off-by: Nitin Vegesna <nvegesna@nvidia.com> Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com> Co-authored-by: Pawel Gadzinski <pgadzinski@nvidia.com>
1 parent 2a435a2 commit d16c754

3 files changed

Lines changed: 201 additions & 5 deletions

File tree

‎tests/pytorch/test_numerics.py‎

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import math
66
import os
7-
from contextlib import contextmanager
7+
from contextlib import contextmanager, nullcontext
88
from typing import Dict, List, Tuple, Optional
99
import pytest
1010

@@ -928,6 +928,20 @@ def _checkpointed_linear_backward(body, use_reentrant, *layers):
928928
assert torch.isfinite(layer.weight.grad).all()
929929

930930

931+
def _assert_fp8_recompute_state_drained(*layers):
932+
"""Check that each module consumed its stash and restored its live scale."""
933+
recompute_buffer = FP8GlobalStateManager.quantization_state.fp8_tensors_recompute_buffer
934+
assert len(recompute_buffer) == len(layers)
935+
for layer in layers:
936+
assert _FP8_RECOMPUTE_KEY in layer.fp8_meta
937+
assert len(recompute_buffer[layer.fp8_meta[_FP8_RECOMPUTE_KEY]]) == 0
938+
assert "updated_scale_fwd" in layer.fp8_meta
939+
assert torch.equal(
940+
layer.fp8_meta["scaling_fwd"].scale,
941+
layer.fp8_meta["updated_scale_fwd"],
942+
)
943+
944+
931945
@pytest.mark.skipif(not fp8_available, reason=reason_for_no_fp8)
932946
@pytest.mark.parametrize("use_reentrant", all_boolean)
933947
def test_checkpoint_inner_autocast_is_an_fp8_recompute_region(use_reentrant):
@@ -977,6 +991,176 @@ def body(value):
977991
assert _FP8_RECOMPUTE_KEY in fp8_layer.fp8_meta
978992

979993

994+
@pytest.mark.skipif(not fp8_available, reason=reason_for_no_fp8)
995+
@pytest.mark.parametrize("use_reentrant", all_boolean)
996+
@pytest.mark.parametrize("recompute_training", all_boolean)
997+
def test_checkpoint_eval_module_balances_fp8_recompute_state(recompute_training, use_reentrant):
998+
"""An eval module must stash metadata for the recompute forward."""
999+
FP8GlobalStateManager.reset()
1000+
fp8_recipe = recipe.DelayedScaling(fp8_format=recipe.Format.HYBRID)
1001+
layer = Linear(16, 16, bias=False, params_dtype=torch.float32).cuda().eval()
1002+
1003+
def body(value):
1004+
with autocast(enabled=True, recipe=fp8_recipe):
1005+
# Exercise an intermediate input whose grad state differs between the
1006+
# reentrant and non-reentrant checkpoint forward implementations.
1007+
return layer(value * 2)
1008+
1009+
inp = torch.randn(16, 16, device="cuda", dtype=torch.bfloat16, requires_grad=True)
1010+
with torch.autocast("cuda", dtype=torch.bfloat16):
1011+
loss = te_checkpoint(body, inp, use_reentrant=use_reentrant).float().sum()
1012+
1013+
layer.train(recompute_training)
1014+
loss.backward()
1015+
torch.cuda.synchronize()
1016+
1017+
assert inp.grad is not None and torch.isfinite(inp.grad).all()
1018+
assert layer.weight.grad is not None and torch.isfinite(layer.weight.grad).all()
1019+
_assert_fp8_recompute_state_drained(layer)
1020+
1021+
1022+
@pytest.mark.skipif(not fp8_available, reason=reason_for_no_fp8)
1023+
def test_checkpoint_without_autograd_does_not_accumulate_recompute_stashes():
1024+
"""Checkpoint calls without autograd must not leave unreachable metadata."""
1025+
FP8GlobalStateManager.reset()
1026+
fp8_recipe = recipe.DelayedScaling(fp8_format=recipe.Format.HYBRID)
1027+
layer = Linear(16, 16, bias=False, params_dtype=torch.float32).cuda()
1028+
inp = torch.randn(16, 16, device="cuda", dtype=torch.bfloat16)
1029+
observed = []
1030+
1031+
def body(value):
1032+
with autocast(enabled=True, recipe=fp8_recipe):
1033+
observed.append(
1034+
(
1035+
is_fp8_activation_recompute_enabled(),
1036+
in_fp8_activation_recompute_phase(),
1037+
)
1038+
)
1039+
return layer(value)
1040+
1041+
with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
1042+
for _ in range(3):
1043+
out = te_checkpoint(body, inp)
1044+
assert torch.isfinite(out).all()
1045+
1046+
recompute_buffer = FP8GlobalStateManager.quantization_state.fp8_tensors_recompute_buffer
1047+
assert _FP8_RECOMPUTE_KEY not in layer.fp8_meta
1048+
assert all(len(stashed) == 0 for stashed in recompute_buffer)
1049+
assert observed == [(False, False)] * 3
1050+
1051+
1052+
@pytest.mark.skipif(not fp8_available, reason=reason_for_no_fp8)
1053+
def test_reentrant_checkpoint_without_grad_input_does_not_stash_fp8_metadata():
1054+
"""A reentrant checkpoint that cannot receive backward must not save recompute state."""
1055+
FP8GlobalStateManager.reset()
1056+
fp8_recipe = recipe.DelayedScaling(fp8_format=recipe.Format.HYBRID)
1057+
layer = Linear(16, 16, bias=False, params_dtype=torch.float32).cuda()
1058+
observed = []
1059+
1060+
def body(value):
1061+
with autocast(enabled=True, recipe=fp8_recipe):
1062+
observed.append(
1063+
(
1064+
is_fp8_activation_recompute_enabled(),
1065+
in_fp8_activation_recompute_phase(),
1066+
)
1067+
)
1068+
return layer(value)
1069+
1070+
inp = torch.randn(16, 16, device="cuda", dtype=torch.bfloat16)
1071+
with torch.enable_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
1072+
for _ in range(3):
1073+
out = te_checkpoint(body, inp, use_reentrant=True)
1074+
assert torch.isfinite(out).all()
1075+
assert not out.requires_grad
1076+
1077+
assert _FP8_RECOMPUTE_KEY not in layer.fp8_meta
1078+
assert not FP8GlobalStateManager.quantization_state.fp8_tensors_recompute_buffer
1079+
assert observed == [(False, False)] * 3
1080+
1081+
valid_inp = torch.randn(
1082+
16,
1083+
16,
1084+
device="cuda",
1085+
dtype=torch.bfloat16,
1086+
requires_grad=True,
1087+
)
1088+
with torch.autocast("cuda", dtype=torch.bfloat16):
1089+
loss = te_checkpoint(body, valid_inp, use_reentrant=True).float().sum()
1090+
loss.backward()
1091+
torch.cuda.synchronize()
1092+
1093+
assert valid_inp.grad is not None and torch.isfinite(valid_inp.grad).all()
1094+
assert layer.weight.grad is not None and torch.isfinite(layer.weight.grad).all()
1095+
assert observed[-2:] == [(True, False), (True, True)]
1096+
_assert_fp8_recompute_state_drained(layer)
1097+
1098+
1099+
@pytest.mark.skipif(not fp8_available, reason=reason_for_no_fp8)
1100+
def test_ineligible_reentrant_checkpoint_preserves_outer_recompute_context():
1101+
"""An inner checkpoint without grad inputs must inherit its outer recompute phase."""
1102+
FP8GlobalStateManager.reset()
1103+
fp8_recipe = recipe.DelayedScaling(fp8_format=recipe.Format.HYBRID)
1104+
inner = Linear(16, 16, bias=False, params_dtype=torch.float32).cuda()
1105+
tail = Linear(16, 16, bias=False, params_dtype=torch.float32).cuda()
1106+
observed = []
1107+
1108+
def inner_body(value):
1109+
observed.append(
1110+
(
1111+
is_fp8_activation_recompute_enabled(),
1112+
in_fp8_activation_recompute_phase(),
1113+
)
1114+
)
1115+
return inner(value)
1116+
1117+
def outer_body(value):
1118+
with autocast(enabled=True, recipe=fp8_recipe):
1119+
inner_out = te_checkpoint(inner_body, value.detach(), use_reentrant=True)
1120+
return tail(value + inner_out)
1121+
1122+
inp = torch.randn(16, 16, device="cuda", dtype=torch.bfloat16, requires_grad=True)
1123+
with torch.autocast("cuda", dtype=torch.bfloat16):
1124+
loss = te_checkpoint(outer_body, inp, use_reentrant=False).float().sum()
1125+
loss.backward()
1126+
torch.cuda.synchronize()
1127+
1128+
assert observed == [(True, False), (True, True)]
1129+
assert inp.grad is not None and torch.isfinite(inp.grad).all()
1130+
assert inner.weight.grad is None
1131+
assert tail.weight.grad is not None and torch.isfinite(tail.weight.grad).all()
1132+
_assert_fp8_recompute_state_drained(inner, tail)
1133+
1134+
1135+
@pytest.mark.skipif(not fp8_available, reason=reason_for_no_fp8)
1136+
def test_checkpoint_without_autograd_preserves_forward_context():
1137+
"""The direct path must preserve a context that explicitly enables gradients."""
1138+
FP8GlobalStateManager.reset()
1139+
fp8_recipe = recipe.DelayedScaling(fp8_format=recipe.Format.HYBRID)
1140+
layer = Linear(16, 16, bias=False, params_dtype=torch.float32).cuda().eval()
1141+
inp = torch.randn(16, 16, device="cuda", dtype=torch.bfloat16, requires_grad=True)
1142+
1143+
def body(value):
1144+
with autocast(enabled=True, recipe=fp8_recipe):
1145+
return layer(value)
1146+
1147+
with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
1148+
out = te_checkpoint(
1149+
body,
1150+
inp,
1151+
context_fn=lambda: (torch.enable_grad(), nullcontext()),
1152+
)
1153+
1154+
assert out.requires_grad
1155+
out.float().sum().backward()
1156+
torch.cuda.synchronize()
1157+
assert inp.grad is not None and torch.isfinite(inp.grad).all()
1158+
assert layer.weight.grad is not None and torch.isfinite(layer.weight.grad).all()
1159+
recompute_buffer = FP8GlobalStateManager.quantization_state.fp8_tensors_recompute_buffer
1160+
assert _FP8_RECOMPUTE_KEY not in layer.fp8_meta
1161+
assert all(len(stashed) == 0 for stashed in recompute_buffer)
1162+
1163+
9801164
def _test_e2e_checkpointing_get_model(config, dtype):
9811165
sigma = 0.023
9821166
init_method = init_method_normal(sigma)

‎transformer_engine/pytorch/distributed.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,9 +372,14 @@ def forward(
372372
# Preserve torch autocast context for the backward pass
373373
torch_gpu_amp_ctx, torch_cpu_amp_ctx = _get_active_autocast_contexts()
374374

375-
with torch.no_grad(), forward_ctx:
376-
with activation_recompute_forward(activation_recompute=True, recompute_phase=False):
377-
outputs = run_function(*args, **kwargs)
375+
# An ineligible nested checkpoint must inherit an enclosing recompute phase.
376+
activation_recompute_ctx = (
377+
activation_recompute_forward(activation_recompute=True, recompute_phase=False)
378+
if any(ctx.needs_input_grad)
379+
else nullcontext()
380+
)
381+
with torch.no_grad(), forward_ctx, activation_recompute_ctx:
382+
outputs = run_function(*args, **kwargs)
378383

379384
# Divide hidden states across model parallel group and only keep
380385
# the chunk corresponding to the current rank.
@@ -740,6 +745,13 @@ def checkpoint(
740745
**kwargs,
741746
)
742747

748+
# When checkpoint is entered with autograd disabled, run the forward directly
749+
# to avoid unreachable FP8 recompute state. Preserve the forward context.
750+
if not torch.is_grad_enabled():
751+
forward_ctx, _ = context_fn()
752+
with forward_ctx:
753+
return function(*args, **kwargs)
754+
743755
from .module.base import TransformerEngineBaseModule
744756

745757
if isinstance(function, TransformerEngineBaseModule):

‎transformer_engine/pytorch/module/base.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1632,7 +1632,7 @@ def prepare_forward(
16321632
FP8GlobalStateManager.add_fp8_tensors_to_global_buffer(self.fp8_meta)
16331633

16341634
# Activation recomputation is used and this is the first forward phase.
1635-
if self.training and is_fp8_activation_recompute_enabled():
1635+
if is_fp8_activation_recompute_enabled():
16361636
FP8GlobalStateManager.copy_forward_fp8_meta_tensors_for_recompute(self.fp8_meta)
16371637

16381638
nvtx_range_push(self.__class__.__name__ + " forward")

0 commit comments

Comments
 (0)