[Bugfix][Kernel] Fix fp8 dense attention: softmax normalisation and an int32 dim overflow - #1020
Open
JohnQinAMD wants to merge 3 commits into
Open
[Bugfix][Kernel] Fix fp8 dense attention: softmax normalisation and an int32 dim overflow#1020JohnQinAMD wants to merge 3 commits into
JohnQinAMD wants to merge 3 commits into
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes two independent correctness issues in dense fp8 flash-attention: (1) softmax normalization error caused by fp8 underflow in the PV path, and (2) a launch-time crash from int32 shape packing overflow when flattening very large tensors. The changes are localized to fp8 dense attention utilities, the Python interface wrapper, and the gfx950 fp8 kernel path.
Changes:
- Add an optional log2-domain bias to the fused
scale*(score-row_max)helper to lift softmax probabilities before fp8 casting (avoiding tail flush-to-zero mass loss under fp8 e4m3). - Add a dense-fp8 guard to split very-large
B*S*H*Dlaunches into per-batch-entry launches to avoidstruct.packint32 shape overflow. - Honor
DUALWAVE_SWP_LAZY_RESCALEby routing through a helper that selects lazy vs eager rescale correction.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| kernels/attention/flash_attn_utils.py | Adds optional bias support in softmax pre-processing to prevent fp8 PV underflow from breaking normalization. |
| kernels/attention/flash_attn_interface.py | Adds a size-triggered per-batch split path for dense fp8 to avoid int32 ABI shape packing overflow. |
| kernels/attention/flash_attn_fp8_gfx950.py | Ensures the lazy-rescale flag is actually respected by selecting the appropriate online-rescale correction path. |
Suppressed comments (1)
kernels/attention/flash_attn_utils.py:434
- The
_scale_sub_score_pairdocstring still describes the return asscale * (v_s - row_max_raw), but the function now also supports an optionalbiasterm (log2-domain shift). Updating the docstring will prevent future callers from missing the bias behavior when reading this helper.
Returns ``scale * (v_s - row_max_raw)`` per element via a single FMA
(``fma(s, scale, -scale*row_max_raw)``), so the fp8 QK MMA can emit raw
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
JohnQinAMD
force-pushed
the
fp8-softmax-normalisation
branch
2 times, most recently
from
August 18, 2026 00:57
efff219 to
0914e81
Compare
added 2 commits
August 19, 2026 07:27
With V set to all ones the output must be exactly 1.0 everywhere, since softmax
weights sum to 1. It came back as low as 0.637, with 58-83% of rows below 1.0.
bf16 on the same inputs deviates by at most 4e-05.
P is cast to e4m3 for the PV MMA and e4m3's smallest subnormal is 2**-9. A
softmax over thousands of keys puts most of the tail below that, where it
flushes to zero -- while l_row, summed before the cast, still counts it. The
output is divided by a sum that includes weights the matmul dropped. It only
shows on peaked attention, so a random-init model does not reveal it.
Scale P up before the cast so it uses e4m3's whole range rather than its top
octave. l_row scales with it, leaving the output unchanged apart from the tail
that no longer underflows, and the shift rides the addend of an FMA that already
runs, so it is free.
How much scale is safe depends on how large exp2 can get: `lazy_correct_o` holds
the running max until a tile exceeds it by RESCALE_THRESHOLD log2 units, so
exp2 <= 2**THRESHOLD there and log2(448) - THRESHOLD is left over, while the
eager path rebases every tile and all of log2(448) is available. The scale is
derived from that and applies on both paths.
At the default threshold of 8 that is a 2x scale, worst row 0.637 -> 0.766, at
unchanged speed. Lowering the threshold buys the rest, measured at B=2 S=12288
H=8 D=128 with a wide score range:
threshold P scale worst row TFLOP/s
8 (default) 2x 0.766 1641
6 7x 0.910 1632
4 28x 0.988 1548
2 112x 0.996 1498
0 (eager) 448x 0.996 1506
There is no cheap middle ground: by the time the threshold is low enough to fix
the row error it costs what the eager path costs. The default is left alone.
Doing this surfaced a second defect. Both call sites invoked `lazy_correct_o`
unconditionally, so DUALWAVE_SWP_LAZY_RESCALE reached the traits and was never
read and setting it did nothing. They now go through a helper that honours it.
The test uses the V=ones property, so it needs no reference implementation, and
covers both rescale paths at the bound each can reach.
A dense fp8 launch raises once B*S*H*D reaches 2**31:
struct.error: 'i' format requires -2147483648 <= number <= 2147483647
At D=128, H=64 that is S >= 131072.
jit_argument._LayoutPlan packs shapes as int32 -- "i" * len(shape), with only
the strides following use_32bit_stride -- and the fp8 path flattens Q/K/V/O to
1-D, so the packed dim is the whole element count. bf16 passes the natural 4-D
shape, where no single dim is that large; the comment above that branch already
warns about this.
Batch entries are independent in attention and a leading slice of a contiguous
[B,S,H,D] tensor is itself contiguous, so one launch per entry divides the flat
dim by B at no copy and no kernel change. Guarded on numel, so everything below
the bound takes the path it takes today. Splitting costs 3.2% at S=27k, 0.9% at
S=61k and nothing by S=121k, and only runs past S=131072.
Two alternatives that do not work, so they are not worth retrying: passing the
natural 4-D shape for fp8 returns garbage at sizes that work today (rel 1.7e+05
at S=61380 against a correct 2.4e-02) because the fp8 module addresses from the
flat descriptor; and passing another flat-equivalent view such as [B, -1] clears
the error and produces silently wrong output, which is worse than the current
loud failure.
A caller-supplied `out` is returned as given. Each per-entry call writes into its
own out[i:i+1] view, so concatenating would copy several GB again at the sizes
that reach this branch and hand back a different tensor than was passed in.
Reaching the branch for real costs ~10 GB of q/k/v/out, so the bound is a named
constant and the test lowers it to cover the split on a small tensor -- against
the unsplit result, since splitting must not change the output.
JohnQinAMD
force-pushed
the
fp8-softmax-normalisation
branch
from
August 19, 2026 07:31
0914e81 to
816ebd8
Compare
5 tasks
JohnQinAMD
pushed a commit
that referenced
this pull request
Aug 20, 2026
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>
The lazy path cannot lift P far enough to keep the softmax tail out of e4m3's
flush-to-zero range, so leaving it on means shipping a known-wrong default. Take
the accuracy.
Measured at B=2 H=8 D=128, V=ones, where the exact output is 1.0:
S k scale worst row mean |o-1| TFLOP/s
1024 1 0.9844 -> 0.9844 0.0139 -> 0.0105 165.0 -> 158.4
1024 200 0.9492 -> 0.9883 0.0034 -> 0.0014 162.7 -> 160.4
12288 1 0.9805 -> 0.9805 0.0137 -> 0.0044 1733.2 -> 1632.1
12288 200 0.7656 -> 0.9922 0.0021 -> 0.0004 1732.0 -> 1611.4
The peaked case is the one that mattered: 0.766 to 0.992. Error drops 3-5x
across the board and costs 1.4-7.0%, most of it at long sequences.
The switch is per dtype rather than global: dualwave_swp_lazy_rescale becomes
Optional and resolves to False for fp8, True for bf16, so bf16 keeps the lazy
path and callers passing the flag explicitly are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two independent bugs in the dense fp8 attention forward on gfx950. One commit each; they can be taken separately.
struct.errorfor large inputs. Any dense fp8 call withB*S*H*D >= 2**31fails. AtD=128, H=64that isS >= 131072.Motivation
Bug 1: rows lose softmax mass
Set
Vto all ones. The output must then be exactly1.0everywhere, since softmax weights sum to 1. It is not:Why.
P(the softmax weights) is cast to e4m3 before the PV matmul. The smallest number e4m3 can hold is2**-9. With thousands of keys, most weights are smaller than that, so they become zero. Butl_row— the sum used to normalise — is computed before that cast, and still counts them. The output is divided by a sum that includes weights the matmul dropped.This only shows up when attention is peaked. On near-uniform attention every weight is similar and nothing is lost, so a random-init model will not reveal it.
Bug 2: int32 overflow on the shape
jit_argument._LayoutPlanpacks shapes as int32 ("i" * len(shape)); only strides honouruse_32bit_stride. The fp8 path flattens Q/K/V/O to 1-D, so the packed dim is the total element count, and it overflows. bf16 passes the natural 4-D shape, where no single dim is that large — the comment above that branch already warns about this.Changes
Bug 1 — scale
Pup before the cast, so weights use e4m3's full range instead of only its top.l_rowis scaled by the same factor, so the output is unchanged apart from the tail that no longer underflows. This is free: the scale folds into an FMA that already runs, added as an optionalbiasargument to_scale_sub_score_pair, so bf16 callers are untouched.How much scale is safe is set by how large
exp2can get: the lazy path holds the running max until a tile exceeds it byRESCALE_THRESHOLD, leavinglog2(448) - THRESHOLD; the eager path rebases every tile and gets all oflog2(448). Both paths derive their scale from that.Doing this surfaced that the fp8 kernel never read
DUALWAVE_SWP_LAZY_RESCALE— both of its call sites usedlazy_correct_ounconditionally, so setting the flag did nothing on that path. The bf16 kernel has always honoured it; the fp8 one now does too.Bug 2 — run one kernel launch per batch entry when the total would overflow. Batch entries are independent in attention, and a leading slice of a contiguous tensor is still contiguous, so this needs no copy and no kernel change. Guarded on
numel >= 2**31.fp8 now defaults to the eager rescale. The lazy path cannot lift
Pfar enough to keep the softmax tail out of e4m3's flush-to-zero range, so leaving it on ships a known-wrong default.dualwave_swp_lazy_rescalebecomesOptionaland resolves toFalsefor fp8,Truefor bf16 — bf16 is untouched and explicit callers are unaffected. Bug 2's path only runs at sizes that raise today.At B=2 H=8 D=128 with
V=ones, where the exact output is 1.0:Error drops 3-5x across the board for 1.4-7.0%, most of it at long sequences.
Performance Results (MI355X, gfx950)
Attention forward,
B=2 D=128, non-causal.fp8 over bf16 goes from 1.57–1.60x to 1.47–1.51x. The cost is the eager rescale, not the scaling.
Accuracy on six q/k/v triples captured from a real 80B model. "Floor" is the error from the e4m3 format alone — quantise, dequantise, run in fp32 — the best any fp8 kernel could do.
A CUDA fp8 attention kernel reaches 1.05x of the same floor on the same tensors, so 1.05x is the achievable target rather than a number I picked.
Choosing the threshold
The default threshold of 8 leaves a 2x scale, which helps at no cost. Lowering it buys the rest. Measured at
B=2 S=12288 H=8 D=128with a wide score range,V=onesso the exact output is 1.0:There is no cheap middle ground. By the time the threshold is low enough to fix the row error, it costs what the eager path costs. So the choice is between the free partial fix at the default and the full fix at −8%; there is nothing in between.
Testing
test_fp8_softmax_normalisesuses theV=onesproperty above, so it needs no reference implementation, and covers both rescale paths with the bound each can reach;test_fp8_out_tensor_is_filled_and_returnedcovers theoutcontract on the split pathtest_flash_attn_fwd.pypasses — full file 97 passed cold (FLYDSL_RUNTIME_ENABLE_CACHE=0, 2m49s), with the new default in placeSfrom 1k to 12kBreaking Changes
None.