[CUDA] PagedAttention: quantized KV cache, XQA decode, MLA, QK-Norm and head sink - #29912
Merged
Conversation
tianleiwu
commented
Jul 27, 2026
tianleiwu
commented
Jul 27, 2026
tianleiwu
commented
Jul 27, 2026
tianleiwu
commented
Jul 27, 2026
tianleiwu
marked this pull request as draft
July 28, 2026 17:37
v_head_size can only differ from head_size in LATENT mode, where value, value_cache and v_scale are all absent. Using effective_v_head_size for those tensors implied a width that can never occur. Reserve effective_v_head_size for output 0 and the V view of key_cache, and state the rule explicitly in the schema and MLA sections.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR extends the CUDA contrib com.microsoft::PagedAttention operator to cover additional modern serving features (absorbed MLA / latent KV layout, quantized KV cache, QK RMSNorm, head sinks, explicit slot mapping, and replay-stable host metadata for CUDA Graph friendliness) and adds a paged-KV XQA decode path for quantized caches.
Changes:
- Expand the
PagedAttentionop schema + type/shape inference to support quantized caches (T_CACHE+ scales), optional/conditional cache/value IO forkv_cache_layout=LATENT, and new optional inputs (slot mapping, head sink, QK-norm, metadata). - Update the CUDA kernel implementation to handle quantized caches, new prologues/epilogues, backend selection (Flash/MEA/paged-decode/XQA/latent), and add paged-XQA translation units/loaders.
- Update documentation and Python symbolic shape inference for the new operator behavior.
Reviewed changes
Copilot reviewed 27 out of 29 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| onnxruntime/python/tools/symbolic_shape_infer.py | Adds special-case output/cache inference for packed QKV and LATENT layout. |
| onnxruntime/core/graph/contrib_ops/bert_defs.cc | Updates PagedAttention schema + shape inference for new inputs/attrs, quantized cache types, and LATENT output rules. |
| onnxruntime/contrib_ops/cuda/cuda_contrib_kernels.cc | Registers additional typed CUDA kernels for PagedAttention (activation + cache dtype pairs). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_loader.h | Declares paged-KV XQA decode launcher and shared-memory query helper. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_loader.cu | Implements dispatcher for paged-XQA decode by head size / cache quant type / bf16. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_loader_impl.cuh | Shared TU body for generating paged-XQA kernels across group sizes and dtypes. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_impl_gen.cuh | Instantiation template mirroring XQA contiguous kernels but binding paged-KV entry points. |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_fp16_int8_64.cu | Instantiates fp16+int8 paged-XQA kernel (head=64). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_fp16_int8_128.cu | Instantiates fp16+int8 paged-XQA kernel (head=128). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_fp16_fp8_64.cu | Instantiates fp16+fp8 paged-XQA kernel (head=64, gated). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_fp16_fp8_128.cu | Instantiates fp16+fp8 paged-XQA kernel (head=128, gated). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_bf16_int8_64.cu | Instantiates bf16+int8 paged-XQA kernel (head=64). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_bf16_int8_128.cu | Instantiates bf16+int8 paged-XQA kernel (head=128). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_bf16_fp8_64.cu | Instantiates bf16+fp8 paged-XQA kernel (head=64, gated). |
| onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_bf16_fp8_128.cu | Instantiates bf16+fp8 paged-XQA kernel (head=128, gated). |
| onnxruntime/contrib_ops/cuda/bert/xqa/utils.cuh | Adds int8→half conversion fastpath; adjusts constants for paged kernels. |
| onnxruntime/contrib_ops/cuda/bert/xqa/mhaUtils.cuh | Extends paged KV cache metadata (extraSeqLen) to unify seq-len handling. |
| onnxruntime/contrib_ops/cuda/bert/xqa/mha_impl.cuh | Fixes constexpr-divisor hazards; plumbs extraSeqLen into paged KV cache list. |
| onnxruntime/contrib_ops/cuda/bert/paged_attention.h | Extends kernel class state for new attrs/options + XQA shared-mem caching. |
| onnxruntime/contrib_ops/cuda/bert/paged_attention.cc | Implements new backend selection, metadata bounds, quantized cache wiring, and XQA/latent paths. |
| onnxruntime/contrib_ops/cuda/bert/paged_attention_impl.h | Adds helpers for paged decode and latent shared-memory sizing; templates on cache dtype. |
| onnxruntime/contrib_ops/cuda/bert/paged_attention_impl.cu | Adds quantized cache read/write, fused QK-norm+RoPE prologue, paged decode, latent backend, XQA paged decode, and updated gathers. |
| onnxruntime/contrib_ops/cuda/bert/paged_attention_helper.h | Validates new inputs/attrs (LATENT, slot mapping, head sink, QK-norm, cache quantization/data types, metadata). |
| onnxruntime/contrib_ops/cuda/bert/attention_data.h | Extends PagedAttentionData for cache dtype, scales, decode workspaces, and XQA scratch. |
| onnxruntime/contrib_ops/cpu/bert/attention_parameters.h | Extends PagedAttentionParameters with sink/QK-norm/quant/LATENT/offset fields. |
| onnxruntime/contrib_ops/cpu/bert/attention_common.h | Adds KV cache logical dtype enum + parsing helpers. |
| docs/ContribOperators.md | Updates PagedAttention docs to reflect new schema surface area. |
Address review feedback on the PagedAttention shape inference: - bert_defs.cc: value_cache (input 4) is schema-optional because it must be absent for kv_cache_layout='LATENT'. A SEPARATE node could therefore declare value_cache_out while omitting input 4, which made the propagation read an out-of-range input. Fail with an explicit shape-inference error instead. - symbolic_shape_infer.py: only derive head_size when the query hidden size is divisible by num_heads (LATENT) or by (num_heads + 2 * kv_num_heads) (packed QKV); otherwise fall back to generic propagation instead of silently emitting a truncated output width. Also skip cache-output propagation when the aliased cache input is absent, which previously raised IndexError.
The CPU build pipelines install a CPU-only torch wheel, so every 'device="cuda"' allocation raises 'AssertionError: Torch not compiled with CUDA enabled'. TestPagedAttentionAttentionMetadata and TestPagedAttentionMLA had no CUDA gate and failed there. Add a shared has_cuda_device() helper (reused by the existing has_flash_attention/has_memory_efficient_attention gates) and apply it as a class-level skip to every test class that lacked one.
The schema gained T_CACHE/T_KV_SCALE type constraints and the slot_mapping, head_sink, q_norm_weight, k_norm_weight, k_scale, v_scale and attention_metadata inputs, so the generated kernel table row was stale and the Kernel Documentation Validation job failed. Row copied verbatim from that job's generated output.
tianleiwu
added a commit
to microsoft/onnxruntime-genai
that referenced
this pull request
Jul 30, 2026
…h capture (#2333) ## Summary Extends the PagedAttention path end to end: the model builder can now export a quantized (int8/fp8) paged KV cache, both the builder and the engine wire up the new `attention_metadata` input that removes a per-node host sync, the continuous-batching engine can capture and replay decode steps as CUDA graphs, and the paged cache manager no longer double-counts prefill tokens when reserving blocks. Measured on 8×H200 (only one GPU was used in benchmark) with gpt-oss-20b, batch-1 decode on the int8-KV paged model goes from **242.7 → 305.7 tok/s (+26%)** from the two changes in this PR (`attention_metadata` + CUDA graphs), and the paged KV pool now serves twice the context/concurrency it did for the same `num_blocks`. Requires the matching ONNX Runtime changes (`PagedAttention` `attention_metadata` input at index 16, XQA paged decode kernels) in microsoft/onnxruntime#29912. ## Key Changes ### 1. Quantized KV cache for PagedAttention (model builder) | Change | File | |---|---| | Accept `PagedAttention` in `make_quantized_kv_cache_init()` (was GQA-only) | `builders/base.py` | | Reject `int4_*` on the paged path — `PagedAttention`'s `T_CACHE` is `{float16, bfloat16, int8, float8e4m3fn}`, there is no sub-byte paged cache backend | `builders/base.py` | | Emit per-channel scales in the canonical `(num_kv_heads, 1, head_size)` shape the paged op validates (GQA only checks the element count) | `builders/base.py` | | Wire `k_scale`/`v_scale` optional inputs and set `k_quant_type`/`v_quant_type`. `PagedAttention` has no `kv_cache_bit_width` attribute — it derives the element type from the cache tensor | `builders/base.py` | | Document the paged + quantized-KV combination and its constraints | `models/README.md`, `builder.py` | Shared logic between `make_group_query_attention` and `make_paged_attention` was factored into `get_qk_norm_weight_inputs()`, `get_kv_cache_scale_inputs()`, `get_attention_op_attributes()` and `extend_with_optional_inputs()`, so the two builders now differ only in their fixed input ordering and GQA's `kv_cache_bit_width`. ### 2. `attention_metadata` input `PagedAttention::ComputeInternal` previously copied the sequence-length tensors device→host and called `cudaStreamSynchronize` to obtain `max_query_len`/`max_kv_len` — **once per node, per step**, i.e. 24 pipeline stalls per token on a 24-layer model. The new optional CPU input (`int32[2]`, index 16) carries *upper bounds* for those values, letting the kernel skip the readback. It only ever predicts the backend selection; it never overrides it. - `builders/base.py` — emits it as a graph input (static shape `[2]`, int32), wires it to input 16, lists it in `genai_config.json`. - `src/config.{h,cpp}` — `Defaults::AttentionMetadataName` and `Decoder::Inputs::attention_metadata`. - `varlen_decoder_io.cpp` — `PrepareAttentionMetadata()` fills it each step from sequence lengths the engine already holds on the host. It no-ops when `!session_info_.HasInput(...)`, so **older models still load**. | batch | without metadata | with metadata | gain | |---|---|---|---| | 1 | 242.65 tok/s | 263.75 tok/s | **+8.7%** | | 8 | 796.09 tok/s | 811.50 tok/s | +1.9% | | 32 | 2635.67 tok/s | 2681.96 tok/s | +1.8% | TTFT unchanged. The gain is inversely proportional to batch size, as expected for a fixed per-step host cost. ### 3. CUDA graph capture in the continuous-batching engine An nsys trace of batch-1 decode showed ~2.4 ms of GPU work against a 3.8 ms wall clock — roughly **37% GPU idle**, spent launching ~170 kernels per step. Change 2 is a prerequisite here: ORT captures with `cudaStreamCaptureModeGlobal`, so no sync API may run during capture. - **`VarlenGraphBuffers`** (`varlen_decoder_io.{h,cpp}`) — persistent `input_ids`, `cumulative_sequence_lengths`, `past_sequence_lengths` and `logits` tensors sized for `max_batch_size`, owned by `SimpleDecoder` so their addresses outlive the per-step IO. A step views a smaller prefix. - **Static device block table** (`paged_key_value_cache.{h,cpp}`) — the block table used to be a pageable CPU `OrtValue`, which cannot be H2D-copied during capture. Under capture it becomes a persistent device tensor of `[max_batch_size, max_block_table_columns]`. - **Shape stability via bucketing** — column counts round *up to a power of two* (min 8) and clamp to the reserved capacity, so a long decode touches only a handful of distinct shapes instead of re-capturing every time a block is appended. - **Capture policy** (`simple_decoder.cpp`) — only pure-decode steps that fit the buffers are captured; prefill and mixed steps run eagerly with `gpu_graph_id = -1`. `GraphId = batch_size * 64 + column_bucket_exponent + 1`. Because `ComputeInternal` never re-runs on replay, `attention_metadata` must carry replay-wide bounds; the engine writes `[1, block_table_columns * block_size]`. | config | graphs OFF | graphs ON | gain | |---|---|---|---| | b=1, p=128, n=256 | 263.68 tok/s | **305.68 tok/s** | **+15.9%** | | b=8, p=128, n=256 | 810.15 tok/s | 870.54 tok/s | +7.5% | | b=32, p=128, n=256 | 2697.87 tok/s | 2879.20 tok/s | +6.7% | | b=2, p=4096, n=256 | 390.10 tok/s | 435.02 tok/s | +11.5% | TTFT unchanged — prefill is never captured. fp8-KV reaches 318.96 tok/s at b=1. ### 4. Fix: the paged cache manager double-counted every prefill Pinning `num_blocks` to exactly `ceil(max_length / block_size)` made every paged request fail on its first step with `Cannot append tokens to request that is not ready.` `BlockPool::AllocateBlocks()` constructs blocks with their slots *already marked used*, and `PagedKeyValueCache::Add()` called it with the full prefill. The first `Step()` then ran `AppendTokens()` over those same still-unprocessed tokens and allocated a **second complete set of blocks**. A 4,079-token prefill at `block_size = 256` consumed **32 blocks instead of 16**. The fix makes `AppendTokens()` the single place slots become used: - `BlockPool::ReserveBlocks()` (new) allocates blocks with `size_ = 0`; `AllocateBlocks()` keeps its fill-on-allocate behaviour and both share one implementation behind a `mark_slots_used` flag. - `Add()` reserves capacity without consuming it — still enough to stop a second request admitted in the same batch from stealing the blocks. - `AppendTokens()` walks *all* of the request's blocks filling empty slots before asking the pool for more, instead of only `blocks.back()`. - `CanAppendTokens()` counts every empty slot the request owns (new `EmptySlots()` helper) and takes the block size from `BlockPool::BlockSize()` rather than `blocks.back()->Capacity()`, which was also UB for an empty block list. - `CanAdd()` uses `>=` rather than `>`, so a pool sized exactly for one request admits it. Block ordering, and therefore the physical KV layout, is unchanged. A given KV pool now supports twice the context/concurrency it did, at no throughput cost: | batch | before | after | |---|---|---| | 1 | 375.60 tok/s | 374.97 | | 8 | 1679.86 | 1690.98 | | 32 | 4097.30 | 4127.86 | ## Experiment Results Measured on 8×H200 141 GB (sm_90), CUDA 13.0, ORT 1.29.0. ### Cumulative decode throughput — gpt-oss-20b, int8 per-channel KV, prompt 128 / new 256 | batch | baseline | + `attention_metadata` | + CUDA graphs | total | |---|---|---|---|---| | 1 | 242.7 | 263.8 | **305.7** | **+26.0%** | | 8 | 796.1 | 811.5 | **870.5** | +9.3% | | 32 | 2635.7 | 2682.0 | **2879.2** | +9.2% | Attention is no longer the bottleneck at these sizes — the 24 mxfp4 `QMoE` GEMMs (~1.5 ms/step) and 49 `MatMulNBits` nodes dominate, against ~0.2 ms for all 24 attention nodes. ### GQA vs PagedAttention on a matched RC11f model pair Two models built from the *identical* recipe (int4 body, int4 QMoE, int8 per-channel KV, same scale file); only the attention operator differs. `model.onnx.data` is byte-for-byte the same size in both. | | result | |---|---| | **Accuracy** | GPQA-diamond 0.5404 (GQA) vs 0.6212 (paged). **No evidence of degradation.** The +8.1 pp is one draw with SE ≈ 3.3 pp — do not read it as a benefit. | | **Determinism** | 0/198 discordance on replicates of *both* models; generation is bit-reproducible. | | **Memory** | Identical to within 24 MiB (0.16%) at matched KV capacity from 16k to 128k `max_length`. Paged costs nothing extra; its advantage is structural (shared pool vs `batch × max_length` reservation). | | **Decode TPS** | b=1 **+1.5%**, b=8 −6.1%, b=32 **−22.9%** (see below). | | config | GQA tok/s | paged tok/s | delta | |---|---|---|---| | b=1, p=128, n=256 | 370.0 | **375.6** | **+1.5%** | | b=8, p=128, n=256 | 1788.8 | 1679.9 | −6.1% | | b=32, p=128, n=256 | 5313.3 | 4097.3 | **−22.9%** | | b=2, p=4096, n=256 | 690.4 | 680.6 | −1.4% | **Known gap:** PagedAttention is throughput-neutral at batch 1 and long context but loses ground as the batch grows. TTFT is 2–4% higher everywhere, which is the block-table setup and `ExpandBlockTableToPages` running per node per step. Hoisting that out of the per-step path is the next profiling target; the b=32 regression should be closed before proposing paged as a drop-in replacement for batch serving. ## Testing Notes ```bash # Builder unit tests python3 -m pytest test/python/builder -q # 299 passed, 3 skipped lintrunner -a # clean ``` End-to-end validation on gpt-oss-20b (8×H200): - **Bit-exactness:** decode output is byte-identical with CUDA graphs off and on. - **Long context:** needle-in-a-haystack passes at 6,093 / 8,093 / 8,153 prompt tokens with graphs on. The 8,093 case crosses the 32→64 column bucket mid-decode, exercising mid-run re-capture. - **Replay actually happens:** nsys over 94 decode steps shows 1 × `cudaStreamBeginCapture`, 1 × `cudaGraphInstantiate`, **94 × `cudaGraphLaunch`**. - **Block accounting:** with `block_size = 256`, `num_blocks = 16` now serves a 4,079-token prefill plus 16 generated tokens (4,095 tokens in 4,096 slots — exactly full); `num_blocks = 15` is cleanly rejected. - **Backward compatibility:** models built before this change have no `attention_metadata` input; `PrepareAttentionMetadata()` no-ops and they load and run unchanged. To reproduce the builder side: ```bash python -m models.builder -m openai/gpt-oss-20b -o $OUT -p int4 -e cuda \ --extra_options use_paged_attention=true paged_block_size=256 enable_cuda_graph=true \ kv_cache_quant_type=int8_per_channel kv_cache_scale_file=path_to_scales.json ```
The paged decode kernels use FLT_MAX as the softmax running-max sentinel but relied on it arriving transitively. The TensorRT CI build prunes enough of the included headers (ORT_QUICK_BUILD, EXCLUDE_SM_*) that the transitive path disappears, so nvcc reports "identifier FLT_MAX is undefined". Include the header directly.
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 33 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
onnxruntime/python/tools/symbolic_shape_infer.py:2572
- In
_infer_PagedAttention, the packed-QKV divisor uses(kv_num_heads or 0). If the node is malformed andkv_num_headsis missing/None, this makes the divisornum_heads, so shape inference can incorrectly derive ahead_sizeand emit an invalid output width instead of falling back to generic propagation (contradicting the comment about missing attributes). Guard the packed-QKV path sokv_num_headsmust be present/positive before computing the divisor.
tianleiwu
requested review from
apsonawane,
baijumeswani,
hariharans29 and
kunal-vaishnavi
August 1, 2026 21:42
hariharans29
approved these changes
Aug 5, 2026
3 tasks
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
PagedAttentionis ORT's continuous-batching attention operator, but onmainit only supportsFP16/BF16 caches with RoPE and softcap, has no paged decode kernel, and forces a device→host
synchronization on every node on every step (which makes it uncapturable by CUDA graphs). This PR
brings it to feature parity with
GroupQueryAttentionfor the popular LLM families and adds thepaging and latent-cache primitives that serving frameworks need, additively — every model valid
under the shipped
com.microsoft::PagedAttentionopset-1 schema keeps working unchanged.The design rationale, the compatibility invariant, and the alternatives that were considered and
rejected are written up in the new design document
docs/contrib_ops/cuda/paged_attention.md; the sectionnumbers referenced below point into it.
Summary of Changes
Schema (
bert_defs.cc,docs/ContribOperators.md)All additions are trailing optional inputs, new attributes whose defaults reproduce current
behavior, or widened type constraints (§4).
slot_mappinghead_sinkq_norm_weight/k_norm_weightk_scale/v_scaleattention_metadata[max_query_len, max_kv_len], which removes the per-node per-step D→H sync (§4.7)qk_norm_epsilon1e-6k_quant_type/v_quant_typeNONENONE|PER_TENSOR|PER_CHANNELk_cache_dtype/v_cache_dtype""kv_cache_layoutSEPARATESEPARATE|LATENT(absorbed MLA: one cache, novalue/value_cache)v_head_size0LATENTonly (DeepSeek-V3 uses 576/512)rotary_offset0[rotary_offset, rotary_offset + rotary_dim)so MLA can rotate only the positional suffixkey_cache/value_cachemove fromTto a newT_CACHEconstraint (float16,bfloat16,int8,float8e4m3fn), andvalue_cache/value_cache_outbecome optional so aLATENTnode canomit them. Shape inference now takes the cache element type from inputs 3/4 rather than from
query,which was wrong for a quantized cache.
CUDA kernels (
paged_attention_impl.cu,paged_attention.cc/.h,paged_attention_helper.h)LaunchPagedDecodeAttention) — split-KV, block-table-aware decode withnative head-sink, softcap, sliding-window and on-the-fly cache dequantization.
onnxruntime/contrib_ops/cuda/bert/xqa/) — TensorRT-LLM's XQA kernelsextended to the paged block layout: 8 new translation units
xqa_paged_{fp16,bf16}_{int8,fp8}_{64,128}.cuplus a shared paged loader. Selected forquantized-cache decode.
ReshapeAndCachequantizes on write; all read paths dequantize withk_scale/v_scaleunderPER_TENSORorPER_CHANNELgranularity.ApplyHeadSink— exact post-hoc LSE rescale (1/(1+exp(s_h − lse))) applied after thequantized/unquantized branch, so no backend can silently drop the sink (§6).
QkNormRotaryTNH— fuses QK-RMSNorm, RoPE (withrotary_offset) and the packed-QKV unpackinto one pass.
PagedLatentAttentionKernel/LatentAttention) — single latent cache, V readas the leading
v_head_sizechannels of the same row that supplies K (§12).shapes and the
block_table.shape[1] * block_sizecapacity bound; per-step quantities are read ondevice. The unconditional
cudaStreamSynchronizeis gone from the capturable path (§4.7).int8→fp16conversion fast path (xqa/utils.cuh,cvtS8x4ToF16x4) — replaces a scalarI2Floop with aprmt+sub.f16x2sequence (5 full-rate instructions per 4 elements, bitidentical). Shared with the non-paged GQA loader.
<T, T_CACHE>-typed; FP8 combinations are behindUSE_FP8_KV_CACHE && !DISABLE_FLOAT8_TYPES.GQA bug fix (
flash_api.{h,cc},group_query_attention_impl.cu)mha_fwdhadconstexpr void* head_sink = nullptr;hardcoded inside it, andFlashAttentionAndQuantizeKV— the only GQA prompt path taken when the KV cache is quantized —called it. So for gpt-oss with an INT8/FP8 KV cache, the attention sinks were silently dropped for
the entire prompt on every layer while decode stayed correct.
mha_fwdnow takeshead_sinkandGQA forwards it.
Op-level prefill error drops 0.074829 → 0.000122. On gpt-oss-20b (int4 body, INT8 per-channel
KV), MMLU-Pro-800 goes 0.6175 (494/800) → 0.7200 (576/800). Existing CI missed this because
atol["int8_fp16"] = 1e-1intest_gqa.pyis ~800× wider than the post-fix error.Tooling and docs
symbolic_shape_infer.py: correct output width for packed-QKV andLATENTnodes, and cacheoutputs typed from the cache inputs.
docs/contrib_ops/cuda/paged_attention.mddesign document; regeneratedContribOperators.mdand
OperatorKernels.md.Testing
test_paged_attention_cuda.pygrows from a smoke test to ~2k lines / 198 cases, with new suites forfeatures (
slot_mapping, head sink, QK-Norm), quantized cache (int8/fp8 × per-tensor/per-channel),the paged decode kernel, the XQA decode path,
attention_metadata, and MLA — each against a PyTorchreference.
The GQA suite is included because the int8 conversion fast path is shared with the non-paged loader.
Backward compatibility. A node with none of the new inputs/attributes takes exactly the code path
it does today:
T_CACHE == T,value_cachepresent,kv_cache_layout == SEPARATE, all quantizationNONE. The compatibility invariant is stated normatively in §4.2.Experimental Results
Measured on gpt-oss-20b, H200. E2E numbers are driven through onnxruntime-genai; the CUDA-graph and
engine-side plumbing they depend on is not part of this PR — they are included to show what the
operator-side changes enable, not as a claim about this diff alone.
Paged decode kernel (isolated,
nh=64 / kvh=8 / hs=64 / block=256)XQA on/off at
b=8, ctx=4096, per decode call:PER_TENSORPER_CHANNELPER_TENSORPER_CHANNELBefore XQA the quantized paths were ~2.5× slower than fp16 — the generic kernel was the bottleneck,
not the KV bytes.
The
cvtS8x4ToF16x4conversion path then closes the residual int8-vs-fp8 gap (nsys median, SASS goesfrom 3928 to 3592 instructions with 192 → 0
I2F):The int8/fp8 gap goes from up to +59% down to ≤ 7.6% (int8 is faster at the largest config), so the
two cache formats can now be chosen on accuracy grounds.
End-to-end decode throughput (mxfp4 body, INT8 KV, prompt 128 / new 256)
attention_metadata(no sync)Including XQA, batch-1 int8 decode goes 199.2 → 305.7 tok/s (+53.5%). After this work attention
is no longer the bottleneck at b=1 — the MoE GEMMs and 49
MatMulNBitsnodes dominate the step, withXQA at 24 × 8.6 µs.
PagedAttention vs GroupQueryAttention, matched models
Two models built from the identical recipe (int4 body, INT8 per-channel KV, identical
num_heads/kv_num_heads/scale/window/rotary, byte-identical weight file), differing only in theattention operator. Greedy generation on this stack is bit-reproducible (0/198 discordance across
replicates), so there is no sampling noise to subtract.
The two benchmarks disagree in direction and both deltas are 3 questions: equivalent within noise.
(An apparent +8 pp advantage for paged in earlier runs turned out to be the GQA sink bug fixed above,
seen from the other side.)
Peak device memory at matched KV capacity agrees to within 24 MiB (0.16%) from 16k to 128k
max_length— paged costs nothing extra, and its advantage is structural (a shared pool sized toaggregate demand rather than
batch × max_length).The b=32 gap was profiled with
nsys --cuda-graph-trace=node: the captured graph body is at paritywith GQA's eager model pass (6.220 ms vs ~6.2 ms) and the entire regression is a 2.384 ms
search/sampling tail, which the onnxruntime-genai
Engineruns once per request rather than onceper batch. It is not attributable to this operator, and a partial engine-side fix already recovers
b=32 to 4517 tok/s.
Follow-ups (not in this PR)
attention_biasandoutput_qk(§10, §11) — schema slots reserved, kernels deferred.int4/float4e2m1) packed caches — attribute vocabulary reserved and rejected atvalidation until a backend exists (§21.4).
.Alias(3, 1).Alias(4, 2)on the kernel def, so a non-aliasing allocation plan fails at partitiontime instead of run time (§4.4).
atol["int8_fp16"]intest_gqa.pynow that the sink bug is fixed.