[HIP] [JIT] fp8_paged_mqa_logits: hand-written gfx950 decode indexer kernel - #5047
sumin-hong wants to merge 1 commit into
Conversation
Add a hand-written HIP implementation of the paged decode FP8 MQA indexer for gfx950 alongside the existing Triton/Gluon implementation. The kernel streams paged K data directly to registers, shares each K stream across consecutive MTP rows, reduces 32 heads with permlane operations, and supports the production block-size 1 and preshuffled block-size 64 layouts. The host dispatch exposes the main launch parameters while providing defaults for the supported 32-head, 128-dimensional shape. Add a correctness and performance sweep covering batch size, MTP row count, context length, KV block size, ragged contexts, and shuffled block tables. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Sumin Hong <sumin.hong@moreh.io>
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
PR title tags: |
There was a problem hiding this comment.
Pull request overview
This PR adds a gfx950-only, fixed-shape (32 heads, head_dim=128) hand-written HIP backend for the decode-phase FP8 paged MQA “indexer logits” op, wired through the aiter JIT/pybind extension path and accompanied by a correctness/perf sweep that A/Bs against the existing Triton/Gluon implementation.
Changes:
- Add
fp8_paged_mqa_logitsPython op wrapper + support gate for gfx950 / (32,128) / KVBlockSize {1,64}. - Add HIP kernel + host dispatch + pybind binding and register a new JIT module (
module_fp8_paged_mqa_logits) with-fno-honor-nans. - Add an op_test sweep script comparing Triton vs HIP vs a torch reference across batch/next_n/ctx_len/layout variations.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
aiter/ops/fp8_paged_mqa_logits.py |
New Python entry point + is_supported() gate for the gfx950-only decode kernel. |
csrc/kernels/fp8_paged_mqa_logits.cu |
HIP kernel implementation and Torch-facing dispatch/autotune logic. |
csrc/include/fp8_paged_mqa_logits.h |
C++ API declaration for the new op. |
csrc/pybind/fp8_paged_mqa_logits_pybind.cu |
Pybind module entry for the new op. |
csrc/include/rocm_ops.hpp |
Adds the pybind macro wiring (FP8_PAGED_MQA_LOGITS_PYBIND). |
aiter/jit/optCompilerConfig.json |
Registers module_fp8_paged_mqa_logits build config and HIP flags. |
op_tests/test_fp8_paged_mqa_logits.py |
New correctness + perf sweep comparing Triton vs HIP vs torch reference. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| TORCH_CHECK(n_heads == NUM_HEADS && head_dim == HEAD_SIZE, | ||
| "Only n_heads=32, head_dim=128 supported"); | ||
| TORCH_CHECK(kv_cache_fp8.is_contiguous(), | ||
| "kv_cache_fp8 must be contiguous for paged MQA logits"); |
| // ---- autotune ChunkK, num_warps, R, SplitKV ---- | ||
| // From the gfx950 reliable (cache-busting) sweep, the best config tracks the | ||
| // total K footprint tot = batch*ctx_len (redundancy vs occupancy trade): | ||
| // tot < 32K → R=1 (tiny: maximise occupancy, K-redundancy is ~free/L2) | ||
| // tot < 158K → R=2 (mid: occupancy still dominates) | ||
| // else → R=3 (bandwidth-bound: minimise redundant K reads; plenty | ||
| // of work for occupancy). R>=4/6 never wins (q_reg | ||
| // register pressure kills waves — occupancy is the only | ||
| // HBM-latency hider; prefetch/large-R both hurt). | ||
| // CHUNK_K = 128 for long ctx (more splittable tiles) or R=1; else 256. | ||
| // num_warps = 8 except the tiny R=1 case (4). |
| @compile_ops(MD_NAME, fc_name="fp8_paged_mqa_logits") | ||
| def fp8_paged_mqa_logits( |
| The zero-valued tunables (ChunkK, SplitKV, num_warps, RowsPerBlock) mean "use | ||
| the host heuristic". Writes into `out` when given (and returns it), otherwise | ||
| allocates [batch*next_n, max_model_len] f32. Only the causal window | ||
| p <= context_lens[b] - next_n + n is written; the -inf outside it is the | ||
| caller's, exactly as for `deepgemm_fp8_paged_mqa_logits`. |
|
Results from a production-style deployment, in case they help: GLM-5.3 (full model,
One deployment note: switching an instance from block 16 to block 64 with an existing LMCache L2 returns 100 % cache hits with corrupted KV, because the stored objects follow the page geometry; a block-size change needs an empty cache. |
|
Follow-up, end-to-end decode rather than the kernel in isolation (full GLM-5.3, TP4 on 4x MI355X, fp8 KV,
So for single-stream decode at these lengths the paged indexer is not on the critical path for us (the prefill kernel from #5046 is a different story: 600k-token prefill 285 s -> 93 s). Just a data point on where the decode kernel pays off — presumably larger decode batches. |
|
Could you provide e2e performance (say 1k/1k conc4-256 powers of 2 and 128k/1k conc4-64) and accuracy data (esp. long-context & reasoning like GPQA Diamond + Needle-in-Haystack) comparing performance against the Gluon reference kernel ( |
I have attached the additional experiment results to the PR description. |
Motivation
The DeepSeek-V3.2 / GLM-5 "lightning indexer" produces the sparse-attention
selection logits. For each query row
mand KV positionn:In the decode path K and its per-token dequant scale are co-packed in a paged
cache and gathered through a block table. This PR adds a hand-written HIP kernel
for that path on gfx950 (CDNA4), alongside the existing Triton/Gluon
deepgemm_fp8_paged_mqa_logits. The prefill half is #5046.The decode indexer streams the entire K cache for a handful of FLOPs per token,
so it is squarely memory-bound and the grid carries a
next_naxis -- every oneof a batch's MTP rows re-reads the same K. That redundancy, and the cost of
routing K through LDS, is what this kernel targets.
Technical Details
New op:
aiter/ops/fp8_paged_mqa_logits.pyfp8_paged_mqa_logitsuses the same tensor interface asdeepgemm_fp8_paged_mqa_logits-- sametensors (
q_fp8,kv_cache_fp8,weights,context_lens,block_tables,max_model_len), same contract that only the causal windowp <= context_lens[b] - next_n + nis written and the-infoutside it is thecaller's. Key design points:
and contracted 32 heads x 32 tokens per
mfma_scale_f32_32x32x64_f8f6f4tile.ROWS_PER_BLOCKrows share one K stream. R consecutivenext_nrows arehandled by one block, so the cache is read once per R rows instead of once per
row; grid is
(batch, SplitKV, ceil(next_n / R)). R trades that redundancyagainst occupancy, so the host picks it -- along with
ChunkK,num_warpsandSplitKV-- from the total KV footprintbatch * max_model_len. Each isoverridable.
kv_scale >= 0and ReLU is positive-homogeneous, so thescale is hoisted out of the head sum and applied once per KV column. This also
matches the order the torch reference applies it in.
v_permlane32_swap_b32head reduce, instead of__shfl_down(x, 32)whichlowers to
ds_bpermute_b32-- an LDS round-trip plus anlgkmcntwait.KVBlockSizeis a compile-time constant, instantiated for 1 and 64. Each istied to one cache layout, the pairing production already uses: 1 reads the plain
co-packed cache, 64 the
shuffle_weight(layout=(16,16))preshuffled one.-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, in the hottest loop of the kernel.
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, kv_block_size)gateson that, so a caller 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 the head_size=128-only ASM paged-attention kernelin
aiter/ops/attention.py.Files added / changed:
aiter/ops/fp8_paged_mqa_logits.py-- the op and its support gatecsrc/kernels/fp8_paged_mqa_logits.cu-- kernel and host dispatchcsrc/include/fp8_paged_mqa_logits.h,csrc/pybind/fp8_paged_mqa_logits_pybind.cucsrc/include/rocm_ops.hpp,aiter/jit/optCompilerConfig.json--module_fp8_paged_mqa_logitsop_tests/test_fp8_paged_mqa_logits.py-- correctness + perf sweepTest Plan
op_tests/test_fp8_paged_mqa_logits.pyruns Triton and HIP on identical inputsand grades both against one fp32 torch reference (a port of vLLM's
fp8_paged_mqa_logits_torch). Gates are an exact-infmask match pluscalc_diff < 1e-3andcheckAllclose; tolerances are not widened.The sweep is the cartesian product of
batch in {1,4,16,64},next_n in {1,2,4,6},heads in {32,64},head_dim=128,kv_len in {1024, 8192, 32768, 131072},KVBlockSize in {1,64}andvar_ratio in {0.0, 0.3}-- 512 cases, 256 of which the HIP kernel supports.Points worth calling out:
next_nup to 6. The host clampsROWS_PER_BLOCKtonext_n, so a sweepstopping at 2 can never reach the R=3 instantiation MTP decode actually runs on.
var_ratio 0.3draws each length from +/-30% ofkv_len).A uniform batch gives every sequence the same tail tile and the same causal
boundary, so a kernel deriving its bounds from one sequence -- or from
max_model_len-- would pass.kernel that ignores the table and walks the cache linearly fails rather than
passing by accident.
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 errexactly.Performance on MI355x/gfx950,
heads=32,head_dim=128, ragged context(
var_ratio=0.3),run_perfteston an otherwise idle GPU -- 72 shapes:Summarised over the full 72-shape sweep:
KVBlockSize=1KVBlockSize=64(preshuffled)The win is concentrated on the
KVBlockSize=1path, where the HIP kernel isfaster on every shape measured and the gap widens with
next_n(geomean 1.60x atnext_n=1, 1.98x at 2, 2.44x at 6) -- which is what the shared-K-stream designpredicts, since R only has rows to amortise over once
next_n > 1.On the preshuffled
KVBlockSize=64path the two are at parity overall(geomean 1.01x): the HIP kernel wins by ~1.2-1.3x at high
next_nand loses by upto 0.69x on the small end, where its prologue is not amortised. It is reported
here rather than hidden -- with
is_supported()in place a caller can pick perconfiguration, and the small-
next_npreshuffled corner is the obvious nexttarget.
Related Work
This PR proposes a structurally different, fixed-shape HIP backend with direct register streaming and shared K reads across MTP rows. A direct head-to-head comparison with #4221 and quantitative roofline utilization remain pending.
Current Validation
mainat9aa8a6b91f1972952314bd16176a76d392f6b85cwith no overlapping-file conflicts.black==26.3.0 --check: pass for the added Python op and test.ruff==0.16.0 check: pass for the added Python op and test.python3 -m py_compile: pass for the added Python op and test.AI assistance: OpenAI Codex was used for rebase adaptation, formatting, static checks, overlap research, and PR drafting. The submitter reviewed the resulting commit.
GLM-5.2-MXFP4 E2E and accuracy (2026-09-08 update)
This supplied experiment report addresses #5047 (comment). All supplied result tables are retained. The interpretations below distinguish observed differences, unverified statistical estimates, and the accuracy coverage still to be identified.
E2E and accuracy for this PR on GLM-5.2-MXFP4 / MI355X (gfx950) / TP4, per @nholmber's request.
Reported result: about 1.02x output throughput at 128k input on the flat KV layout (
block_size=1) across all five concurrency points, with TPOT about 1.03-1.07x better. On the preshuffled layout (block_size=64), throughput is about 0.6% lower across the five points. At 1k input, the reported differences are small and the call counter shows much less decode-indexer activity. These observations describe this MTP=0 integration run.Setup
amd/GLM-5.2-MXFP4,kv-cache-dtype=fp8_e4m3, MTP=08a728663c(0.28.1rc1.dev388)amd-aiter 0.1.19+ this PR's files, JIT modulemodule_fp8_paged_mqa_logits--tensor-parallel-size 4,--linear-backend aiter --moe-backend aiter,--block-size {1,64}benchmark_serving.py --dataset-name random,--random-range-ratio 1.0,--ignore-eos,--request-rate inf,--seed 1234Integration — one env-gated branch in
rocm_fp8_paged_mqa_logits()(
vllm/v1/attention/ops/rocm_aiter_mla_sparse.py), where vLLM already callsdeepgemm_fp8_paged_mqa_logits. GLM-5.2 isindex_n_heads=32, index_head_dim=128, sois_supported()holds and the kernel really engages.One caller-side change is required and worth flagging, because a naive drop-in is wrong rather
than slow: this kernel writes only the causal window and leaves the
-infoutside it to thecaller. vLLM's existing path relies on a trailing
nan_to_num_, which does not clear stale finite values — with thiskernel, stale finite logits from an earlier decode step survive in the reused workspace and corrupt
top-k selection. We pre-fill
-infbefore the call instead, matching what this PR's own op test doesfor both kernels. Cost is symmetric or better: the fill writes 16.9 MB where
nan_to_num_reads andwrites 33.8 MB.
Both arms ran concurrently on one node, one 4-GPU half each, from one container and one aiter
tree. The compared decode paths include the caller-side output initialization described above:
_gluon_deepgemm_fp8_paged_mqa_logits[_preshuffle](the Gluon reference kernel)aiter.ops.fp8_paged_mqa_logits(this PR)The branch raises rather than silently falling back when
is_supported()is false, and a callcounter prints per TP rank, providing evidence that the HIP path was exercised during the instrumented run:
op_tests/test_fp8_paged_mqa_logits.py: 512 / 512 configurations reported passing. First JIT build 21.8 s. As described in the original Test Plan, the HIP kernel supports 256 of those configurations; unsupported HIP entries are not counted as HIP coverage.128k in / 1k out,
block_size=1The supplied report summarizes these points as mean +2.40%, with a reported 95% CI of [+2.11%, +2.70%]. Every listed point favours this PR on both metrics. The interval calculation and independent repetition count were not included, so this interval should not be read as an established bound on repeat-run variability.
128k in / 1k out,
block_size=64(preshuffled)The supplied report summarizes these points as mean -0.59%, with a reported 95% CI of [-0.85%, -0.33%]. All five throughput differences are negative and TPOT moves in the same direction. This is an observed slowdown in this sweep; the available report does not establish its significance against repeat-run variability.
1k in / 1k out
This scenario has limited sensitivity to the kernel. The report attributes this to vLLM short-circuiting the decode indexer when
max_seq_len <= index_topk(sparse_attn_indexer_kpool.py:210,717); GLM-5.2 shipsindex_topk=2048and a 1k/1k server caps at 2080 tokens. Our call counter measures 0.19 op callsper decode step here against >=2.4 at 128k.
The report gives a spread of sigma = 0.507% and a mean difference of +0.21% across these 14 short-context points. They provide a descriptive short-context comparison, but are not a strict A/A control because the measured op-call count is nonzero. Their spread does not independently establish a +-1% detection threshold or rule out GPU-half placement effects at 128k.
Accuracy
Coverage note: the supplied report contains one baseline/HIP accuracy table and does not identify the block size used for these evaluations. Separate model-accuracy coverage for both
block_size=1and64has not yet been established from this report.Validation gates and expectations from the AMD "GLM-5.2 MXFP4 - vLLM status and validation" deck,
run with the deck's own commands, both arms in parallel on one 4-GPU half each.
</think>, 391niah_single_2@ 64k (500 samples)niah_single_2@ 128k (500 samples)GSM8K differs by 0.45 pp; the report gives a combined standard error of 0.98 pp (z ~ 0.46). GPQA decreases by 2.5 pp — 5 questions out of 198 — with a reported combined standard error of 3.0 pp (z ~ 0.84). The report also records 7 of 198 responses reaching the 100k token budget on each arm. Those approximate error comparisons do not demonstrate equivalence, and both GPQA point estimates are below the stated ~92%+ expectation. The individual response outcomes needed for a paired analysis were not included.
RULER is at the ceiling on both arms at both tested lengths. This supports long-context retrieval on these samples. The caller's
-infinitialization contract remains important because stale finite logits can alter top-k selection.Notes
block_size=1wins andblock_size=64does not. This PR's op-level table predicts bothsigns: geomean 3.86x at
KVBlockSize=1(36/36 shapes) against 1.01x atKVBlockSize=64(18/36,worst 0.69x). MTP=0 pins
next_n=1, this kernel's weakest case by construction — theROWS_PER_BLOCKdesign shares one K stream across R consecutivenext_nrows, and with one rowthere is nothing to amortise. The op table shows the same shape: geomean 1.60x at
next_n=1rising to 2.44x at
next_n=6.next_nvalues from this MTP=0 serving run. They suggest a mechanism for the flat-layout benefit, but do not quantify the indexer's share of time in this run. In particular, the original report's 0.13% and 2.5% contribution estimates were extrapolations across configurations rather than measurements of the serving run, and are not used to establish an E2E bound here.--block-sizemust be set explicitly. The serving recipe leaves vLLM's default of 16, whichis_supported()rejects. Set 1 or 64 explicitly to exercise this kernel and use the dispatch counters above to verify the selected path.~129k, so about 25 fit. Throughput saturates at ~95 tok/s from conc 16 on and TTFT reaches 450 s at
conc 64 — those points queue rather than running fully in parallel. Identical on both arms, so the
comparison holds, but the concurrency label is nominal.
--no-enable-prefix-caching), so absolute numbers are not comparableto runs that leave it at the default.
(
block_size=64) and 2 and 1 (block_size=1). The intended comparisons are baseline versus HIP within each block-size/scenario pair; different sample counts limit comparisons across layouts.to before/after against the Gluon reference. We can add it as a third arm if that would help.
number here is
next_n=1); ablock_size=16instantiation, since 16 is what most vLLM/ROCmdeployments run and the kernel currently supports only 1 and 64; and the small-
next_npreshuffledcorner, which is where the -0.59% comes from and which this PR already names as the next target.
Submission Checklist