Skip to content

Add SM80/SM120 block-sparse forward attention support - #2389

Open
blake-snc wants to merge 1 commit into
Dao-AILab:mainfrom
blake-snc:feat/sm120-block-sparsity
Open

blake-snc wants to merge 1 commit into
Dao-AILab:mainfrom
blake-snc:feat/sm120-block-sparsity

Conversation

@blake-snc

@blake-snc blake-snc commented Mar 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Implements block-sparse forward attention in the SM80 base-class kernel (flash_fwd.py), which is shared by SM80 and SM120
  • Two block types: mask_blocks (partially masked, apply mask_mod per element) and full_blocks (fully unmasked, skip masking entirely)
  • SM120 (FlashAttentionForwardSm120) inherits the SM80 base class and gets block sparsity for free; removes the assert not use_block_sparsity guard in interface.py
  • Follows the same mma_one_n_block callback pattern as SM90/SM100

Design

mma_one_n_block callback pattern (mirrors SM90)

A new mma_one_n_block_bs method handles one KV block: load K, load V, GEMM QK, optional score_mod, mask, online softmax, GEMM PV. This mirrors SM90's mma_one_n_block.

A new run_block_sparse_mainloop_sm80 utility in block_sparse_utils.py takes the callback and iterates mask blocks (applying mask_mod) then full blocks (seqlen masking only). This mirrors the non-intra-wg-overlap path of consume_block_sparse_loads.

SM80 can't use the exact TMA producer/consumer split (no warpgroup specialization), but the mma_one_n_block callback abstraction is the same.

First full block seqlen masking

The first full block always receives mask_seqlen=True in run_block_sparse_mainloop_sm80, since full blocks may sit at a higher n position than any mask block (and thus need seqlen-boundary masking).

SM120 arch fix

FlashAttentionForwardSm120.__init__ now forces self.arch = Arch.sm_80. Without this, FlashAttentionForwardBase.__init__ sets self.arch from the real GPU arch (sm_121a on DGX Spark), which causes the SM80 epilogue to incorrectly attempt TMA-O — crashing since tma_atom_O=None in this kernel variant.

Dense mainloop unchanged

The original dense mainloop is guarded by if const_expr(blocksparse_tensors is None): and is completely unmodified.

Validation

Validated on SM121a (DGX Spark GB10):

  • test_block_sparsity.py: 4621 passed, 40 skipped (causal, sliding window, block diagonal; bf16/fp16; D=64/128/256; various seqlens)

