[Bugfix][Kernel] Fix NaN from the lazy rescale on a wide score range - #1033
[Bugfix][Kernel] Fix NaN from the lazy rescale on a wide score range#1033JohnQinAMD wants to merge 4 commits into
Conversation
The dense bf16 DUALWAVE_SWP forward returns NaN in its default configuration
once the logit spread widens. At B=2 S=8192 H=32 D=128 with K scaled 32x,
9.2% of output elements are NaN; at 64x, all of them are. Setting
dualwave_swp_lazy_rescale=False is finite on the same inputs, and so is aiter,
which stays accurate as the range widens. This is not new -- a 7/31 checkout
reproduces it identically.
The rescale branch is wave-uniform. `below` is a per-lane comparison, but the
ballot turns it into one decision for the whole wave, so a single lane past
DUALWAVE_SWP_RESCALE_THRESHOLD sends every lane into _lazy_rescale_o_rescale --
including lanes whose tile max sits *below* their running max. That body then
took the tile max unconditionally:
corr = exp2(m_row - m_tile_max)
...
out.append(_anchor_scalar_f32(m_tile_max))
For a lane that did not trigger the branch, m_tile_max < m_row, so corr is
exp2 of a positive number. It scales v_o and l_row up, and with a wide spread
that is large enough to reach inf; LSE confirms it, coming back inf on 51,757
of the 52,218 NaN rows while good rows sit in [80.8, 217.8]. The final divide
then yields NaN. The eager rescale_o never had this problem because it takes
`m_new = maxnumf(m_row, m_tile_max)` first.
Take the max here too. For a lane that did trigger the branch this is
m_tile_max and nothing changes; for a lane that was dragged along, corr becomes
exp2(0) = 1 and its accumulators are left alone.
rel L2 against fp32, B=2 S=8192 H=32 D=128 non-causal, MI355X, cache cold:
K scale before after eager (reference)
8 5.468e-03 5.404e-03 5.298e-03
16 7.291e-03 7.209e-03 7.159e-03
24 NaN, 0.2% 8.667e-03 8.637e-03
32 NaN, 9.2% 9.923e-03 9.903e-03
48 NaN, 91.1% 1.207e-02 1.205e-02
64 NaN, 100% 1.391e-02 1.390e-02
The lazy and eager paths are mathematically the same and now agree to within
0.2%. Note the two rows that never produced NaN also improve: those lanes were
being scaled wrongly all along, just not far enough to overflow.
Throughput is unchanged -- 1252.0/1077.3/1053.9 TFLOP/s before against
1251.0/1076.1/1053.7 after at S=61,380/180,180/239,580, two rounds each. The
extra maxnumf sits on the rescale branch only.
The test that was missing: every existing case uses near-uniform attention,
where the running max barely moves and this branch is never reached. The new
one widens the spread and pins the lazy path to the eager one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes NaNs in the gfx950-only dense bf16 DUALWAVE_SWP flash-attention forward path when using the lazy online rescale under wide logit ranges by aligning the lazy rescale math with the eager rescale’s max-tracking behavior. Adds a targeted regression test that widens the score range via K scaling and asserts finiteness + accuracy parity versus the eager path.
Changes:
- Update the lazy rescale branch to use
m_new = max(m_row, m_tile_max)when computing the correction factor and when anchoring the updated max. - Add a gfx950-gated test that reproduces the wide-score scenario (via K scaling) and checks lazy rescale produces no NaNs and is not worse than the eager path.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
kernels/attention/flash_attn_utils.py |
Fix lazy rescale correction/anchoring to prevent overflow → NaN under wave-uniform rescale branching. |
tests/kernels/test_flash_attn_fwd.py |
Add gfx950 regression test covering wide-score lazy rescale stability and accuracy relative to eager. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| assert torch.isfinite(lazy).all(), ( | ||
| f"lazy rescale produced {int(torch.isnan(lazy).sum())} NaN of {lazy.numel()} at k_scale={k_scale}" | ||
| ) |
The assertion fails on any non-finite value, so a message that counts only NaN would mislead if an inf ever got through.
|
Good catch — the assertion covers any non-finite value, so the message now reports NaN and inf separately, and the eager baseline is asserted finite before it is used as the reference. Pushed. |
The fp8 helper has the same wave-uniform branch as the bf16 one, and the same
two lanes-dragged-along cases, but it cannot take the max: rebasing to a lower
running max is what lets it keep P large against the base, and pinning the base
costs accuracy at ranges that work today -- mean |o-1| goes 0.0001 to 0.0088 at
k*1000 with V=ones.
What overflows is not the rebase but its lack of a bound. corr = exp2(-m_diff_scaled)
grows without limit as the score range widens, so cap the drop instead: rebase
while the correction stays under 2**16, and leave the lane alone past that.
The threshold is separate from DUALWAVE_SWP_RESCALE_THRESHOLD, which decides
when to rescale rather than how far down. Measured at S=12288, V=ones, mean
|o-1| and NaN count:
k scale before bound 8 bound 16 bound 32
1000 0.0001, 0 NaN 0.0021 0.0001 0.0001
5000 0.0000, 20.0M NaN 0.0025 0.0019 0.0001, 887k NaN
20000 nan, 25.2M NaN -- 0.0006 0.0006, 2.4k NaN
16 keeps every working range bit-for-bit where it is and takes the NaN to zero;
32 starts leaking again. Verified both on main and on top of #1020.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
The dense bf16
DUALWAVE_SWPforward returns NaN in its default configuration once the logit spread widens. At B=2 S=8192 H=32 D=128 with K scaled 32x, 9.2% of the output is NaN; at 64x, all of it.dualwave_swp_lazy_rescale=Falseis finite on the same inputs, and so is aiter. Not a regression — a 7/31 checkout reproduces it identically.Repro
Root cause
The rescale branch is wave-uniform.
belowis per-lane, but the ballot collapses it into one decision for the whole wave:So one lane past
DUALWAVE_SWP_RESCALE_THRESHOLDsends every lane into_lazy_rescale_o_rescale, including lanes whose tile max sits below their running max. That body took the tile max unconditionally:For a lane that did not trigger the branch,
m_tile_max < m_row, socorr = exp2(positive)and it scalesv_oandl_rowup. With a wide spread that reaches inf, and the finalv_o / l_rowis inf/inf = NaN. The eagerrescale_onever had this problem — it takesm_new = maxnumf(m_row, m_tile_max)first.Evidence: LSE comes back inf on 51,757 of the 52,218 NaN rows (438 NaN, 23 finite), while good rows sit in [80.8, 217.8]. The row statistics break first; the accumulator is downstream of them.
The fix
Take the max, exactly as the eager path does. For a lane that did trigger the branch this is
m_tile_maxand nothing changes; for a lane dragged along,corrbecomesexp2(0) = 1and its accumulators are left alone.Result
rel L2 against fp32, MI355X, cache cold (
FLYDSL_RUNTIME_ENABLE_CACHE=0):The two paths are mathematically the same and now agree to within 0.2%. The two rows that never produced NaN also improve — those lanes were being scaled wrongly all along, just not far enough to overflow.
Throughput is unchanged. 1252.0 / 1077.3 / 1053.9 TFLOP/s before against 1251.0 / 1076.1 / 1053.7 after, at S=61,380 / 180,180 / 239,580, two rounds each. The extra
maxnumfsits on the rescale branch only.The fp8 helper, second commit
DualwaveFp8SoftmaxHelperhas the same wave-uniform branch and produces NaN too, though only at a much wider range — its threshold compares(m_tile_max - m_row) * logit_scale, so the same 8 is worth ~91 raw log2 units. At S=12288 withV=ones: 20.0M NaN elements atk*5000, all 25.2M atk*20000.It cannot take the max. Rebasing to a lower running max is what keeps P large against the base, and pinning it costs accuracy at ranges that work today — mean |o-1| goes 0.0001 → 0.0088 at
k*1000. What overflows is the missing bound, not the rebase, so cap the drop at2**16instead:16 leaves every working range bit-for-bit where it is and takes the NaN to zero; 32 starts leaking again. Verified on
mainand on top of #1020, whose own fp8 tests pass unchanged (11 passed) and whose threshold table is unaffected —k*200measures 0.0021 either way.Testing
test_lazy_rescale_survives_a_wide_score_rangeover K scales 8/32/64, failing onmainat 32 and 64. Every existing case uses near-uniform attention, where the running max barely moves and this branch is never reached.@_requires_gfx950.Breaking Changes
None. Output changes only where it was NaN, or where a lane was being scaled by a factor it should never have received.