Skip to content

[Bug] FusedAttention (cuDNN) THD backward produces wrong gradients on sm_120 (RTX 5090); forward is correct, Unfused is correct #3333

Description

@qshf

Description

On an RTX 5090 (sm_120), DotProductAttention with qkv_format="thd" and the cuDNN FusedAttention backend returns incorrect gradients in the backward pass, while the forward output is correct. Switching the same call to the UnfusedDotProductAttention backend (NVTE_FUSED_ATTN=0 NVTE_FLASH_ATTN=0) yields correct gradients with identical inputs.

The error is not related to context parallelism, packing, number of sequences, sequence length being a multiple of 128, or the cu_seqlens_padded arguments — a single sequence (no packing) already reproduces it. This appears distinct from #2186 (which is THD + CP tail-padding specific).

Environment

Component Version
GPU NVIDIA GeForce RTX 5090 (sm_120)
Driver 595.84
TransformerEngine 2.17.0
PyTorch 2.11.0+cu130
CUDA 13.0.1
cuDNN 9.25.0.15 (libcudnn9-cuda-13)
Base image lmsysorg/sglang:latest + system cuDNN 9.25

Reproduction

Minimal, no Megatron dependency. Compares TE's backward gradient against a plain per-sequence SDPA reference (math backend, fp32), using identical q/k/v (fixed seed). We report the cosine similarity of the query gradient and its norm.

import os
import torch
import transformer_engine.pytorch as te

device = "cuda"
H, D = 8, 128

def mk(total):
    torch.manual_seed(42)
    return [torch.randn(total, H, D, device=device, dtype=torch.bfloat16, requires_grad=True) for _ in range(3)]

def ref(q, k, v, lengths):
    outs = []; s = 0
    for l in lengths:
        qi = q[s:s+l].transpose(0, 1); ki = k[s:s+l].transpose(0, 1); vi = v[s:s+l].transpose(0, 1)
        o = torch.nn.functional.scaled_dot_product_attention(qi.float(), ki.float(), vi.float(), is_causal=True)
        outs.append(o.transpose(0, 1)); s += l
    return torch.cat(outs, 0)

def run(lengths):
    total = sum(lengths); offs = [0]
    for l in lengths: offs.append(offs[-1] + l)
    cu = torch.tensor(offs, device=device, dtype=torch.int32)
    qr, kr, vr = mk(total); ref(qr, kr, vr, lengths).float().sum().backward(); gref = qr.grad.float()
    q, k, v = mk(total)
    attn = te.DotProductAttention(num_attention_heads=H, kv_channels=D, attention_dropout=0.0,
        qkv_format="thd", attn_mask_type="padding_causal").to(device).train()
    o = attn(q, k, v, qkv_format="thd", cu_seqlens_q=cu, cu_seqlens_kv=cu,
             max_seqlen_q=max(lengths), max_seqlen_kv=max(lengths)).view(total, H, D)
    o_ref = ref(qr.detach().requires_grad_(), kr.detach().requires_grad_(), vr.detach().requires_grad_(), lengths)
    out_cos = torch.nn.functional.cosine_similarity(o.detach().flatten()[None].float(), o_ref.flatten()[None].float()).item()
    o.float().sum().backward(); g = q.grad.float()
    gcos = torch.nn.functional.cosine_similarity(g.flatten()[None], gref.flatten()[None]).item()
    tag = "OK " if gcos > 0.99 else "BAD"
    print(f"  {tag} lengths={str(lengths):10s} out_cos={out_cos:.6f}  q_grad_cos={gcos:.4f}  q_grad_norm={g.norm():.3e} (ref {gref.norm():.2f})")

for L in [[128], [128, 96], [128, 128], [256, 256]]:
    run(L)

Results

cuDNN FusedAttention (NVTE_FLASH_ATTN=0, default fused on) — forward correct, backward wrong:

[INFO | DotProductAttention]: Running with FusedAttention backend (sub-backend 1)
  BAD lengths=[128]      out_cos=0.999998  q_grad_cos=0.4446  q_grad_norm=2.864e+02 (ref 74.97)
  BAD lengths=[128, 96]  out_cos=0.999998  q_grad_cos=0.0265  q_grad_norm=2.068e+10 (ref 101.93)
  BAD lengths=[128, 128] out_cos=0.999998  q_grad_cos=0.4275  q_grad_norm=4.348e+02 (ref 104.96)
  BAD lengths=[256, 256] out_cos=0.999998  q_grad_cos=0.4303  q_grad_norm=4.262e+02 (ref 126.25)

UnfusedDotProductAttention (NVTE_FUSED_ATTN=0 NVTE_FLASH_ATTN=0) — same inputs, backward correct:

[INFO | DotProductAttention]: Running with UnfusedDotProductAttention backend
  OK  lengths=[128]      out_cos=0.999995  q_grad_cos=1.0000  q_grad_norm=7.494e+01 (ref 74.97)
  OK  lengths=[128, 96]  out_cos=0.999995  q_grad_cos=1.0000  q_grad_norm=1.019e+02 (ref 101.93)
  OK  lengths=[128, 128] out_cos=0.999995  q_grad_cos=1.0000  q_grad_norm=1.049e+02 (ref 104.96)
  OK  lengths=[256, 256] out_cos=0.999994  q_grad_cos=1.0000  q_grad_norm=1.262e+02 (ref 126.25)

The only variable changed between the two runs is the attention backend. Forward out_cos ≈ 1.0 in both cases; only the FusedAttention backward is wrong (gradient direction wrong, q_grad_cos 0.03–0.44, and for [128,96] the norm blows up to 2e10).

What we ruled out (each with a controlled experiment)

  • Reference correctness — TE's own UnfusedDotProductAttention agrees with our reference (q_grad_cos=1.0), so the reference is right.
  • Packing / multi-sequence / cu_seqlens boundaries — a single sequence [128] (no packing) already fails.
  • Sequence length not a multiple of 128 (a known cuDNN varlen caveat) — [128,128] and [256,256] are all-128-multiples and still fail.
  • Missing cu_seqlens_q_padded / cu_seqlens_kv_padded — explicitly passing them (equal to cu_seqlens, since there is no padding) does not change the result.
  • Context parallelism — not used here (single GPU, no CP), unlike Fused attention with THD format + CP may cause a bug in backward pass, leading to NaN values during training LLM. #2186.

Workaround

NVTE_FUSED_ATTN=0 NVTE_FLASH_ATTN=0 forces UnfusedDotProductAttention, which produces correct gradients (at O(N²) cost).

Possibly related

Notes

  • We could not cross-check on sm_80 (A100) as that instance has been released; happy to re-run additional configs on the RTX 5090 if useful (different head_dim, dtype fp16, attn_mask_type variants, etc.).

Metadata

Metadata

Assignees

No one assigned

    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