[CUDA] Serve quantized KV caches with XQA: attention sinks, independent and per-channel scales - #29900
Merged
Merged
Conversation
…scales out Per-channel INT8/FP8 KV caches were disqualified from the XQA decode kernel because XQA only understands a scalar dequantization scale, so decode fell back to "dequantize the whole cache, then run flash attention" -- an O(context) cost paid on every decode step, which is what made quantized KV ~2.8x slower than FP16 KV at 16k. Dequantization is linear and the channel dim is contracted for K and free for V: scores_t = sum_d q_d * (k_td * sk_d) = sum_d (q_d * sk_d) * k_td out_d = sum_t p_t * (v_td * sv_d) = (sum_t p_t * v_td) * sv_d so the per-channel scales can be folded out of the kernel exactly: sk into Q before the launch and sv into the attention output after it, two elementwise passes over [batch, seq, num_heads, head_size] that are O(1) in context length. Softmax only sees the (already correct) scores, and every step after the P*V accumulation is linear, so attention sinks, sliding window and the multi-block (Flash Decoding) reduction stay valid. XQA now treats a null k/v cache scale pointer as "scale is 1", which is how ExtremeDecoding signals that it folded the scale itself. Measured on H200 (gpt-oss-20b, 24 layers, head_size 64, group 8), decode ms/token at 16k context: INT8 per-channel 7.42 -> 2.75 (2.70x), FP8 per-channel 7.65 -> 2.91 (2.63x), vs 2.59 for the FP16-KV XQA baseline. TTFT is unchanged (-26% vs FP16 KV).
The XQA per-channel path scales Q by the per-channel K dequantization scale before the kernel launch. That was a standalone elementwise pass, but UnpackRoPEAppend already loads Q, applies RoPE and stores it to the same scratch buffer, so the multiply can ride along for free: it takes a q_fold_scale argument and applies it to the registers it is about to store. Removes one kernel launch and one full read+write of Q per attention op per decode step (24 of the 48 folding launches for gpt-oss-20b). The V-side output rescale still needs its own pass, since it consumes the XQA output. Verified numerically equivalent to the two-kernel version: the op-level max-abs and RMS error vs the fp32 reference are bit-identical across head_size 64/128 x past 4/512, and the 128-token greedy continuation of gpt-oss-20b INT8 per-channel KV is unchanged (and still identical to the FP16-KV baseline). test_gqa.py 865 passed, onnxruntime_provider_test GroupQueryAttention 63 passed.
tianleiwu
requested review from
apsonawane and
Copilot
and removed request for
Copilot
July 27, 2026 16:05
Contributor
There was a problem hiding this comment.
Pull request overview
This PR extends the contrib CUDA GroupQueryAttention (GQA) “XQA” decode path to efficiently serve quantized KV caches (INT8/FP8) that previously fell back to full-cache dequantization + Flash-Attention, adding support for attention sinks, independent K/V scales, and exact per-channel scale folding.
Changes:
- Enable attention sinks (smooth softmax via
head_sink) for quantized XQA decode and thread sink/scales correctly through both single- and multi-block reductions. - Support independent
k_scale/v_scale, and servePER_CHANNELquantization by folding K scale into Q and V scale into the output (exact linear transform). - Optimize the non-XQA quantized decode fallback with a vectorized, length-aware dequantization path and expand Python parity/dispatch coverage + docs.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| onnxruntime/test/python/transformers/test_gqa.py | Adds new parity+dispatch tests for quantized XQA with sinks, separate K/V scales, and per-channel folding. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader.h | Updates XQA loader API to accept separate K/V scale pointers and document null-scale semantics. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_fp16.cu | Threads separate K/V scale pointers through FP16 XQA dispatch. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_fp16_int8.cu | Updates INT8 XQA dispatch signature to include sinks + separate K/V scales. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_fp16_int8_impl.cuh | Threads sinks + separate K/V scales into INT8 group-size specializations. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_fp16_impl.cuh | Enables sinks on quantized paths and threads separate K/V scales throughout. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_fp16_fp8_impl.cuh | Threads sinks + separate K/V scales into FP8 group-size specializations. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_fp16_64.cu | Updates explicit instantiation signature to match loader changes (H=64). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_fp16_128.cu | Updates explicit instantiation signature to match loader changes (H=128). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_fp16_256.cu | Updates explicit instantiation signature to match loader changes (H=256). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_bf16.cu | Threads separate K/V scales + sinks through BF16 XQA dispatch (incl. INT8 path). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_bf16_int8.cu | Updates BF16 INT8 XQA dispatch signature to include sinks + separate K/V scales. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_bf16_int8_impl.cuh | Threads sinks + separate K/V scales into BF16 INT8 group-size specializations. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_bf16_impl.cuh | Enables sinks on quantized BF16 paths and threads separate K/V scales throughout. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_bf16_fp8_impl.cuh | Threads sinks + separate K/V scales into BF16 FP8 group-size specializations. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_bf16_64.cu | Updates explicit instantiation signature to match loader changes (BF16 H=64). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_bf16_128.cu | Updates explicit instantiation signature to match loader changes (BF16 H=128). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_loader_bf16_256.cu | Updates explicit instantiation signature to match loader changes (BF16 H=256). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_impl_gen.cuh | Updates generated launch plumbing to pass separate K/V scale pointers. |
| onnxruntime/contrib_ops/cuda/bert/xqa/mha_impl.cuh | Implements separate K/V scale handling and null-scale semantics in the kernel. |
| onnxruntime/contrib_ops/cuda/bert/group_query_attention.cc | Updates XQA eligibility: allow sinks on quantized paths; relax scale restrictions; accept PER_CHANNEL for XQA via folding. |
| onnxruntime/contrib_ops/cuda/bert/group_query_attention_qkv.cuh | Adds q_fold_scale support to fold per-channel K scale into Q during preprocessing. |
| onnxruntime/contrib_ops/cuda/bert/group_query_attention_qdq.cuh | Adds vectorized/length-aware dequant fast path and output scaling kernel for per-channel V folding. |
| onnxruntime/contrib_ops/cuda/bert/group_query_attention_impl.h | Reserves Q scratch buffer for per-channel K folding on XQA. |
| onnxruntime/contrib_ops/cuda/bert/group_query_attention_impl.cu | Wires per-channel folding into ExtremeDecoding (XQA) and length-aware dequant into fallback path. |
| docs/contrib_ops/cuda/gqa.md | Updates quantized-decode/XQA eligibility documentation and test inventory. |
kunal-vaishnavi
approved these changes
Jul 27, 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.
Description
Extends the XQA decode kernel to serve the quantized (INT8/FP8) KV-cache configurations that
previously fell back to dequantize-the-whole-cache-then-Flash-Attention. Four changes, in
contrib_ops/cuda/bert/:Attention sinks on quantized XQA.
use_xqa_attention_sinksno longer requires!is_inputs_quantized. The sink is folded into the softmax row sum by the same code thenon-quantized path uses, and the dequant scale is already applied to the scores before the row
max/sum, so sink and score live in the same domain — true for both the single-block and the
multi-block (Flash Decoding) reduction, where the sink is added to the merged row sum.
Independent
k_scale/v_scale. XQA previously required the two scale tensors to be thesame pointer, which no builder-emitted model satisfies. The kernel now keeps them apart:
k_scale→qkScale(applied to Q·Kᵀ),v_scale→voScale(applied to the P·V accumulator).PER_CHANNELscales folded out of the kernel. XQA only accepts a scalar dequant scale.But dequantization is linear and the channel index is the contracted dim for K and the free
dim for V:
so the per-channel scales can be moved out of the kernel exactly: pre-scale Q by
sk,post-scale the attention output by
sv.p_tis unchanged because softmax only ever sees the(already correct) scores, and every step after the P·V accumulation is linear, so attention
sinks, sliding window and the multi-block reduction all stay valid. XQA reads a null scale
pointer as "scale = 1", which is how the caller signals it folded the scale itself.
The Q side costs nothing extra:
UnpackRoPEAppendalready loads Q, applies RoPE and stores it tothe very scratch buffer XQA reads, so it takes a
q_fold_scaleargument and applies the multiplyto the registers it is about to store. Only the V side needs its own pass, since it consumes
XQA's output. Both are
O(num_heads * head_size)— O(1) in context length, versus theO(context) full-cache dequant they replace.
Vectorized / length-aware dequant fallback for what XQA still cannot serve (INT4,
unsupported
head_size/group size). The fallback dequant kernel now uses 32-byte vector loads,hoists per-channel scales out of the sequence loop, maps (batch, head) to
blockIdx.yso theinner addressing is 32-bit, and walks only the rows inside the sequence instead of the padded
cache capacity.
docs/contrib_ops/cuda/gqa.mdis updated to match: quantized-decode description, XQA eligibilityconditions, backend-selection table, and the parity-test list.
Motivation and Context
The quantized KV-cache decode path was ~2.8x slower than an FP16 KV cache at 16k context, and
the gap grew with context length because the fallback rebuilt the entire dequantized K+V cache on
every decode step. The binding gate was attention sinks: a model carrying
head_sinksetsuse_smooth_softmax, which combined with a quantized cache disqualified XQA regardless of scalegranularity, head size or group size. Scale-granularity workarounds do not help — all quantized
configurations previously landed within 0.5% of each other, because none of them reached XQA.
Decode latency, batch 1, 64 generated tokens, gpt-oss-20b geometry (24 layers,
head_size64,group 8), H200 / SM90, median of 5, idle GPU:
(ms/token,
before -> after. The FP16 control is unchanged, so the comparison is sound.) QuantizedKV decode goes from −66% to −4% versus an FP16 cache at 16k, while keeping its −26% TTFT
(749 ms vs 1017 ms @16k). Scale granularity now costs ~0.05 ms/token (2% at 16k), so per-channel —
by far the most accurate INT8 option — no longer has to be traded away for decode speed.
For configurations XQA still cannot serve, change 4 alone gives 7.44 -> 4.61 ms/token @16k (1.61x);
its residual overhead over the FP16 non-XQA Flash path is now 0.44 ms/token (was 3.40), i.e. roughly
75% of theoretical HBM bandwidth for the traffic it must move.
Testing
onnxruntime/test/python/transformers/test_gqa.py: 865 passed, 384 of them new. Three newclasses, each asserting both numerics and that the op actually reaches XQA
(
SdpaKernel == "XQA"), so a silent fallback fails the test rather than passing quietly:TestXQAQuantizedHeadSinkParity(96) — quantized decode with an attention sink.TestXQASeparateKVScaleParity(144) — distinctk_scale/v_scaletensors.TestXQAPerChannelScaleParity(144) — per-channel K/V scales folded into Q and the output.Combined coverage: INT8 + FP8 x fp16 + bf16 x
head_size64/128/256 x group 4/8 x global /sliding-window / multi-block (past 512) x sink on/off x rotary on/off x prepacked vs
runtime-converted sink.
onnxruntime_provider_test --gtest_filter='*GroupQueryAttention*': 63 passed.The
q_fold_scalefusion in change 3 was verified to be a numerical no-op: op-level max-abs andRMS error against the fp32 reference are bit-identical to the unfused two-pass version across
head_size64/128 x past 4/512.End to end, a gpt-oss-20b build with an INT8 per-channel KV cache produces a token-identical
128-token greedy continuation to the same model with an FP16 KV cache.
lintrunnerclean.