Contributed by Second Nature Computing (https://joinsecondnature.com)

batch_idx=batch_size,
seqlen_q_static=mQ.shape[0],
seqlen_k_static=mK.shape[0],
mCuSeqlensQ=mCuSeqlensQ,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you rebase on main? Varlen was already merged in #2333, as you know.

Comment thread flash_attn/cute/flash_fwd.py Outdated
# Unpack sparse block lists for this (batch, head, m_block) tile.
# mask_blocks = partially-masked KV blocks (need mask applied).
# full_blocks = fully-unmasked KV blocks (no masking needed).
_bs_mask_cnt, _bs_mask_idx, _bs_full_cnt, _bs_full_idx = blocksparse_tensors

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please keep variable names consistent with the other kernels. Is there a reason not to use the same design pattern for handling blocksparsity as in the others, e.g.

kv_producer_state = produce_block_sparse_loads(
?

Comment thread flash_attn/cute/flash_fwd.py Outdated
# Indices stored in decreasing order: highest n_block first.
# First mask block: is_first=True, mask_seqlen=True, mask_mod=self.mask_mod.
# Remaining mask blocks: is_first=False, mask_seqlen=False, mask_mod=self.mask_mod.
# CuTe DSL forbids closures in dynamic CF, so block processing is inlined at

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary comment on CuTe DSL

Comment thread flash_attn/cute/flash_fwd.py Outdated
# === Process full blocks (no masking) ===
# When no mask blocks preceded: first full block is the first block overall
# (is_first=True) and gets mask_seqlen=True for seqlen_k boundary check.
# When mask blocks preceded: all full blocks have is_first=False.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First full block must always get seqlen masking, since it is possible that all mask blocks are further left than some full block.

@blake-snc
blake-snc force-pushed the feat/sm120-block-sparsity branch from 6935828 to a904872 Compare March 25, 2026 22:42
@blake-snc

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Here's a summary of what changed in the v2 rewrite:

1. Rebased on main ✅

2. Design pattern matching SM90 ✅ The block-sparse mainloop now follows the same mma_one_n_block callback pattern as SM90/SM100:

  • New mma_one_n_block_bs method handles one KV block (load K, load V, GEMM QK, score_mod, mask, softmax, GEMM PV) — mirrors SM90's mma_one_n_block
  • New run_block_sparse_mainloop_sm80 utility in block_sparse_utils.py takes a mma_one_n_block callable and iterates mask blocks then full blocks — mirrors SM90's consume_block_sparse_loads (non-intra-wg-overlap path)

SM80 can't use the exact TMA producer/consumer split since it has no warpgroup specialization, but the mma_one_n_block callback abstraction is the same.

3. CuTe DSL comment removed ✅ (was in the old version)

4. First full block seqlen masking ✅ In run_block_sparse_mainloop_sm80, the first full block always gets mask_seqlen=True regardless of whether mask blocks were processed first.

Also fixed two bugs found during validation on SM121a:

  • blocksparse_tensors was missing from the kernel() method signature
  • FlashAttentionForwardSm120.__init__ now forces self.arch = Arch.sm_80 to prevent the SM80 epilogue from incorrectly using TMA-O (which crashes on SM121a since tma_atom_O=None in this variant)

Validated: test_block_sparsity.py — 4621 passed, 40 skipped on SM121a.

@johnnynunez

Copy link
Copy Markdown
Contributor

ping @drisspg @tridao

@blake-snc
blake-snc force-pushed the feat/sm120-block-sparsity branch from 377f1bd to 4dd1d4d Compare April 16, 2026 18:02
thad0ctor added a commit to thad0ctor/flash-attention that referenced this pull request May 25, 2026
… + tile tuning

Makes the three cherry-picked upstream SM120 PRs (Dao-AILab#2553, Dao-AILab#2349, Dao-AILab#2389) actually
usable end-to-end on consumer Blackwell (RTX 5090, RTX PRO 6000 Blackwell). The
upstream PRs alone leave SM120 forward dispatcher-buggy and backward broken;
this commit adds the integration glue, backward support, real paged-KV +
pack_gqa implementations, a subprocess-isolated per-shape tile lookup, and the
test coverage to back it up.

# Dispatcher fixes (SM120 forward + backward couldn't compile or run end-to-end
# without these)

- Initialize dQ_single_wg in the SM120 backward setup (was unbound)
- Keep softmax_scale non-None for SM80/SM120 backward dK epilogue (inline log2
  computation like SM90 does)
- atomic_add_fp32: adopt the new keyword-only nvvm.atomicrmw signature in
  nvidia-cutlass-dsl >= 4.x
- Drop unsupported is_split_kv kwarg on the SM120 forward path
- Pass split_idx=0, num_splits=1, seqlen_info=seqlen at the SM80/SM120
  get_total_block_count call site (arity mismatch fix)
- Rename vec_size -> score_vec_size on the SM120 TMA forward (typo in upstream
  Dao-AILab#2349; was AttributeError on softcap/learnable_sink/score_mod)
- Auto-downgrade pack_gqa=False when qhead_per_kvhead doesn't divide
  tile_m=128 (qwen2.5-7b's 7-way GQA otherwise fails cute.local_tile division)
- Auto-downgrade pack_gqa=False when paged-KV is used (cross-feature
  interaction with PagedKVManager's K/V indexing)
- Route head_dim > head_dim_v to the non-TMA SM120 path: bisection showed the
  hang lives in FlashAttentionForwardSm120Tma, not in the SM80-base mainloop
  as upstream diagnosed. Non-TMA can_implement accepts d > dv; the TMA path
  still rejects it so the dispatcher falls through. d > dv shapes now work
  (verified bitwise-identical to SDPA on the minimum repro).
- Route SM120 through the shared _validate_head_dims helper (invalid head_dim
  was reaching the kernel and faulting with cudaErrorMisalignedAddress)
- Clamp the cu_seqlens[batch_idx+1] read in SeqlenInfoQK.create so SM80/SM120
  over-launched varlen tiles don't fault on a non-resident page
- arch-gate FlashAttentionForwardBase.epilogue smem store atom: SM80/SM120
  force the universal copy, SM90 keeps WGMMA-paired stmatrix (upstream PR
  Dao-AILab#2553's bc67a9c unconditionally forced 80, which silently switched SM90
  forward through the universal-copy path)
- Include sm120_num_stages in the forward compile cache key (different ns
  values with the same tile would otherwise share a key and the second call
  would reuse the first-compiled kernel)
- Document why deterministic backward can't be lifted on SM120 (the SM80
  base kernel itself lacks the dQ_semaphore code path; a feature gap shared
  with SM80)

# SM120-specific kernel work

- Real paged-KV forward via PagedKVManager on the SM80-base kernel,
  supported through head_dim <= 128. A paged-specific tile override
  (128, 128, ns=1) gates on page_table is not None and head_dim <= 128 so
  PagedKVManager's tile_n >= num_threads invariant holds. SMEM math fits:
  48 KB at d=64, 72 KB at d=96, 96 KB at d=128 (cap 99 KB).
- Real pack_gqa=True support: rewrite PackGQA.compute_ptr to compute the
  flat offset arithmetically from stride[0][0] and stride[0][1] rather than
  cute.crd2idx (which cuTeDSL 4.4-4.5 collapses through trailing slices).
  Call pack_gqa_layout in the SM80-base forward so packed Q is actually
  materialized (was missing — would have produced wrong output even after
  the crd2idx workaround).
- Backward postprocess dQ smem-store atom: force universal copy on SM80/SM120
  (same class of bug as the upstream Dao-AILab#2553 forward fix but in the dQ
  postprocess; left silent rmem->smem scrambling otherwise). Permanent
  regression test with a white-box source-inspection guard against
  reintroduction.
- New D > 128 SM120 tile bracket (64, 64, ns=1) that fits the 99 KB SMEM cap
  for head_dim=256.

# Forward tile selection (per-shape lookup)

The SM120 forward dispatch now consults a tile + num_stages lookup keyed on
(head_dim, qhead_per_kvhead, seqlen, causal). Shapes outside the lookup fall
back to the head_dim-only brackets that match the pre-tuning defaults.

The lookup was built from a subprocess-isolated sweep: each (cell, candidate)
pair is measured in a fresh python process so JIT-cache pollution can't bias
the rankings (a single-process sweep silently reuses compiled kernels across
candidates with subtly different shapes). The top-3 candidates per cell get a
reproducibility re-measurement; variance > 10% excludes a candidate. A
candidate ships only when its mean TFLOPS beats the baseline tile by >= 2%;
otherwise the cell falls back to baseline.

# Test coverage added

- tests/cute/test_paged_kv_sm120.py (38 cases): paged-KV correctness across
  page_size {16, 64, 256}, identity / permuted / shared page tables, GQA + MQA,
  d in {64, 96, 128}; expected NotImplementedError for d in {192, 256};
  expected correctness (now, not rejection) for the paged + d > dv + varlen
  cross-feature combination.
- tests/cute/test_flash_attn_bwd_sm120_postprocess.py (10 cases): backward dQ
  postprocess regression suite, combines numeric vs fp32-SDPA comparison with
  a white-box source-inspection guard against the buggy literal pattern.
- tests/cute/test_flash_attn_sm120_dgtdv.py (11 cases): regression test for
  the Bug E d > dv non-TMA routing. 8 kernel-launch parametrizations plus
  3 unit probes (TMA rejection, non-TMA acceptance, SMEM constraint). All
  kernel tests carry pytest-timeout(30) with --timeout-method=signal so a
  future TMA gate widening that re-introduces the GPU hang fails as a timeout
  instead of wedging the GPU.

# What this is NOT

- Real paged-KV at head_dim > 128: rejected at dispatch with a clear
  NotImplementedError. Lifting would require either a refactor of
  PagedKVManager (per-thread page-table fragment > 0 at tile_n < num_threads)
  or a separate kernel; the 99 KB SMEM cap precludes the simple (128, 128, ns=1)
  approach used for d <= 128.
- Real fix for the TMA path d > dv hang: the kernel-level root cause needs
  cuda-gdb or instrumented bisection; the routing fix makes user-visible
  shapes correct today, but the TMA kernel itself is still latent-broken for
  d > dv. The can_implement gate ensures the TMA path is never selected for
  d > dv.
- Deterministic backward on SM120: asserts off because the SM80 base kernel
  itself lacks the dQ_semaphore code path. Lift would need a feature port
  from SM90 into the SM80 base; out of scope here.
FlashAttentionForwardSm80 (which SM120 inherits) accepted blocksparse_tensors
but the mainloop was dense-only; block-sparse forward existed on SM90/SM100
only. Add it to the non-warp-specialized cp.async path.

- block_sparse_utils.run_block_sparse_mainloop_sm80: visits the active
  mask/full blocks (mask blocks first with mask_mod + seqlen masking, then
  full blocks with seqlen masking only). The first full block always gets
  seqlen masking even after mask blocks, since a full block may sit at the
  seqlen_kv boundary regardless of mask-block positions. Mirrors the masking
  contract of consume_block_sparse_loads (SM90/SM100).
- FlashAttentionForwardSm80.mma_one_n_block_bs: per-block load+compute. Unlike
  the dense compute_one_n_block it does not prefetch the next block, since
  sparse blocks are not contiguous (no producer warp on this pipeline to run
  the warp-specialized produce/consume helpers).
- Guard the dense contiguous prologue prefetch behind use_block_sparsity; the
  block-sparse path drains async copies and makes Q available instead.
- Wire blocksparse_tensors from __call__ through kernel() (was dropped).
@blake-snc
blake-snc force-pushed the feat/sm120-block-sparsity branch from 4dd1d4d to 78957f5 Compare June 15, 2026 05:52
@blake-snc

Copy link
Copy Markdown
Contributor Author

Thanks for the review @reubenconducts, and apologies for the long gap. Rebased onto current main and reworked the implementation against where the block-sparse code has landed since. Summary of how each point was addressed:

Rebased on main (varlen #2333, etc.) — done; the PR is now a single forward-only addition on top of current main.

Design pattern / consistency with flash_fwd_sm90.py — this needed a caveat I should flag explicitly. flash_fwd_sm90.py (and sm100) drive block-sparse through produce_block_sparse_loads / consume_block_sparse_loads, which are warp-specialized (producer/consumer warp-groups + the mbarrier handshake described in the block_sparse_utils SM100 note). The Sm80/Sm120 forward is the Ampere cp.async pipeline — there's no producer warp to run those helpers, so they don't port 1:1. What is shared is the block-list model, so the implementation:

  • reuses the same get_curr_blocksparse_tensors accessor and the mask/full block split;
  • adds run_block_sparse_mainloop_sm80 in block_sparse_utils.py that mirrors the masking contract of consume_block_sparse_loads exactly (mask blocks first with mask_mod + seqlen masking, then full blocks with seqlen masking only);
  • uses a per-block mma_one_n_block_bs (load K/V → QK → mask → softmax → PV) instead of the dense compute_one_n_block, since sparse blocks aren't contiguous so the dense path's cross-block prefetch doesn't apply.

Naming now matches the other kernels (use_block_sparsity, curr_mask_block_idx, etc.). Open to restructuring further if you'd prefer a different split.

First full block must always get seqlen masking — fixed, and it matches consume_block_sparse_loads: the first full block gets mask_seqlen=True whether or not mask blocks preceded it (a full block can sit at the seqlen_kv boundary regardless of mask-block positions); it only gets is_first_n_block=True when there were no mask blocks.

Unnecessary CuTe DSL comment — gone in the reworked version.

Validation — on DGX Spark (GB10, sm_121a), ~493 cases from tests/cute/test_block_sparsity.py pass (fixed-length block_diagonal/mini_causal across tiles/batch/heads/non-square shapes, larger seqlens, edge cases, and the parameterized sliding-window masks). The dense path is untouched by this change (block-sparse runs as a separate branch behind use_block_sparsity). Happy to add CI coverage notes or run anything else on hardware.

@blake-snc

Copy link
Copy Markdown
Contributor Author

Status update: our SM120 hardware is currently allocated to other work, so I am unable to re-validate this for the time being. The branch currently applies cleanly against main, and the gap it fills is still present (block sparsity is asserted off for SM 12.0 on current main). Glad to see it adopted by anyone with SM120-class hardware; for design questions I am also reachable at github.com/bledden.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants