Repository navigation
[PyTorch] LayerNormLinear drops gradients from gathered LayerNorm output under TP/SP #3741
Description
In Transformer Engine, LayerNormLinear with return_layernorm_output=True, return_layernorm_output_gathered=True, column TP=2 and sequence parallelism drops the gradient from a consumer of its returned LayerNorm output. Backward only adds grad_outputs[1] when the gathered flag is false, so the input gradient misses that branch's contribution.
The repro below forks Megatron-Bridge #6453 using only te.LayerNormLinear and te.Linear, with no Megatron or LoRA dependencies:
base, ln = fused_layernorm_linear(x)
# For gathered LN, select this rank's sequence slice before fc1's SP all-gather.
y = base + fc1(ln)It compares TP=2 + SP against the same unsharded graph using identical weights, random inputs and random upstream gradients. Expected: dx, da and db match the baseline for both local and gathered LN outputs. The affected gathered path loses the fc1 contribution to dx; the two linear weight gradients remain correct. dy checks the supplied upstream gradient. Comparisons use max(abs(got-ref)) / max(abs(ref)).
torchrun --standalone --nproc_per_node=2 repro_te_layernorm_linear_sp.pyThe patch below adds the returned-LN gradient before reduce-scatter and preserves local/no-gather behavior. It selects the explicit communication path when overlap would otherwise reduce before the addition. Patch application, Python compilation and CPU dispatch/finite-difference checks pass; these checks do not cover CUDA/NCCL execution.
Repro
# Fork of the repro in https://github.com/NVIDIA-NeMo/Megatron-Bridge/issues/6453.
import math
import os
# Set before importing torch/TE so TE's cuBLAS calls also avoid TF32.
os.environ["NVIDIA_TF32_OVERRIDE"] = "0"
import torch
import torch.distributed as dist
import transformer_engine
import transformer_engine.pytorch as te
S, BATCH, H, O = 64, 2, 256, 512
TP = 2
EPS = 1e-5
TOL = 1e-3 # Same threshold used to flag the input-gradient error in the issue.
COLUMNS = ("dx", "dy", "da", "db")
def all_gather(tensor, dim):
parts = [torch.empty_like(tensor) for _ in range(TP)]
dist.all_gather(parts, tensor.contiguous())
return torch.cat(parts, dim=dim)
def rel(got, ref):
return ((got - ref).abs().max() / ref.abs().max()).item()
class TwoProjections(torch.nn.Module):
def __init__(
self, *, tensor_parallel, sequence_parallel, return_layernorm_output_gathered, device
):
super().__init__()
self.sequence_parallel = sequence_parallel
self.return_layernorm_output_gathered = return_layernorm_output_gathered
common = dict(
tp_group=dist.group.WORLD if tensor_parallel else None,
tp_size=TP if tensor_parallel else 1,
parallel_mode="column" if tensor_parallel else None,
sequence_parallel=sequence_parallel,
params_dtype=torch.float32,
fuse_wgrad_accumulation=False,
bias=False,
device=device,
)
self.fused_layernorm_linear = te.LayerNormLinear(
H,
O,
eps=EPS,
normalization="LayerNorm",
return_layernorm_output=True,
return_layernorm_output_gathered=return_layernorm_output_gathered,
**common,
)
self.fc1 = te.Linear(H, O, **common)
def forward(self, x):
base, ln = self.fused_layernorm_linear(x)
if self.sequence_parallel and self.return_layernorm_output_gathered:
# [S,B,H] -> [S/TP,B,H]. Linear(SP=True) will gather this once.
ln = ln.chunk(TP, dim=0)[dist.get_rank()].contiguous()
return base + self.fc1(ln)
@torch.no_grad()
def initialize_and_copy(reference, model, tensor_parallel, branch_scale, device):
"""Initialize full tensors once, then copy exact TP slices into the test."""
rng = torch.Generator(device=device).manual_seed(7)
rank = dist.get_rank()
target = dict(model.named_parameters())
for name, param in reference.named_parameters():
if name.endswith("layer_norm_weight"):
param.normal_(mean=1.0, std=0.1, generator=rng)
elif name == "fc1.weight":
# Vary only the second projection's magnitude, like the issue.
param.normal_(mean=0.0, std=1.0, generator=rng).mul_(branch_scale)
else:
param.normal_(mean=0.0, std=0.1, generator=rng)
dist.broadcast(param, src=0)
if tensor_parallel and name in ("fused_layernorm_linear.weight", "fc1.weight"):
# Column parallelism partitions weight [O,H] along O.
source = param.chunk(TP, dim=0)[rank]
else:
# LayerNorm gamma and beta are replicated across TP ranks.
source = param
target[name].copy_(source)
def check(tensor_parallel, sequence_parallel, return_layernorm_output_gathered, branch_scale, device):
rank = dist.get_rank()
# Always return local LN in the TP=1 reference. This avoids the gathered
# flag's backward path in TE versions affected by the reported issue.
reference = TwoProjections(
tensor_parallel=False,
sequence_parallel=False,
return_layernorm_output_gathered=False,
device=device,
)
model = TwoProjections(
tensor_parallel=tensor_parallel,
sequence_parallel=sequence_parallel,
return_layernorm_output_gathered=return_layernorm_output_gathered,
device=device,
)
initialize_and_copy(reference, model, tensor_parallel, branch_scale, device)
rng = torch.Generator(device=device).manual_seed(42)
x_full = torch.randn(S, BATCH, H, device=device, generator=rng)
gout_full = torch.randn(S, BATCH, O, device=device, generator=rng)
dist.broadcast(x_full, src=0)
dist.broadcast(gout_full, src=0)
# SP shards the sequence; column TP shards output features.
x_local = x_full.chunk(TP, dim=0)[rank] if sequence_parallel else x_full
gout = gout_full.chunk(TP, dim=-1)[rank].contiguous() if tensor_parallel else gout_full
x = x_local.clone().requires_grad_(True)
y = model(x)
y.retain_grad()
y.backward(gout)
# All ranks participate in collectives before rank 0 compares results.
# a = fused_layernorm_linear.weight; b = fc1.weight.
# dy is the supplied upstream gradient, so it checks output-gradient slicing.
dx = all_gather(x.grad, dim=0) if sequence_parallel else x.grad
dy = all_gather(y.grad, dim=-1) if tensor_parallel else y.grad
da = model.fused_layernorm_linear.weight.grad
db = model.fc1.weight.grad
if tensor_parallel:
da = all_gather(da, dim=0)
db = all_gather(db, dim=0)
if rank == 0:
xr = x_full.clone().requires_grad_(True)
yr = reference(xr)
yr.retain_grad()
yr.backward(gout_full)
errors = {
"dx": rel(dx, xr.grad),
"dy": rel(dy, yr.grad),
"da": rel(da, reference.fused_layernorm_linear.weight.grad),
"db": rel(db, reference.fc1.weight.grad),
}
bad = [k for k in COLUMNS if not math.isfinite(errors[k]) or errors[k] > TOL]
status = " <-- mismatch: " + ", ".join(bad) if bad else " OK"
print(
f"{TP if tensor_parallel else 1:>2} {str(sequence_parallel):>5} "
f"{str(return_layernorm_output_gathered):>32} {branch_scale:>8g} | "
+ " ".join(f"{errors[k]:10.2e}" for k in COLUMNS)
+ status,
flush=True,
)
return errors
def main():
if int(os.environ.get("WORLD_SIZE", "1")) != TP:
raise SystemExit("Run with torchrun --standalone --nproc_per_node=2 <script.py>")
if not torch.cuda.is_available():
raise SystemExit("This reproduction requires two CUDA GPUs and CUDA-enabled TE.")
local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)
device = torch.device("cuda", local_rank)
torch.backends.cuda.matmul.allow_tf32 = False
torch.backends.cudnn.allow_tf32 = False
dist.init_process_group("nccl")
try:
rank = dist.get_rank()
if rank == 0:
print(
f"PyTorch {torch.__version__}; TE {transformer_engine.__version__}; "
f"GPU {torch.cuda.get_device_name(device)}",
flush=True,
)
print("Baseline: TP=1, SP=False; FP32")
print("Relative max error = max(abs(got-ref)) / max(abs(ref))")
print(
f"{'TP':>2} {'SP':>5} {'return_layernorm_output_gathered':>32} {'W scale':>8} | "
+ " ".join(f"{name:>10}" for name in COLUMNS),
flush=True,
)
for tensor_parallel, sequence_parallel, return_layernorm_output_gathered, branch_scale in [
(False, False, False, 0.1), # Unsharded control.
(True, True, False, 0.1), # TP + SP, local returned LN.
(True, True, True, 0.1), # TP + SP, gathered returned LN.
(True, True, True, 0.01),
(True, True, True, 0.001),
(True, True, True, 0.0), # No input-gradient contribution from fc1.
]:
check(
tensor_parallel, sequence_parallel, return_layernorm_output_gathered,
branch_scale, device,
)
finally:
dist.destroy_process_group()
if __name__ == "__main__":
main()Patch
diff --git a/transformer_engine/pytorch/module/layernorm_linear.py b/transformer_engine/pytorch/module/layernorm_linear.py
--- a/transformer_engine/pytorch/module/layernorm_linear.py
+++ b/transformer_engine/pytorch/module/layernorm_linear.py
@@ -685,6 +685,24 @@
)
nvtx_range_pop(f"{nvtx_label}.fsdp_gather")
+ grad_ln_out = grad_outputs[1] if ctx.return_layernorm_output else None
+ ln_grad_needs_reduce_scatter = (
+ grad_ln_out is not None
+ and ctx.return_layernorm_output_gathered
+ and ctx.parallel_mode == "column"
+ and ctx.sequence_parallel
+ and ctx.tp_size > 1
+ )
+ if ln_grad_needs_reduce_scatter:
+ # Combine both full-sequence gradients before reducing them. The
+ # overlap kernels can reduce dgrad inside GEMM, before this sum
+ # can be formed, so use the explicit communication path here.
+ ctx.ub_overlap_ag = False
+ ctx.ub_overlap_rs_dgrad = False
+ ctx.ub_bulk_dgrad = False
+ ctx.ub_bulk_wgrad = False
+ ctx.grad_input_quantizer = None
+
# Configure Userbuffers communication (comm+GEMM overlap)
ctx.ub_obj_gradout = None
ub_obj_dgrad = None
@@ -901,6 +919,8 @@
nvtx_range_push(f"{nvtx_label}.column_parallel_comm_dgrad")
dgrad = gemm_out
if ctx.sequence_parallel:
+ if ln_grad_needs_reduce_scatter:
+ dgrad = dgrad + grad_ln_out.reshape_as(dgrad)
dgrad, dgrad_work = reduce_scatter_along_first_dim(
dgrad,
ctx.tp_group,
@@ -1131,8 +1151,10 @@
# Residual gradient
dgrad = dgrad.view(inputmat.shape)
- if ctx.return_layernorm_output and not ctx.return_layernorm_output_gathered:
- dgrad = dgrad + grad_outputs[1].view_as(dgrad)
+ if grad_ln_out is not None and not ln_grad_needs_reduce_scatter:
+ # A local returned output already has a local gradient. This
+ # also covers the gathered flag when no gather actually ran.
+ dgrad = dgrad + grad_ln_out.reshape_as(dgrad)
# Norm gradient
dgamma = None
Activity
0z5a commentedon Oct 27, 2026
Thanks for the clear repro. Here is some GPU data, plus a port of your patch to current main.
Repro on GPUs (main
fac8d4a9). Your script reproduces the issue on two setups:torchrun --nnodes 2 --nproc-per-node 1, FP32.In FP32, on both setups, the gathered rows show
dxrelative errors of 7.79e-01, 1.14e-01 and 1.16e-02 for W scale 0.1, 0.01 and 0.001.dy,daanddbare exact, and the unsharded control and the local-LN row are OK. In BF16 the gathered rows give 7.77e-01 and 1.16e-01. At W scale 0.001 the error (1.42e-02) falls under the script's BF16 tolerance of 0.02, so that row prints OK even though the gradient is still missing.The patch doesn't apply to main.
_LayerNormLinear.backwardhas since been refactored into_layernorm_linear_backward_impl(args), and all three hunks fail. I ported the same logic below, following whatLayerNormMLPalready does forfc1:tp_size > 1), the LN gradient is added to dgrad before the dgrad reduce-scatter.grad_input_quantizer=None), because the LN gradient has to be added to dgrad first.Ported diff against main (applies to
fac8d4a9and current main)With the port, every row of your repro is OK:
dxrel. err, W scale 0.1 / 0.01 / 0.001 / 0The BF16 values match the local-LN row (TP=2 with SP, not gathered: 1.26e-02). That row is the BF16 rounding difference between TP=2 and the TP=1 baseline.
I also added one row to the repro: TP=2 without SP, with
return_layernorm_output_gathered=True. No gather runs there, but main also drops the LN gradient in that case, because it checks only the flag. On 2× L40S that row givesdxerrors of 7.79e-01 (FP32) and 7.77e-01 (BF16) on main, and 2.59e-07 and 1.26e-02 with the port.Not covered:
fp8_autocast, which waits on the amax all-reduce. So I have no FP8 numbers.Would you like to open the PR with this version? If you'd rather, I can open it and credit you.
0z5a commentedon Oct 28, 2026
Follow-up with the cases my earlier comment did not cover: FP8 and Userbuffers. They ran on two B300 GPUs (sm_103, NVLink), TP=2, with BF16 parameters, the same script and the same port of your fix (diff in the earlier comment).
I changed one thing in the script: the TP=1 reference now runs on every rank. Under FP8 autocast the amax reductions are collectives, and running the reference on rank 0 only was what hung my earlier FP8 attempt.
Relative max error of
dx. Each cell lists four rows: TP without SP with the gathered flag (W 0.1), then TP + SP with the gathered output at W 0.1, 0.01 and 0.001:fac8d4a9DelayedScaling)dy,daanddbare 0 in every row. With the port, every gathered row matches its non-gathered control row (1.26e-02 in BF16, 8.42e-03 in FP8), and the TP + SP control row with Userbuffers still passes on main. FP8 reproduces the bug at the same size as BF16, but at W 0.01 main's FP8 error (1.14e-01) is inside the 0.15 FP8 tolerance I used. Only the W 0.1 rows exceed the tolerance there.For the Userbuffers rows, I built the fused
LayerNormLinearwithub_name="qkv"andub_overlap_ag=True, plus one of two dgrad settings:ub_bulk_dgrad/ub_bulk_wgradub_overlap_rs_dgrad, withqkv_dgradset toring_exchangeI also called
initialize_ubon the TP group. For the gathered + SP + TP case the port turns these overlaps off: they reduce inside the GEMM, before the LayerNorm-output gradient has been added.Still not covered: TP > 2, and the
pipelineandexternalUserbuffers methods.Are you planning to open the PR yourself? If not, I'm happy to open it with you credited, and I'd include a test built on this script.