[PyTorch] LayerNormLinear drops gradients from gathered LayerNorm output under TP/SP
Les mainteneurs répondent en général sous 2 jours
@ksivaman y travaille déjà.
Depuis le 26/10/2026.
Évaluation
Cette issue n'a pas encore été évaluée.
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.py
The 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
- Langage dominant
- Python
- Étoiles
- 3.6k
- Forks
- 883
- Merge moyen
- 4 j 17 h
- PR mergées (30 j)
- 70
Préparer son environnement
- Aucun Dockerfile ni fichier Docker Compose
- Propose un modèle de pull request
- Lire le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Autres issues de NVIDIA/TransformerEngine
-
[PyTorch] fp8_cs_quantize fake implementation returns a vector inverse scale instead of a scalarPeut-être à nouveau libre @sanjana658 l’a pris il y a 23 jours, et aucune pull request n’est ouverte. Ouverte
Difficulté 2/5 1-3 heures Accessibilité débutants 82/100
NVIDIA/TransformerEngine#3636 · 2 commentaires ·
Les mainteneurs répondent en général sous 2 jours
-
[Bug] Backend selection picks FA3 for training with head_dim_qk=192 / v_head_dim=128, but FA3 backward cannot run itPeut-être à nouveau libre @modelpath-dev l’a pris il y a 57 jours, et aucune pull request n’est ouverte. Ouverteattention
Difficulté 2/5 1-3 heures Accessibilité débutants 85/100
NVIDIA/TransformerEngine#3481 · 4 commentaires ·
Les mainteneurs répondent en général sous 2 jours
-
Increase MAX_TENSOR_NUMOuvertebug
Difficulté 2/5 1-3 heures Accessibilité débutants 68/100
NVIDIA/TransformerEngine#2189 · 7 commentaires · 5 réactions ·
Les mainteneurs répondent en général sous 2 jours
-
`DotProductAttention` with `fp8_dpa=True` does not update its backward scales when no Linear-family module is in the autocast region, and dQ/dK lose most of their normPeut-être pris @sha7doww l’a pris il y a 2 jours. Ouverte
Difficulté 4/5 3-5 jours Accessibilité débutants 48/100
NVIDIA/TransformerEngine#3778 ·
Les mainteneurs répondent en général sous 2 jours
-
[Performance] FusedAdam capturable kernel is ~1.9x slower than regular Adam on GB300Peut-être pris @ksivaman l’a pris il y a 2 jours. Ouverte
NVIDIA/TransformerEngine#3777 · 1 personne assignée ·
Les mainteneurs répondent en général sous 2 jours
Toutes les issues de NVIDIA/TransformerEngine
Issues similaires
-
[Bug]: Responses API 500s on assistant message with empty content listPeut-être pris @shadowmodder l’a pris aujourd’hui. Ouvertebug
Difficulté 2/5 1-3 heures Accessibilité débutants 66/100
vllm-project/vllm#64940 ·
Les mainteneurs répondent en général sous 1 jour
-
limits
Difficulté 2/5 1-3 heures Accessibilité débutants 82/100
Les mainteneurs répondent en général sous 1 jour
-
Keybind quest outdatedOuverteStatus: Triage Type: Quest
Difficulté 2/5 1-3 heures Accessibilité débutants 66/100
GTNewHorizons/GT-New-Horizons-Modpack#27490 ·
Les mainteneurs répondent en général sous 1 jour
-
LLM.save() discards the model's active prompt stylePeut-être pris @remote-controlled-man l’a pris aujourd’hui. Ouverte
Difficulté 2/5 1-3 heures Accessibilité débutants 78/100
Lightning-AI/litgpt#2378 ·
Les mainteneurs répondent en général sous 23 jours
-
Difficulté 2/5 1-3 heures Accessibilité débutants 62/100
Les mainteneurs répondent en général sous 1 jour