[PyTorch] LayerNormLinear drops gradients from gathered LayerNorm output under TP/SP
维护者通常 2 天内回复
@ksivaman 已经在做这个了。
开始于 2026年10月26日。
评估
这个 Issue 还没有评估数据。
描述
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
- 主要语言
- Python
- 星标
- 3.6k
- 派生
- 883
- 平均合并
- 5 天 37 分钟
- 30 天内合并 PR
- 72
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
NVIDIA/TransformerEngine 的其他 Issue
-
[PyTorch] fp8_cs_quantize fake implementation returns a vector inverse scale instead of a scalar可能重新可做 @sanjana658 于 21 天前认领,目前没有进行中的 PR。 未关闭
难度 2/5 1-3 小时 新手友好度 82/100
NVIDIA/TransformerEngine#3636 · 2 条评论 ·
维护者通常 2 天内回复
-
[Bug] Backend selection picks FA3 for training with head_dim_qk=192 / v_head_dim=128, but FA3 backward cannot run it可能重新可做 @modelpath-dev 于 55 天前认领,目前没有进行中的 PR。 未关闭attention
难度 2/5 1-3 小时 新手友好度 85/100
NVIDIA/TransformerEngine#3481 · 4 条评论 ·
维护者通常 2 天内回复
-
bug
难度 2/5 1-3 小时 新手友好度 68/100
NVIDIA/TransformerEngine#2189 · 7 条评论 · 5 个 reaction ·
维护者通常 2 天内回复
-
难度 4/5 3-5 天 新手友好度 55/100
NVIDIA/TransformerEngine#3765 ·
维护者通常 2 天内回复
-
难度 4/5 3-5 天 新手友好度 48/100
NVIDIA/TransformerEngine#3751 · 1 条评论 ·
维护者通常 2 天内回复
查看 NVIDIA/TransformerEngine 的全部 Issue
相似的 Issue
-
难度 2/5 1-3 小时 新手友好度 72/100
ComposioHQ/awesome-claude-skills#2398 ·
维护者通常 2 天内回复
-
bug converter
难度 2/5 1-3 小时 新手友好度 62/100
grafana/sigma-rule-deployment#435 ·
维护者通常 1 天内回复
-
bug
难度 2/5 1-3 小时 新手友好度 73/100
datamodel-code-generator/datamodel-code-generator#5589 ·
维护者通常 1 天内回复
-
Shell: serve the home app's Cookie Policy on a hard reload可能已有人在做 @2001anshkaushik 今天认领。 未关闭feature module:ai
难度 2/5 1-3 小时 新手友好度 88/100
rocketride-org/rocketride-server#2899 · 1 条评论 ·
维护者通常 2 天内回复
-
GEFSv12
难度 2/5 1-3 小时 新手友好度 70/100