DSA: fix dK SMEM handoff race and two latent NaN sources in the dense indexer - #9
Merged
risemeup1 merged 1 commit intoAug 19, 2026
Conversation
… indexer
The dense DSA warmup path (dense_indexer_score_recompute +
dense_attn_score_recompute + dense_indexer_backward) produced
non-deterministic d_index_k and had two unguarded paths that turn into
silent NaN. All three are fixed here.
1. dense_indexer_backward_sm100.py: SMEM handoff race on dK
In _reduce_warpgroup_2q all 128 lanes of the reduce warpgroup scatter
their TMEM readback into sdK_reduce, but the cp.reduce.async.bulk that
ships the tile is issued by a single thread and reads the WHOLE tile.
The only thing between them was fence_proxy("async.shared"), a proxy
fence: it orders the executing thread's own prior generic-proxy writes
against the async proxy, and neither waits for nor publishes any other
lane's stores. There is no happens-before edge between lanes 1..127's
stores and thread 0's DMA read.
A second, separate race sits in the same loop: the
cp_async_bulk_wait_group that is supposed to keep the next iteration
from overwriting the single-buffered staging tile was executed by all
128 lanes, but bulk groups are per-thread and only thread 0 ever
commits one, so for the other 127 lanes it is a no-op.
Both are fixed with a warpgroup NamedBarrier (id 5; 0/3/4 are taken by
sync_threads, compute_sync_barrier and tmem_alloc_barrier), following
the arrive_and_wait / fence_proxy / arrive_and_wait pattern the dQ
epilogue in the same file already uses.
Verified numerically. For a sum of N terms accumulated in fp32 in any
order the error is bounded by (N-1) * 2**-24 * sum|t_i|; run-to-run
differences above that bound cannot be explained by re-association of
the cross-CTA atomics. Before the fix, 711 / 756 / 90 elements of
d_index_k exceeded that bound on three of five test shapes, by up to
22.6x. After, zero elements exceed it on any shape and the residual
spread drops 4x..48x, to 0.08..0.29 of the bound -- i.e. back inside
what the cross-CTA reduction order alone explains. d_index_q and
d_weights are bit-identical before and after, as expected: they do not
go through the staging buffer. Cost: +1.9% on the backward.
2. _interface_sm100.py: denom_out was never initialised
Allocated with torch.empty and, unlike out, never filled. The tile
scheduler only visits q rows below cu_seqlens_q[-1], so in THD every
row in [cu_seqlens_q[-1], total_q) is returned as whatever the
allocator handed back. Reproduced: leaked values from the previous
call, including an exact 64.0 (a previous attn_l1norm) and fp32
denormals in the 1e-43 range. Zeroed next to out.fill_, which also
covers a caller-supplied buffer. 0 rather than -inf: consumers
compute exp(out - denom) and out is already -inf on those rows.
Note the varlen validation never asserts cu_seqlens_q[-1] == total_q;
it is a caller-side invariant that nothing checks.
3. dense_score_recompute_sm100.py: attn exp2 had no upper clamp
Neither branch bounded the exponent (the qhpkv != 64 branch only
clamped the lower end against denormals). A finite but far too small
lse -- e.g. the additive -1e30 mask constant a caller's own LSE pass
can leave on a row whose candidate set degenerated -- overflows fp32,
giving out = +inf and denom = +inf, and the consumer's score / denom is
a silent NaN. Reproduced: 37888 +inf score elements and 37888 NaN
targets on one test shape. Clamped to 120, chosen so that the head
sum stays finite (2**120 * 64 = 8.5e37 < FLT_MAX); after the fix the
same case yields no +inf and no NaN. This bounds each term, not the
column sum, so a degenerate lse can still saturate the L1 norm to
+inf -- which is benign, the consumer's target becomes 0 and the row
drops out. Cost: +1.7% on the forward.
Both forward changes are bit-identical to the previous behaviour on
legitimate inputs; the clamp never fires when lse is a real LSE.
4. indexer_backward/api.py: reject unsupported head_dim
check_support only asserted heads >= 64. Measured: head_dim 64 and
128 are correct, 96 / 100 / 112 return silently wrong d_index_k
(relative error up to 4e2; head_dim=100 also corrupts d_index_q with
NaN and 1e38 values), and 192 / 256 abort with cudaErrorInvalidValue.
The dK epilogue's bulk-reduce size assumes the global row stride equals
head_dim_padded, and the TMEM load atom only tiles the staging buffer
completely at the widths it was tuned for. Reject rather than return
bad gradients.
ForFishes
force-pushed
the
fix/dsa-dk-smem-race-v1.27.0
branch
from
August 19, 2026 12:00
2c756ab to
5d13922
Compare
risemeup1
approved these changes
Aug 19, 2026
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.
What
Four fixes in the dense DSA warmup path (
dense_indexer_score_recompute+dense_attn_score_recompute+dense_indexer_backward). Found while chasingglobal_grad_norm: nanin a DSA warmup run where the forward loss stayed finite but the indexer gradients blew up.1.
dense_indexer_backward_sm100.py— SMEM handoff race on dK (the main one)In
_reduce_warpgroup_2q, all 128 lanes of the reduce warpgroup scatter their TMEM readback intosdK_reduce, but thecp.reduce.async.bulkthat ships the tile is issued by a single thread and reads the whole tile. The only thing between them wasfence_proxy("async.shared", space="cta"), which is a proxy fence: it orders the executing thread's own prior generic-proxy SMEM writes against the async proxy, and neither waits for nor publishes any other lane's stores. There is no happens-before edge between lanes 1..127's stores and thread 0's DMA read.sdK_reduceis single-buffered and never zeroed, so a read that overtakes the stores picks up either the previous KV block's dK or uninitialised SMEM.A second, separate race sits in the same loop: the
cp_async_bulk_wait_groupthat is supposed to stop the next iteration from overwriting the staging tile was executed by all 128 lanes, but bulk groups are per-thread and only thread 0 ever commits one — for the other 127 lanes it is a no-op.Both are fixed with a warpgroup
NamedBarrier(id 5; 0/3/4 are taken bysync_threads,compute_sync_barrierandtmem_alloc_barrier), following thearrive_and_wait/fence_proxy/arrive_and_waitpattern that the dQ epilogue in the same file already uses — including the comment at the dQ site explaining why the second barrier is needed before the staging tile is reused. dQ got it right; dK was missed.2.
_interface_sm100.py—denom_outwas never initialisedAllocated with
torch.emptyand, unlikeout, never filled. The tile scheduler only visits q rows belowcu_seqlens_q[-1], so in THD every row in[cu_seqlens_q[-1], total_q)comes back as whatever the allocator handed over. Zeroed next toout.fill_, which also covers a caller-supplied buffer.0rather than-inf: consumers computeexp(out - denom)andoutis already-infon those rows, so-inf - (-inf)would be a NaN.Worth noting separately: the varlen validation never asserts
cu_seqlens_q[-1] == total_q. It is a caller-side invariant that nothing checks.3.
dense_score_recompute_sm100.py— attnexp2had no upper clampNeither branch bounded the exponent (the
qhpkv != 64branch only clamped the lower end against denormals). A finite but far too smalllse— e.g. the additive-1e30mask constant a caller's own LSE pass can leave on a row whose candidate set degenerated — overflows fp32, givingout = +infanddenom = +inf, so the consumer'sscore / denomis a silent NaN. Clamped to120, chosen so the head sum stays finite (2**120 * 64 = 8.5e37 < FLT_MAX).This bounds each term, not the column sum, so a degenerate
lsecan still saturate the L1 norm to+inf. That is benign — the consumer's target becomesfinite/inf = 0and the row drops out of the loss — but it is a mitigation, not a guarantee of a correct value.4.
indexer_backward/api.py— reject unsupportedhead_dimcheck_supportonly assertedheads >= 64. Measured:head_dim64 and 128 are correct; 96 / 100 / 112 return silently wrongd_index_k(relative error up to 4e2, andhead_dim=100also corruptsd_index_qwith NaN and 1e38 values); 192 / 256 abort withcudaErrorInvalidValue. The dK epilogue's bulk-reduce size assumes the global row stride equalshead_dim_padded, and the TMEM load atom only tiles the staging buffer completely at the widths it was tuned for. Rejecting is better than returning bad gradients.How it was verified
For a sum of N terms accumulated in fp32 in any order the error is bounded by
(N-1) * 2**-24 * sum|t_i|. The launch grid is(ceil(s_q/2), batch), so thousands of CTAs reduce into the same global dK rows andd_index_kis inherently non-deterministic even with a correct kernel — "only dK is non-deterministic" proves nothing on its own. Run-to-run differences above that bound cannot be explained by re-association, and that is the criterion used here.Five THD shapes (
[256],[512,512],[300,100,612],[1000,24],[1024,1024]withq_causal_offsets), byte-identical inputs, 8 repeats:[256][512,512][300,100,612][1000,24][1024,1024]Before the fix, up to 22.6x over the loosest possible bound. After, zero elements exceed it on any shape and the residual spread sits at 0.08..0.29 of the bound — back inside what the cross-CTA reduction order alone explains. Also holds under a concurrent GPU load used to perturb warp scheduling.
Other checks:
index_score,index_lse,attn_score,attn_l1norm,d_index_q,d_weightsare all bit-identical; onlyd_index_kchanges.d_index_q/d_weightsdo not go through the staging buffer, and stay bit-exact over 20 repeats before and after.lseis a real LSE, and the zeroing does not touch a covered row.d_index_kvs an fp32 reference goes from 2.0e-2..5.4e-2 of peak (failing) to 2.75e-3, i.e. the same level asd_index_q/d_weights, which is the bf16 dS staging floor.denom_outprobe recovers leaked values from the previous call (including an exact64.0— a previousattn_l1norm— and fp32 denormals around 1e-43); the degenerate-lseprobe goes from 37888+infscore elements and 37888 NaN targets to zero of both.s_local=8192(64 KV blocks per CTA), alternating runs of 30 iterations.barrier_id=5confirmed free: cutlass only constructsNamedBarrierintmem_allocator(which receives id 4 from this kernel) and in an unrelated gemm template;PipelineTmaStoreuses no named barrier; this kernel has no CLC scheduler.Caveats
NVIDIA B30Z, cc 10.3. The manifestation of an SMEM race depends on warp scheduling and SASS codegen, so the quantitative numbers should be discounted across architectures. That a formal data race exists is a static fact and arch-independent, and every before/after comparison above was taken on the same machine.Note on this branch (paddle/v1.27.0)
The same change was developed and verified on
paddle/v1.26.0; this is the port.Three of the four files applied with offsets only;
_interface_sm100.pywas re-derived by hand because it differs by 609/150 lines between the two branches, and the attn hunk carries an extra comment explaining why the existingdenom_out.zero_()at allocation time is not sufficient (it is gated behindprecision == "mxfp8"and a shape check, so the bf16 path never reaches it).All three defects were confirmed to still be present on this branch before patching. Stripping comments, the added executable lines are identical between the two ports (23 lines each), and the removed lines match as well — so the verification above transfers, modulo the surrounding-code differences.
Not functionally verified on this branch. The environment used for testing has
nvidia-cutlass-dsl4.4.1, andpaddle/v1.27.0needs a newer one (sparse_attention_backward/dsa_bwd_sm100.pyimportscutlass.cute.nvgpu.OperandMajorMode, which 4.4.1 does not provide; the DSA package__init__loads all symbols eagerly, so this blocks the whole namespace). What was checked here: the hunks land in the correct context, all four files compile, and the executable diff is identical to the verified v1.26.0 port. A run of the test suite on a machine with the required cutlass-dsl is still owed.