[HIP] [JIT] fp8_mqa_logits: hand-written gfx950 prefill indexer kernel - #1
Open
anhcvt wants to merge 1 commit into
Open
[HIP] [JIT] fp8_mqa_logits: hand-written gfx950 prefill indexer kernel#1anhcvt wants to merge 1 commit into
anhcvt wants to merge 1 commit into
Conversation
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
PR title tags: |
Adds the prefill half of the DeepSeek-V3.2 / GLM-5 sparse-attention lightning
indexer as a HIP kernel, alongside the existing Triton/Gluon one:
logits[m, n] = sum_h relu(Q[m,h,:] . K[n,:]) * w[m,h] * kv_scale[n]
for n in [ks[m], ke[m]), -inf elsewhere
K is already gathered into a contiguous [N, 128] buffer, so there is no block
table. Same call contract as aiter.ops.triton.attention.fp8_mqa_logits.
aiter/ops/fp8_mqa_logits.py the op, plus is_supported()
csrc/kernels/fp8_mqa_logits.cu module_fp8_mqa_logits
op_tests/test_fp8_mqa_logits.py triton vs hip, one fp32 reference
The kernel streams K HBM->register with no LDS staging and contracts 32 heads x
32 tokens per mfma_scale_f32_32x32x64_f8f6f4 tile. Heads reduce across lanes
with v_permlane32_swap_b32, two query rows at a time, and the per-token scale is
applied once after that reduction (kv_scale >= 0, so it commutes with the ReLU).
BLOCK_M query rows share one K stream; the warps of a block split the tile range
rather than the rows, which keeps several K streams in flight per block. With
clean_logits the kernel writes the -inf outside each window itself, so the caller
needs no separate fill pass over the whole output.
Row groups are dispatched high-m first for N > 2048. Under causal masking a row
group's work grows with m, so in natural order the longest-running blocks are
dispatched LAST and become the tail; reversing starts them first and lets the
short ones fill in behind. Worth +6% at (4096, 4096) and +3% at (8192, 8192),
neutral elsewhere, and free -- it only reorders block dispatch. `unroll2` and
`reverse_rows` are tri-state (-1 heuristic, 0/1 off/on) so the host can tell
"off" from "caller did not choose".
It is gfx950-only and fixed at n_heads=32/head_dim=128 (the shipped GLM-5-FP8
indexer shape). is_supported() gates on that so a caller serving other shapes can
route them to the Triton kernel rather than trip a TORCH_CHECK.
The test sweeps both kernels over the same shapes and six window modes. Beyond
causal and cp it covers misaligned, empty, past-end and multi-request windows --
all legal indexer input at a chunk boundary, and where the masking and the -inf
fill are easiest to get wrong. It poisons the output buffer with NaN first, so a
position no kernel writes fails the -inf mask check instead of passing on
allocator leftovers -- that poisoning is a separate call from the timed one, so
neither candidate is charged a full-output memset per iteration. Shapes include
the real chunk sizes a request is split into, and the reference runs in
query-row chunks so those fit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
anhcvt
force-pushed
the
anhcao/hip-fp8-mqa-logits
branch
from
August 26, 2026 16:47
bd49b75 to
e262860
Compare
| [M, N] f32; outside each row's window the kernel writes -inf when clean_logits, | ||
| and leaves the buffer untouched otherwise. | ||
| """ | ||
| ... |
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.
Motivation
The DeepSeek-V3.2 / GLM-5 "lightning indexer" produces the sparse-attention
selection logits. For each query row
mand KV positionn:In the prefill path the caller has already gathered K out of the paged cache into
a contiguous
[N, 128]buffer with a separate[N]fp32 scale, so there is noblock table and no preshuffle. This PR adds a hand-written HIP kernel for that
path on gfx950 (CDNA4), alongside the existing Triton/Gluon
aiter.ops.triton.attention.fp8_mqa_logits. The paged decode half is a separatePR.
cu_seqlen_ksis a request's base offset into the gathered K buffer andcu_seqlen_kegrows by one per query row, so each request is a causal triangleand several requests arrive packed into one
[M, N]chunk. Chunks are sized soM*N*4stays underVLLM_SPARSE_INDEXER_MAX_LOGITS_MB, which makes a 32Krequest
(4096, 32768)and a 128K one(1024, 131072).Technical Details
New op:
aiter/ops/fp8_mqa_logits.pyfp8_mqa_logitsis a drop-in for the Triton entry point -- same tensors(
q_fp8,k_fp8,kv_scale,weights,cu_seqlen_ks,cu_seqlen_ke) and thesame
clean_logitssemantics. Key design points:tokens per
mfma_scale_f32_32x32x64_f8f6f4tile.BLOCK_Mquery rows share one K stream, so K is read once per row blockrather than once per row. Grid is
(ceil(M / BLOCK_M), SplitN);SplitNsplits each block's KV tile range so a small row grid still fills the device.
streams in flight per block. Splitting rows across warps instead -- so K is read
once per
BLOCK_M * NUM_WARPSrows rather than once perBLOCK_M-- measured1.5-23% slower: the latency hiding is worth more than the L2 traffic it saves.
v_permlane32_swap_b32head reduce.v_permlane32_swap_b32 a, bleaves a's reduction in lanes 0-31 and b's in lanes 32-63, so two query rows
reduce with one swap and one add, and the store that follows uses all 64 lanes
instead of half.
kv_scale >= 0and ReLU is positive-homogeneous, so thescale is applied once per KV column after the head reduction rather than inside
it.
N > 2048. Under causal masking arow group's work grows with
m, so in natural order the longest-running blocksare dispatched LAST and become the tail; reversing starts them first and lets
the short ones fill in behind. Worth +6% at
(4096, 4096)and +3% at(8192, 8192), neutral elsewhere, and free -- it only reorders block dispatch.-inffill. Withclean_logitsthe kernel writes the-infoutsideeach row's window itself. The Triton path pre-fills all
M*Nelements withtorch.fulland then overwrites the valid ones, paying for the valid regiontwice.
-fno-honor-nansfor the module, so the ReLU is a singlev_max_f32.Without it LLVM must assume a signalling NaN and emits an IEEE canonicalize
first -- two VALU per accumulator value, which is ~27% of the kernel's VALU.
BlockM,SplitN,num_warps,unroll2andreverse_rowsare all tunable;zero means "use the host heuristic".
The kernel is gfx950-only and fixed at
n_heads=32, head_dim=128-- the shippedGLM-5-FP8 indexer shape.
is_supported(num_heads, head_dim)gates on that, so acaller that also serves other shapes can route them to the Triton kernel rather
than trip a
TORCH_CHECK. This mirrors how_should_use_asm_kernelgates thehead_size=128-only ASM paged-attention kernel in
aiter/ops/attention.py.Files added / changed:
aiter/ops/fp8_mqa_logits.py-- the op and its support gatecsrc/kernels/fp8_mqa_logits.cu-- kernel and host dispatchcsrc/include/fp8_mqa_logits.h,csrc/pybind/fp8_mqa_logits_pybind.cucsrc/include/rocm_ops.hpp,aiter/jit/optCompilerConfig.json--module_fp8_mqa_logitsop_tests/test_fp8_mqa_logits.py-- correctness + perf sweepTest Plan
op_tests/test_fp8_mqa_logits.pyruns Triton and HIP on identical inputs andgrades both against one fp32 torch reference -- the same
ref_fp8_mqa_logitstheTriton lane's test uses. Gates are an exact
-infmask match pluscalc_diff < 1e-3andcheckAllclose; tolerances are not widened.The sweep is the cartesian product of 14
(s_q, s_k)shapes,num_heads in {32,64,128},head_dim in {64,128},clean_logits in {0,1}andsix window modes -- 900 cases, 150 of which the HIP kernel supports. Points worth
calling out:
causalandcp, the sweep coversmisaligned,empty(rows withcu_endsbelow zero or belowcu_starts),past_end(bounds beyond
seq_len_kv) andmulti_req(several requests packed into onechunk, so
cu_startsjumps at each boundary and a block's rows straddle it).All are legal indexer input at a chunk boundary, and all are where the masking
and the
-inffill are easiest to get wrong. Grading the HIP kernel underpast_endcaught two out-of-bounds writes during development, both on theclean_logits=Falsepath.so a position the kernel fails to write fails the
-infmask check. Without itthe check is close to vacuous -- the caching allocator hands back a block a
previous case already left holding the correct
-inf.(4096, 32768),(2048, 65536),(1024, 131072).The reference runs in query-row chunks so its
[heads, s_q, s_k]score tensorstays bounded (unchunked it is 17 GiB at
heads=32, s_q=1024, s_k=131072).nanrather thanreporting a wrong-but-fast number, and any case dropped for lack of memory is
logged by name so a short table cannot read as full coverage.
Test Result
All correctness gates pass on gfx950 across the sweep; per-case
hip errmatchestriton err. Grading the HIP kernel underpast_endcaught two out-of-boundswrites on the
clean_logits=Falsepath (acu_startspastseq_len_kvleft thefill's first range unclamped, and the store bounded
abs_posonly bycu_ends);both are fixed here.
Performance on MI355x/gfx950,
num_heads=32,head_dim=128,run_perfteston anotherwise idle GPU.
causalis one request per chunk,multi_reqis four:Summarised over the 32-shape sweep (both
clean_logitssettings, both windows):causalmulti_reqclean_logits=Trueclean_logits=FalseThe win tracks
s_k / s_q, which is what the shared-K-stream design predicts:BLOCK_Mrows amortise one K read, so the longer a row's KV range is relative tothe row grid, the more there is to amortise. Every shape with
s_k >= 8 * s_qisa win (1.19x - 6.39x), and the largest is the small-M/long-KV corner
(128, 32768)at 6.4x, where the row grid alone cannot fill the device andSplitNdoes the work.The losses are the square, short-KV shapes --
(8192, 8192)at 0.97x/0.83x and(1024, 1024)/(4096, 4096)underclean_logits=False-- where the tile loop isshort relative to the per-block Q/weights prologue. That prologue is also what
pins the kernel at 2 waves/SIMD:
BLOCK_M=4needs 194 VGPR, of which Q and theper-row weights are 128, and both scale with
BLOCK_M, so KV reuse and registerpressure cannot be traded apart in this design (
BLOCK_M=8needs 256 VGPR andspills 113). Staging K through LDS would decouple them; until then
is_supported()plus a shape check lets a caller keep Triton on that corner.clean_logits=Trueis the better case for the HIP kernel (1.64x vs 1.43x), whichis the fused
-inffill showing up: the Triton path pays atorch.fullover alls_q * s_kelements before the kernel overwrites the valid ones.Submission Checklist