Skip to content

[PyTorch] LayerNormLinear drops gradients from gathered LayerNorm output under TP/SP #3741

Open

Description

@gautham-kollu

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

Activity

  1. self-assigned this
    on Oct 26, 2026
  2. 0z5a commented on Oct 27, 2026

    @0z5a
    Contributor

    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:

    • Two GH200 nodes over InfiniBand, torchrun --nnodes 2 --nproc-per-node 1, FP32.
    • Two L40S cards in one node over PCIe, without peer access, in FP32 and BF16.

    In FP32, on both setups, the gathered rows show dx relative errors of 7.79e-01, 1.14e-01 and 1.16e-02 for W scale 0.1, 0.01 and 0.001. dy, da and db are 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.backward has since been refactored into _layernorm_linear_backward_impl(args), and all three hunks fail. I ported the same logic below, following what LayerNormMLP already does for fc1:

    • When the output was really gathered (column parallel + SP + tp_size > 1), the LN gradient is added to dgrad before the dgrad reduce-scatter.
    • In that case, Userbuffers comm+GEMM overlap is turned off for the backward, and dgrad is not quantized before the reduce-scatter (grad_input_quantizer=None), because the LN gradient has to be added to dgrad first.
    • In every other case, the gradient is added locally as before.
    Ported diff against main (applies to fac8d4a9 and current main)
    --- a/transformer_engine/pytorch/module/layernorm_linear.py
    +++ b/transformer_engine/pytorch/module/layernorm_linear.py
    @@ -954,6 +954,28 @@
             )
             nvtx_range_pop(f"{nvtx_label}.fsdp_gather")
     
    +        # A gathered LayerNorm output has a full-sequence gradient, like dgrad, so it
    +        # must be added before dgrad is reduce-scattered. Comm+GEMM overlap reduces
    +        # inside the GEMM, before that sum exists, so use explicit communication.
    +        grad_ln_out = (
    +            args.grad_ln_out
    +            if args.return_layernorm_output or args.return_layernorm_output_gathered
    +            else None
    +        )
    +        grad_ln_out_needs_reduce_scatter = (
    +            grad_ln_out is not None
    +            and args.return_layernorm_output_gathered
    +            and args.parallel_mode == "column"
    +            and args.sequence_parallel
    +            and args.tp_size > 1
    +        )
    +        if grad_ln_out_needs_reduce_scatter:
    +            args.ub_overlap_ag = False
    +            args.ub_overlap_rs_dgrad = False
    +            args.ub_bulk_dgrad = False
    +            args.ub_bulk_wgrad = False
    +            args.grad_input_quantizer = None
    +
             # Configure Userbuffers communication (comm+GEMM overlap)
             args.ub_obj_gradout = None
             ub_obj_dgrad = None
    @@ -1168,6 +1190,8 @@
                 nvtx_range_push(f"{nvtx_label}.column_parallel_comm_dgrad")
                 dgrad = gemm_out
                 if args.sequence_parallel:
    +                if grad_ln_out_needs_reduce_scatter:
    +                    dgrad = dgrad + grad_ln_out.reshape_as(dgrad)
                     dgrad, dgrad_work = reduce_scatter_along_first_dim(
                         dgrad,
                         args.tp_group,
    @@ -1403,9 +1427,10 @@
                 dgrad_work.wait()
                 dgrad_work = None
     
    -        # Residual gradient
    -        if args.return_layernorm_output and not args.return_layernorm_output_gathered:
    -            dgrad = dgrad + args.grad_ln_out
    +        # Residual gradient. A returned output that was not all-gathered (including the
    +        # gathered flag when no gather ran) has a local gradient.
    +        if grad_ln_out is not None and not grad_ln_out_needs_reduce_scatter:
    +            dgrad = dgrad + grad_ln_out
     
             # Norm gradient
             dgamma = None

    With the port, every row of your repro is OK:

    Setup gathered dx rel. err, W scale 0.1 / 0.01 / 0.001 / 0
    2× GH200, cross-node, FP32 2.59e-07 / 3.13e-07 / 3.36e-07 / 3.18e-07
    2× L40S, FP32 2.26e-07 / 2.37e-07 / 2.40e-07 / 2.16e-07
    2× L40S, BF16 1.26e-02 / 1.23e-02 / 1.26e-02 / 6.29e-03

    The 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 gives dx errors 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. My FP8 variant of the script was broken: it ran the rank-0-only reference inside fp8_autocast, which waits on the amax all-reduce. So I have no FP8 numbers.
    • The Userbuffers overlap paths.

    Would you like to open the PR with this version? If you'd rather, I can open it and credit you.

  3. 0z5a commented on Oct 28, 2026

    @0z5a
    Contributor

    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:

    Configuration main fac8d4a9 main + port
    BF16 7.77e-01 / 7.77e-01 / 1.16e-01 / 1.42e-02 1.26e-02 / 1.26e-02 / 1.23e-02 / 1.26e-02
    FP8 (DelayedScaling) 7.85e-01 / 7.85e-01 / 1.14e-01 / 1.54e-02 8.42e-03 / 8.42e-03 / 6.10e-03 / 6.16e-03
    Userbuffers, AG + bulk dgrad/wgrad 7.77e-01 / 7.77e-01 / 1.13e-01 / 1.26e-02 1.26e-02 / 1.26e-02 / 1.23e-02 / 1.26e-02
    Userbuffers, AG + RS dgrad 7.77e-01 / 7.77e-01 / 1.16e-01 / 1.42e-02 1.26e-02 / 1.26e-02 / 1.23e-02 / 1.26e-02
    FP8 + Userbuffers, AG + RS dgrad 7.85e-01 / 7.85e-01 / 1.14e-01 / 1.54e-02 8.42e-03 / 8.42e-03 / 6.10e-03 / 6.16e-03

    dy, da and db are 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 LayerNormLinear with ub_name="qkv" and ub_overlap_ag=True, plus one of two dgrad settings:

    • ub_bulk_dgrad/ub_bulk_wgrad
    • ub_overlap_rs_dgrad, with qkv_dgrad set to ring_exchange

    I also called initialize_ub on 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 pipeline and external Userbuffers 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions