fix(attention): stop handing FlashAttention 4 the -1 window sentinel - #3532
nvegesna-netizen wants to merge 4 commits into
Conversation
FA4 returns an all-zero output for causal attention on SM100, silently. No
exception, no warning, finite values, every element zero. Measured against a
float64 reference on B200, with no context parallelism involved:
flash_attn_func(q, k, v, causal=True, window_size=(-1, 0))
out.abs().max() = 0.0 relerr = 1.000e+00
flash_attn_func(q, k, v, causal=True, window_size=(None, None))
out.abs().max() = 3.453125 relerr = 2.046e-03
TE normalizes an unbounded window side to -1; FA4 spells it None. Until
flash-attention NVIDIA#2490 a shim widened any pair summing below zero to full
attention, so TE's (-1, 0) was always erased and causal= alone set the mask.
NVIDIA#2490 narrowed that to pairs where both bounds are negative, so (-1, 0) now
survives as a literal band of [row + 1, row] -- empty. The kernel returns zeros
and an all -inf LSE.
Normalizing negative bounds to None at the four FA4 entry points, rather than at
the ~20 sites that build these kwargs: those sites are shared with FA2 >= 2.7 and
FA3, where -1 is the correct spelling, so fixing them in place would mean forking
every one on use_flash_attn_4.
All four entry points, not just the two used by context parallelism. The non-CP
path calls flash_attn_func/flash_attn_varlen_func; CP calls
_flash_attn_fwd/_flash_attn_bwd. Covering one pair is worse than covering
neither, because run_attention_with_cp.py grades a CP run against a non-CP run of
the same backend: with both sides zero the comparison agrees, and correcting one
side alone makes the fix present as a regression. That is not hypothetical --
it is what an earlier partial attempt did.
Three reasons CI could not see this, all still worth addressing separately:
NVTE_FLASH_ATTN_V4=0 in the distributed suite, a flash-attn-4 pin predating
NVIDIA#2490, and the self-referential comparison above, which cannot detect any error
affecting CP and non-CP equally.
The new test anchors to a float64 reference rather than to another backend, and
pins FlashAttention -- cuDNN FusedAttention wins selection for the shape under
test and would mask the defect entirely.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It lives in dot_product_attention.py, not utils.py, so the GPU arm of the new test failed at import on B200 while the eleven parametrized cases passed. The behaviour it checks was separately confirmed in the same run -- a non-CP forward through DotProductAttention returned 3.453125e+00 against 0.000000e+00 before the fix, relerr 2.046e-03 -- so this is the test reaching the assertion, not a change in what is being asserted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
qa/L0_pytorch_unittest exports NVTE_FLASH_ATTN_V4=0, and the skip condition checked flash_attn_func_v4 is None -- an import binding that env var does not affect. So on an SM100 L0 job the test would not skip: it would run, TE would decline FA4, fall through to FA2 (the same lane sets NVTE_FLASH_ATTN_V2=1), and pass while measuring a backend the test is not written for. A green test that exercises the wrong backend is the failure mode this fix exists to close, so relying on the lane to have FA4 enabled is the wrong dependency. The test now pins NVTE_FLASH_ATTN_V4=1 alongside the fused and unfused disables, and asserts the selected backend reports version 4 before grading any numbers. That makes it correct in any lane and stops a future lane quietly opting it out. Reported by greptile-apps on NVIDIA#3532, which flagged that the L0 runner disables FA4; the fall-through to FA2 is the part that makes it a false green rather than a skip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It read as the only backend serving symmetric head_dim in (256, 512] with context parallelism. That was true when written and is now imprecise: Dao-AILab/flash-attention#2877 adds symmetric D512 kernels to FA4, and with the window-sentinel fix in NVIDIA#3532 that path works too. Qualified to released components, which is the claim that actually holds -- NVIDIA#2877 is unmerged and unreviewed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
IMO, it's unnecessary to add the test here, could we add a versioned gate on the calls to "normalize windows" method? (something like this but I'm sure that I've got the minimum version incorrect)
There was a problem hiding this comment.
I checked whether a gate is needed, and I do not think it is.
I transcribed _resolve_causal_local_window from before #2490 (interface.py @ 59cf537812) and after it, verified both transcriptions against the real source, and ran every (causal, window) pair TE emits through each. TE derives causal from attn_mask_type and the window from check_set_window_size, so the two always agree and causal=False with (-1, 0) never arises.
| mask type | pre as-is | pre normalized | post as-is | post normalized |
|---|---|---|---|---|
| causal | CAUSAL | CAUSAL | LOCAL left=-1 right=0 | CAUSAL |
| causal_bottom_right | CAUSAL | CAUSAL | LOCAL left=-1 right=0 | CAUSAL |
| no_mask | FULL | FULL | FULL | FULL |
| sliding window | LOCAL 511/0 | LOCAL 511/0 | LOCAL 511/0 | LOCAL 511/0 |
Normalizing is inert on the older release, not merely harmless. The old code reaches the same resolved state from (-1, 0) by widening that the new code reaches from (None, 0) directly, via the same window_size_left is None and window_size_right == 0 branch, which is unchanged context in #2490's diff. All four entry points funnel through that one resolver on both releases.
So ungated looks safer rather than just simpler: a threshold has to be right, and any release between #2490 merging on 9 Sep and the gated version would stay broken. Reasoning is now in the docstring in 4a84a21e.
On the test, I would keep it. Eleven of the twelve cases are pure Python with no GPU, so the CI cost is small. What earns their place is that this bug was invisible three separate ways: NVTE_FLASH_ATTN_V4=0 in the distributed suite, a flash-attn-4 pin predating #2490, and the CP suite comparing a CP run against a non-CP run of the same backend, which agrees when both sides return zeros. The end to end case pins NVTE_FLASH_ATTN_V4=1 and asserts the selected backend reports version 4 before grading numbers, so it cannot pass on FA2 the way the original would have.
Review asked whether the -1 to None translation should sit behind a version gate keyed on the FA4 release that changed the sentinel. It does not need one, and the reason is worth recording where the next reader will look. Transcribing flash-attention's _resolve_causal_local_window from before NVIDIA#2490 (interface.py @ 59cf537812) and after it, and running every (causal, window) pair TE emits through both: mask type b11 as-is b11 normalized post-2490 as-is causal CAUSAL CAUSAL LOCAL left=-1 right=0 causal_bottom_right CAUSAL CAUSAL LOCAL left=-1 right=0 no_mask FULL FULL FULL sliding window LOCAL 511/0 LOCAL 511/0 LOCAL 511/0 Normalizing changes nothing on the older release, because the old code reaches the same resolved state from (-1, 0) by widening that the new code reaches from (None, 0) directly. So the translation is inert before the change and corrective after it, with no version boundary to get wrong. All four entry points funnel through that one resolver, on both releases, so the trace covers them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem
FlashAttention 4 returns an all-zero output for causal attention on SM100, silently — no
exception, no warning, finite values, every element zero. Fixes #3528.
No context parallelism is needed to reproduce it:
Run it with
NVTE_FUSED_ATTN=0 NVTE_UNFUSED_ATTN=0, or cuDNN FusedAttention wins backendselection for this shape and hides the defect entirely.
Cause
TE normalises an unbounded window side to
-1(check_set_window_size); FA4's sentinel isNone.Until Dao-AILab/flash-attention#2490 a shim widened any pair summing below zero to full attention,
so TE's causal encoding
(-1, 0)was always erased andcausal=Truealone set the mask. #2490narrowed that to pairs where both bounds are negative, so
(-1, 0)now survives as a literalband of
[row + 1, row]— empty.flash_attn/cute/mask.pyguards onis not Noneand uses thevalue arithmetically, so the kernel returns zeros and an all
-infLSE.Measured directly, with TransformerEngine out of the picture:
Change
Normalise negative window bounds to
Noneat the FA4 entry points, where the symbols are bound.Why there rather than at the call sites. The ~20 sites that build these kwargs are shared with
FA2 >= 2.7 and FA3, for which
-1is the correct spelling; rewriting them in place would meanforking every one on
use_flash_attn_4.Why all four entry points. The non-CP path calls
flash_attn_func/flash_attn_varlen_func;context parallelism calls
_flash_attn_fwd/_flash_attn_bwd. Covering only one pair is worsethan covering neither, because
run_attention_with_cp.pygrades a CP run against a non-CP run ofthe same backend: with both sides returning zeros the comparison agrees and passes, so correcting
one side alone makes the fix present as a regression. That is not hypothetical — an earlier partial
attempt did exactly that, turning two passing arms red.
Genuine sliding windows already carry non-negative bounds and pass through untouched.
Verification (B200, SM100)
DotProductAttention3.453125e+00, was0.000000e+00; relerr vs float642.046e-03(256,0),(128,0),(0,0)2.046e-03,2.046e-03,0.000e+00p2pbshd/sbhd/thdp2pfailed before this changeall_gather,a2abshd/sbhdtest_fa4_window_sentinel.pytest_attention.pysweep (regression)Numerics are graded against a float64 reference, never against another backend. That matters
here: the defect survived because the CP suite compares against a run of the same backend, so an
error present on both sides cancels. float64 rather than float32 because torch computes fp32
matmuls in TF32 on Ampere and newer, whose significand is 11 bits — the same as fp16 — so an fp32
reference cannot judge a bf16 kernel.
Tests
tests/pytorch/attention/test_fa4_window_sentinel.py, registered inqa/L0_pytorch_unittest:sliding window such as
(511, 0)is passed through unchangedanchored to a float64 reference. It asserts a non-zero output separately from the tolerance
check, so the specific failure is named rather than reported as a large error.
Note on CI
Three independent reasons this was invisible, each worth addressing on its own and none of them
fixed here:
NVTE_FLASH_ATTN_V4=0is exported inqa/L1_pytorch_distributed_unittest/test.shand the L0suites, so FA4 is never exercised under context parallelism.
flash-attn-4==4.0.0b11, which predates cute: don't widen intentionally-empty offset windows to full attention Dao-AILab/flash-attention#2490.run_attention_with_cp.pygrades a CP run against a non-CP run of the same backend, so itcannot detect any error affecting both equally. Two of the three comm types reported PASS on a
backend that was returning zeros.