Add SM80 (Ampere/A100) dense MLA decode kernel - #183
Open
bzantium wants to merge 2 commits into
Open
Conversation
Implements a CUDA kernel for the dense MLA decode path on SM80 GPUs.
Upstream currently supports SM90/SM100 only; this enables A100 deployment
without forcing migration to Hopper hardware.
Kernel design:
- BLOCK_M=16, 4 warpgroups x 1 warp x V-quarter split (HEAD_DIM_V/4 cols/wg)
- mma.m16n8k16 (BF16/FP16 with FP32 accumulator)
- cp.async with double-buffered sK + cross-block prefetch
- XOR swizzle (Swizzle<3,3,3>-style) for zero-bank-conflict SMEM access
- cp.async.cg (L1 bypass) for K loading
- SMEM 162 KB / CTA: 18 KB sQ + 2 x 72 KB sK, fits within 164 KB cap
Functional coverage:
- BF16 + FP16
- Multi-batch, multi-KV-head, causal mask
- Split-K via the existing combine kernel (no changes to combine path)
- Drop-in API compatibility: dense_decode_fwd signature unchanged
Performance (A100-SXM4-80GB, 2039 GB/s peak HBM):
- Peak: 490 GB/s on b=64 sk=4096 (24 percent of HBM peak)
- Long-seq: 276 GB/s on b=1 sk=65536
- 9-117x speedup vs PyTorch eager BMM reference across the sweep
Build:
FLASH_MLA_DISABLE_SM100=1 FLASH_MLA_DISABLE_SM90=1 pip install -v .
SM80-only is the supported configuration; SM80+SM90 combined builds need
__launch_bounds__ portability fixes in upstream sm90 sources (deferred).
Tests:
- benchmark/bench_sm80_decode.py --check (correctness vs torch eager)
- benchmark/profile_decode_step.py (DeepSeek-V3-shape step profile)
Modifications outside csrc/sm80/:
- csrc/api/{api.cpp,common.h,dense_decode.h}: SM80 arch dispatch
- csrc/smxx/decode/combine/combine.cu: __CUDA_ARCH__>=900 guard for the
PDL device intrinsic so the combine kernel compiles for sm_80
- csrc/smxx/decode/get_decoding_sched_meta/get_decoding_sched_meta.cu:
drop the sm90-only third arg from __launch_bounds__
- setup.py: SM80 build flag and source list, plus include paths from
pip-installed nvidia-* wheels (system CUDA may lack cusparse headers)
bzantium
force-pushed
the
sm80-dense-decode-port
branch
from
May 1, 2026 02:12
de2151d to
4f5ac5b
Compare
Nsight puts the kernel at 6.25% occupancy with Block Limit Shared Mem = 1 and no hardware unit above 37% of peak, so it is latency bound with a single CTA per SM and nothing to run while that CTA sits on __syncthreads(). The 162 KB comes from staging a whole 64-token KV page twice. Two CTAs per SM need about 81.5 KB each and sQ alone is 18 KB, so the K staging has to drop from 128 tokens to at most 56. A 16-token tile with three stages is 54 KB, which fits and deepens the prefetch from two to three. The KV page size stays 64, since the API checks it and the scheduler counts in pages; only the SMEM tile shrinks, so each page is now walked as four sub-tiles. Shared memory was not the only place assuming one resident CTA. The split count came straight from the SM count, so the grid still supplied 108 CTAs for 108 SMs and achieved occupancy stayed at 6.25% even after the theoretical limit doubled. It now scales with the CTAs that actually fit, derived from the kernel's own SMEM footprint rather than hard-coded. The combine kernel's split ladder stopped at 160 and asserted past it, so it gains rungs to 256. The extra partitions only pay off when there is work to fill them. Each one reloads the 18 KB Q tile, runs a prologue and epilogue, and adds a row to the combine reduction. Doubling unconditionally costs 19.7% at b=16 sk=1024, where 216 partitions share 256 KV pages. Both gates below come from the sweep, not from first principles, and are marked as such in the code. Also fixes a latent race. cp_async_wait_group<N> returns immediately when fewer than N + 1 groups are pending, so on the iterations that issue no new load the old constant bound did not wait for the tile about to be read. Measured on an idle A100-SXM4-80GB, CUDA 12.9, five alternating rounds per side, median of five with the range in brackets. Achievable copy bandwidth on the same node is 1747 GB/s. config base GB/s this GB/s delta b=64 sk=4096 490.8 [489-492] 632.9 [631-633] +29.0% b=1 hq=64 sk=4096 37.9 [37.8-37.9] 45.1 [45.1-45.2] +19.0% b=64 sk=1024 318.4 [318-319] 351.3 [350-352] +10.3% b=1 sk=16384 135.3 [134-136] 141.6 [141-142] +4.7% b=1 sk=256 4.4 4.6 +4.5% b=1 sk=1024 16.4 17.0 +3.7% b=1 sk=4096 50.9 52.4 +2.9% The remaining five configurations move between +2.4% and +5.6% but their ranges overlap across rounds, so they are reported as unchanged. Registers drop from 255, the SM80 ceiling, to 206, and the 252/348 byte register spill disappears. DRAM throughput goes from 22.1% to 30.7% of peak. All twelve sweep configurations still match the PyTorch eager reference within the benchmark's 0.02 tolerance. Signed-off-by: Minho Ryu <ryumin93@gmail.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
Implements a CUDA kernel for the dense MLA decode path on SM80 GPUs (Ampere / A100). The current README excludes Ampere; this PR enables A100 deployment without forcing migration to Hopper.
dense_decode_fwdAPI and thecombinekernel are unchanged. Only the decode kernel itself is new.FLASH_MLA_DISABLE_SM100=1 FLASH_MLA_DISABLE_SM90=1 pip install -v .for A100-only nodes.Why
The SM90 kernel relies on TMA, WGMMA, thread block clusters, and
mbarrierasync barriers — all sm90+ only. SM80 must usecp.async+mma.m16n8k16+ldmatrix+cutlass::arch::NamedBarrier. This is a separate kernel rather than a SM90 fallback path.Design
BLOCK_M=16(one warp covers M via mma.m16n8k16); each wg ownsHEAD_DIM_V/4 = 128V columnscp.asyncprefetchSwizzle<3,3,3>-equivalent) for SMEM bank-conflict-freeldmatrixcp.async.cg(L1 bypass) for K loading;cp.async.cafor Q (reused across K iterations)Detailed design rationale and the SMEM budget tradeoffs are in
docs/sm80-dense-decode-design.md.Performance
Idle A100-SXM4-80GB, CUDA 12.9, exclusive node. Achievable copy bandwidth measured on
the same node is 1747 GB/s, which is what the percentages below are against; the 2039 GB/s
datasheet figure is not reachable by any kernel.
Both versions were built in one allocation and run in five alternating rounds. Median of
five, range in brackets.
The last five move in the right direction but their ranges overlap across rounds, so treat
them as unchanged. b=64 sk=4096 goes from 28% to 36% of achievable bandwidth.
Registers drop from 255, the SM80 ceiling, to 206, and the 252/348 byte spill disappears.
DRAM throughput goes from 22.1% to 30.7% of peak.
How the occupancy work went
Nsight put the original kernel at 6.25% occupancy,
Block Limit Shared Mem = 1, with nohardware unit above 37% of peak: latency bound with one CTA per SM and nothing to run while
it waits on
__syncthreads().Staging a 64-token KV page twice costs 162 KB. Two CTAs need roughly 81.5 KB each, and sQ
alone is 18 KB, so K staging had to fall from 128 tokens to at most 56. A 16-token tile with
three stages is 54 KB and also deepens the prefetch. The page size stays 64 and each page is
walked as four sub-tiles.
Shared memory was not the only place assuming one resident CTA. The split count came from
the SM count, so the grid still supplied 108 CTAs for 108 SMs and achieved occupancy stayed
at 6.25% after the theoretical limit doubled. It now scales with the CTAs that actually fit,
derived from the kernel's SMEM footprint. The combine kernel's split ladder stopped at 160
and asserted past it, so it gains rungs to 256.
The extra partitions need work to fill them: each reloads the 18 KB Q tile, runs a prologue
and epilogue, and adds a combine row. Doubling unconditionally cost 19.7% at b=16 sk=1024,
where 216 partitions share 256 KV pages. Two gates, both empirical and marked as such in the
code, keep the scaling to cases the sweep shows it helps.
Full sweep and the earlier milestones are in
docs/sm80-benchmark-2026-05-01.md.Limitations / future work
36% of achievable bandwidth is still short of the SM90 path's ~80% on H800, but the
profile does not blame the missing Hopper instructions for most of it. Warp stall cycles at
b=64 sk=4096, as ratios per issued instruction:
wait(fixed-latency execution dependency)short_scoreboard(MIO, i.e. shared memory)long_scoreboard(global memory)mio_throttlebarrierlg_throttle, math pipeGlobal memory is not the constraint. DRAM reads 304 MB against a 302 MB KV cache, so the
kernel already streams it exactly once with no redundancy, and global dependency accounts
for 11% of the stall. The 40% in the MIO reasons and the 33% in arithmetic dependency are
what is left.
independently, so each
ldmatrixover a K tile happens four times. That duplication isdeliberate and documented, and it was measured against an 8-warpgroup variant back when
shared memory was 162 KB and only one CTA fit. At 72 KB the trade is worth re-testing.
waitshare is the online-softmax rescale chain, which runs every tile and now runsfour times as often since the tile shrank from 64 tokens to 16. Rescaling less frequently
would shorten it.
tile change and is not worth touching.
cp.async.bulk.tensor,mbarrier::complete_tx::bytesandwgmma.mma_asyncfor.target sm_80. Where TMA wouldhave helped is visible in the counters: L1 requests 19.2M sectors, 615 MB, against 304 MB
from DRAM, because
cp.asyncmoves at most 16 bytes per instruction and two of them landin the same 32-byte sector. That is a request-rate cost, not a bandwidth one.
memory per SM and far more HBM bandwidth, so occupancy and staging arithmetic both come
out differently.
thread. A third CTA per SM needs both below their thresholds, not just one.
derived, and another SM80 part may want different ones.
__launch_bounds__(N, M, K)(third arg is sm90 cluster). I left those untouched so this PR is minimal; happy to follow up with portable launch_bounds macros if maintainers prefer combined builds.(head_dim=576 non-pow2, BLOCK_M=16, dynamic stride)combo — this version uses raw PTX. CuTe path is a candidate for follow-up if maintainers prefer that style.Modifications outside
csrc/sm80/csrc/api/api.cpp,common.h,dense_decode.hArch::is_sm80()helper. SM80 build only exposesdense_decode_fwdcsrc/smxx/decode/combine/combine.cu#if __CUDA_ARCH__ >= 900guard aroundcudaGridDependencySynchronize()(PDL is sm90+); split ladder extended from 160 to 256 rungs, needed once the SM80 path asks for more partitions. Existing callers resolve to the same constant as beforecsrc/smxx/decode/get_decoding_sched_meta/get_decoding_sched_meta.cu__launch_bounds__setup.pyFLASH_MLA_DISABLE_SM80flag, sm80 source list, plus include paths from pip-installednvidia-*wheels (system CUDA can lackcusparse.h)Test plan
benchmark/bench_sm80_decode.py --check— all 12 sweep configurations match the PyTorch eager reference. Note that--checkis silently ignored when--no-torch-baselineis also passed, since the comparison lives inside the baseline branch; worth tightening separately(batch, sq, hq, hk, sk)configs (b=1..128, sk=256..65536, hk=1..4) — all PASSis_causal=truefor multi-token Qnum_sm_parts > 1)benchmark/profile_decode_step.py— DeepSeek-V3-shape attention block profileHappy to make any code style / structure changes maintainers want before merge. If upstream prefers to keep the support matrix at SM90/SM100 only, this can also live as a maintained fork — see
docs/sm80-distribution-strategy.mdfor that path.