Skip to content

[CUDA] PagedAttention: quantized KV cache, XQA decode, MLA, QK-Norm and head sink - #29912

Merged
Tianlei Wu (tianleiwu) merged 24 commits into
mainfrom
tlwu/20260727/paged_att_design
Aug 5, 2026
Merged

[CUDA] PagedAttention: quantized KV cache, XQA decode, MLA, QK-Norm and head sink#29912
Tianlei Wu (tianleiwu) merged 24 commits into
mainfrom
tlwu/20260727/paged_att_design

Conversation

@tianleiwu

@tianleiwu Tianlei Wu (tianleiwu) commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Description

PagedAttention is ORT's continuous-batching attention operator, but on main it only supports
FP16/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 GroupQueryAttention for the popular LLM families and adds the
paging and latent-cache primitives that serving frameworks need, additively — every model valid
under the shipped com.microsoft::PagedAttention opset-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 section
numbers 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).

New input Idx Purpose
slot_mapping 10 Explicit per-token cache slot, so the scheduler owns placement instead of the kernel re-deriving it (§5)
head_sink 11 Attention sink / smooth softmax, matching GQA (§6)
q_norm_weight / k_norm_weight 12, 13 Fused QK-RMSNorm (Qwen3, gpt-oss) (§7)
k_scale / v_scale 14, 15 Per-tensor or per-channel dequantization scales for a quantized cache (§8)
attention_metadata 16 Optional CPU input carrying replay-wide upper bounds [max_query_len, max_kv_len], which removes the per-node per-step D→H sync (§4.7)
New attribute Default Purpose
qk_norm_epsilon 1e-6 Epsilon for the fused QK-Norm
k_quant_type / v_quant_type NONE NONE | PER_TENSOR | PER_CHANNEL
k_cache_dtype / v_cache_dtype "" Logical cache element type, named after the ONNX type it denotes
kv_cache_layout SEPARATE SEPARATE | LATENT (absorbed MLA: one cache, no value/value_cache)
v_head_size 0 Narrower V head, LATENT only (DeepSeek-V3 uses 576/512)
rotary_offset 0 Applies RoPE to [rotary_offset, rotary_offset + rotary_dim) so MLA can rotate only the positional suffix

key_cache / value_cache move from T to a new T_CACHE constraint (float16, bfloat16,
int8, float8e4m3fn), and value_cache / value_cache_out become optional so a LATENT node can
omit 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)

  • Paged decode kernel (LaunchPagedDecodeAttention) — split-KV, block-table-aware decode with
    native head-sink, softcap, sliding-window and on-the-fly cache dequantization.
  • XQA paged decode (onnxruntime/contrib_ops/cuda/bert/xqa/) — TensorRT-LLM's XQA kernels
    extended to the paged block layout: 8 new translation units
    xqa_paged_{fp16,bf16}_{int8,fp8}_{64,128}.cu plus a shared paged loader. Selected for
    quantized-cache decode.
  • Quantized paged cacheReshapeAndCache quantizes on write; all read paths dequantize with
    k_scale/v_scale under PER_TENSOR or PER_CHANNEL granularity.
  • ApplyHeadSink — exact post-hoc LSE rescale (1/(1+exp(s_h − lse))) applied after the
    quantized/unquantized branch, so no backend can silently drop the sink (§6).
  • QkNormRotaryTNH — fuses QK-RMSNorm, RoPE (with rotary_offset) and the packed-QKV unpack
    into one pass.
  • Absorbed MLA (PagedLatentAttentionKernel / LatentAttention) — single latent cache, V read
    as the leading v_head_size channels of the same row that supplies K (§12).
  • CUDA-graph safety — backend dispatch, grid sizing and workspace extents now come from static
    shapes and the block_table.shape[1] * block_size capacity bound; per-step quantities are read on
    device. The unconditional cudaStreamSynchronize is gone from the capturable path (§4.7).
  • int8fp16 conversion fast path (xqa/utils.cuh, cvtS8x4ToF16x4) — replaces a scalar
    I2F loop with a prmt + sub.f16x2 sequence (5 full-rate instructions per 4 elements, bit
    identical). Shared with the non-paged GQA loader.
  • Kernel registration is now <T, T_CACHE>-typed; FP8 combinations are behind
    USE_FP8_KV_CACHE && !DISABLE_FLOAT8_TYPES.

GQA bug fix (flash_api.{h,cc}, group_query_attention_impl.cu)

mha_fwd had constexpr void* head_sink = nullptr; hardcoded inside it, and
FlashAttentionAndQuantizeKV — 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_fwd now takes head_sink and
GQA 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-1 in test_gqa.py is ~800× wider than the post-fix error.

Tooling and docs

  • symbolic_shape_infer.py: correct output width for packed-QKV and LATENT nodes, and cache
    outputs typed from the cache inputs.
  • New docs/contrib_ops/cuda/paged_attention.md design document; regenerated ContribOperators.md
    and OperatorKernels.md.

Testing

test_paged_attention_cuda.py grows from a smoke test to ~2k lines / 198 cases, with new suites for
features (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 PyTorch
reference.

python onnxruntime/test/python/transformers/test_paged_attention_cuda.py   # 198 passed
python onnxruntime/test/python/transformers/test_gqa.py -k xqa             # 714 passed

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_cache present, kv_cache_layout == SEPARATE, all quantization
NONE. 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:

cache before after
int8 PER_TENSOR 2315 µs 122 µs
int8 PER_CHANNEL 2339 µs 128 µs
fp8 PER_TENSOR 1633 µs 57 µs
fp8 PER_CHANNEL 1566 µs 57 µs

Before XQA the quantized paths were ~2.5× slower than fp16 — the generic kernel was the bottleneck,
not the KV bytes.

The cvtS8x4ToF16x4 conversion path then closes the residual int8-vs-fp8 gap (nsys median, SASS goes
from 3928 to 3592 instructions with 192 → 0 I2F):

ctx batch int8 before int8 after gain fp8
1024 32 19.91 µs 13.60 µs −31.7% 12.64 µs
4096 8 24.32 µs 17.41 µs −28.4% 16.48 µs
4096 32 66.62 µs 42.66 µs −36.0% 41.98 µs
16384 8 78.11 µs 52.58 µs −32.7% 49.18 µs
16384 32 244.71 µs 162.27 µs −33.7% 176.70 µs

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)

batch baseline + attention_metadata (no sync) + CUDA graphs total
1 242.7 tok/s 263.8 305.7 +26.0%
8 796.1 811.5 870.5 +9.3%
32 2635.7 2682.0 2879.2 +9.2%

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 MatMulNBits nodes dominate the step, with
XQA 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 the
attention operator. Greedy generation on this stack is bit-reproducible (0/198 discordance across
replicates), so there is no sampling noise to subtract.

GQA PagedAttention read as
MMLU-Pro-800 0.7200 (576/800) 0.7163 (573/800) +0.4 pp, 3 questions
GPQA-diamond 0.6061 (120/198) 0.6212 (123/198) −1.5 pp, 3 questions

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.)

config GQA tok/s paged tok/s delta
b=1, p=128, n=256 374.4 376.3 +0.5%
b=2, p=4096, n=256 684.3 686.6 +0.3%
b=8, p=128, n=256 1792.7 1683.8 −6.1%
b=32, p=128, n=256 5285.9 4109.5 −22.3%

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 to
aggregate demand rather than batch × max_length).

The b=32 gap was profiled with nsys --cuda-graph-trace=node: the captured graph body is at parity
with 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 Engine runs once per request rather than once
per 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_bias and output_qk (§10, §11) — schema slots reserved, kernels deferred.
  • Sub-byte (int4 / float4e2m1) packed caches — attribute vocabulary reserved and rejected at
    validation until a backend exists (§21.4).
  • .Alias(3, 1).Alias(4, 2) on the kernel def, so a non-aliasing allocation plan fails at partition
    time instead of run time (§4.4).
  • Re-tightening atol["int8_fp16"] in test_gqa.py now that the sink bug is fixed.

Comment thread docs/contrib_ops/cuda/paged_attention.md Outdated
Comment thread docs/contrib_ops/cuda/paged_attention.md Outdated
Comment thread docs/contrib_ops/cuda/paged_attention.md Outdated
Comment thread docs/contrib_ops/cuda/paged_attention.md Outdated
@tianleiwu Tianlei Wu (tianleiwu) changed the title PagedAttention Design [CUDA] Extend PagedAttention to support Quantized KV Cache, QK Norm, Head Sink and Slot Mapping Jul 28, 2026
@tianleiwu
Tianlei Wu (tianleiwu) marked this pull request as draft July 28, 2026 17:37
@tianleiwu Tianlei Wu (tianleiwu) changed the title [CUDA] Extend PagedAttention to support Quantized KV Cache, QK Norm, Head Sink and Slot Mapping [CUDA] Extend PagedAttention to support MLA, Quantized KV Cache, QK Norm, Head Sink and Slot Mapping Jul 28, 2026
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PagedAttention op schema + type/shape inference to support quantized caches (T_CACHE + scales), optional/conditional cache/value IO for kv_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.

Comment thread onnxruntime/python/tools/symbolic_shape_infer.py Outdated
Comment thread onnxruntime/python/tools/symbolic_shape_infer.py
Comment thread onnxruntime/core/graph/contrib_ops/bert_defs.cc
Comment thread docs/ContribOperators.md
Hariharan Seshadri (hariharans29) added a commit that referenced this pull request Aug 21, 2026
## [WebGPU] PagedAttention: direct paged decode, fused paged prefill,
Unpack/Repack skip (Phase 2 partial)

This PR is the Phase 2 follow-up to #31611. It replaces the "always
gather + always Unpack/Repack" v1 fallback with two paged-aware
FlashAttention programs that read the paged KV cache directly, and a
fast path that lets FA consume the packed varlen Q buffer without
materializing padded BSNH scratch. Net effect: **~2× faster decode,
~1.15× faster uniform prefill, ~1.3× faster varlen prefill** on the
shape matrix below, with no regressions.

The Phase 1 gather-then-flash fallback shipped in #31611 remains intact
and still runs on adapters / configs where the paged-aware shaders can't
safely dispatch (see `Correctness invariants` below).

### What's shipped

1. **Direct paged split-reduce decode.** `FlashAttentionPagedDecodeQKV`
+ `FlashAttentionPagedDecodeVxReduce` index `key_cache` / `value_cache`
directly through `block_table`. Selected when `max_seqlen_q < 32` —
mirrors the dense-FA split-reduce threshold. Eliminates the dense K/V
scratch and its gather bandwidth for every decode step.

2. **Fused paged prefill.** `FlashAttentionPagedPrefillProgram` is a
straight port of the dense-FA prefill shader's shared-memory path with
page-table-aware K/V tile loads
(`bert/flash_attention_paged_prefill.wgsl.template`). Supports fp16,
BSNH Q, packed varlen Q (`q_varlen` template variant), and
variable-Q-length causal masking via `seqlen_k` + `seqlens_q`. No
attention_bias / head_sink / TurboQuant.

3. **Unpack/Repack skip fast paths.** When direct paged attention runs,
we can hand FA a rank-4 view over the raw packed Q buffer instead of
allocating padded BSNH scratch:
- **Uniform mode** (`B * max_seqlen_q == token_count`): view is `[B,
max_seqlen_q, N, H]`. Covers decode, `B==1` prefill, and equal-length
batched prefill (the common continuous-batching case).
- **Varlen mode**: view is `[token_count, 1, N, H]` plus
`cumulative_seqlens_q`; only the fused paged-prefill shader can index it
(`q_varlen`).

Skipping Unpack+Repack removes 2 dispatches (~300–500 µs of CPU dispatch
cost per Run on D3D12) plus a `B * max_seqlen_q * hidden * 2 B` scratch
allocation (tens of MB at long prefill).

### Dispatch-count reduction

| Route (no rotary, non-packed) | #31611 (merged) | This PR (shm-path
adapters) |
|---|---|---|
| **Decode** (`max_seqlen_q < 32`) | Scatter + Gather + UnpackQ +
DecodeQKV + DecodeVxReduce + Repack = **6** | Scatter + PagedDecodeQKV +
PagedDecodeVxReduce = **3** |
| **Prefill** (`max_seqlen_q ≥ 32`) | Scatter + Gather + UnpackQ +
FlashAttention + Repack = **5** | Scatter + FlashAttentionPagedPrefill =
**2** |

Decode's FA is 2 kernels (split-K: QKV + VxReduce); prefill's FA is 1
kernel (`FlashAttentionProgram`). On configs where
`ShouldRunFusedPagedPrefill` rejects (fp32, `head_size > 256`,
`block_size < max_k_step`), the prefill row falls back to the 5-dispatch
#31611 cascade; decode's direct paged split-reduce path has no such
gate. Neither route is adapter-gated — the paged shaders use no subgroup
intrinsics and run on every WebGPU adapter that meets the fp16 /
shm-budget / alignment predicates.

### Correctness invariants

**Prefill selection** consults one shared predicate:

```cpp
bool ShouldRunFusedPagedPrefill(context, is_fp16, max_seqlen_q,
                                head_size, block_size);
```

It rejects (→ gather-then-flash fallback) when any of:

- `!is_fp16` — only fp16 variant is compiled today.
- `max_seqlen_q < 32` — decode uses the split-reduce programs instead.
- `head_size` exceeds the workgroup shared-memory budget (fp16:
`head_size > 256`).
- `block_size < max_k_step` — the fused shader assumes one K/V tile
lives in one paged block (one `block_table` lookup per tile).
`paged_attention_helper` only enforces `block_size >= 16` power-of-two;
e.g. `block_size=16` with fp16 `head_size<=128` (`max_k_step=32`) would
splice into a physically-adjacent block that isn't the next entry in the
table.

The fused paged-prefill shader uses only workgroup shared memory (no
subgroup intrinsics), so there is no adapter-class gate — subgroup
adapters (Qualcomm / AMD / Intel with subgroups) take the paged shm
kernel directly instead of falling back to gather + dense-FA-subgroup.

Because the same predicate gates the "skip `RunGatherKV`", "skip
`q_padded` scratch", and "select fused shader" decisions, the three
cannot drift.

**Decode selection** (`max_seqlen_q < 32`) is a pure shape check — no
adapter, dtype, or block-size gate. The direct paged split-reduce
kernels (`FlashAttentionPagedDecodeQKV` +
`FlashAttentionPagedDecodeVxReduce`) are the sole decode path when the
kernel dispatches at all (fp16 is enforced at kernel registration, so no
fp32 fallback is possible). Unlike fused prefill, the decode kernels do
one `block_table` lookup per K/V slot rather than per tile, so they have
no `block_size` alignment requirement.

**WGSL correctness gotcha handled** in the fused prefill shader.
`cumulative_seqlens_q` is `array<i32>` but row indices are `u32`. Both
`loadq` and `writeo` explicitly cast (`u32(cumulative_seqlens_q[b]) +
q_idx`); without the cast, tint surfaces the type-resolution failure as
an opaque `absl::…raw_hash_map<>::at` at runtime.

### Performance

Machine: dev-box discrete WebGPU adapter (D3D12), 24-core host. Google
Benchmark harness at
`onnxruntime/test/onnx/microbenchmark/paged_attention.cc`,
`--benchmark_min_time=0.3s`, wall-clock timing via `UseManualTime()`.

Earlier revisions of this PR included an
`ORT_WEBGPU_PAGED_ATTENTION_USE_FUSED` env-var kill switch used for A/B
measurement against the #31611 cascade. That toggle has been removed
(the direct/fused paths are selected internally by shape and config; the
numbers below are the reason). The A/B was performed by temporarily
broadening the toggle locally to also force
`use_direct_paged_decode=false` and `skip_unpack_repack=false`, so
fused=0 exercised the exact gather-then-flash cascade shipped in #31611.
All numbers below are with that broadened toggle; the broadening was
reverted before final push.

Column meanings: **nH** = num query heads, **nKV** = num KV heads, **H**
= head dim. Shape families:

- MHA_H64  (nH=16, nKV=16, H=64), MHA_H128 (nH=16, nKV=16, H=128)
- GQA_Qwen (nH=14, nKV=2,  H=128), GQA_Llama (nH=32, nKV=4, H=128)

#### Decode (16 shapes)

| Shape (B/nH/nKV/H/past) | this PR (µs) | #31611 (µs) | Speedup |
|---|---:|---:|---:|
| 1/16/16/128/2048 | 669  | 3612 | **5.40×** |
| 2/16/16/128/512  | 627  | 3248 | **5.18×** |
| 1/16/16/64/2048  | 861  | 3835 | **4.45×** |
| 2/16/16/64/2048  | 907  | 3617 | **3.99×** |
| 2/16/16/64/512   | 603  | 1369 | 2.27× |
| 2/16/16/128/2048 | 2129 | 4816 | 2.26× |
| 1/16/16/128/512  | 607  | 1150 | 1.89× |
| 2/32/4/128/2048  | 1590 | 3010 | 1.89× |
| 1/14/2/128/2048  | 979  | 1601 | 1.64× |
| 2/32/4/128/512   | 656  | 990  | 1.51× |
| 1/16/16/64/512   | 576  | 843  | 1.46× |
| 2/14/2/128/512   | 694  | 984  | 1.42× |
| 1/14/2/128/512   | 600  | 757  | 1.26× |
| 2/14/2/128/2048  | 970  | 1194 | 1.23× |
| 1/32/4/128/512   | 643  | 751  | 1.17× |
| 1/32/4/128/2048  | 1434 | 1437 | 1.00× |

Range **1.00×–5.40×**, geomean ~2.0×. Biggest wins on long-past MHA
(H=128, past=2048) where gather bandwidth dominated. The one 1.00× row
is a small-K/V-cache GQA case where gather cost was already low.

#### Uniform prefill (24 shapes)

Range **1.01×–1.25×**, geomean ~1.13×. Highlights (all wins):

| Shape (B/nH/nKV/H/T) | this PR (µs) | #31611 (µs) | Speedup |
|---|---:|---:|---:|
| 1/32/4/128/128  | 1225  | 1530  | **1.25×** |
| 2/14/2/128/128  | 1111  | 1385  | **1.25×** |
| 2/16/16/64/128  | 766   | 948   | 1.24× |
| 2/32/4/128/512  | 9723  | 12054 | 1.24× |
| 1/16/16/128/128 | 856   | 1051  | 1.23× |
| 2/16/16/128/1024| 17296 | 21029 | 1.22× |
| 1/14/2/128/128  | 718   | 877   | 1.22× |
| 2/32/4/128/128  | 1640  | 1966  | 1.20× |
| 1/16/16/64/128  | 750   | 891   | 1.19× |
| 2/16/16/128/128 | 1294  | 1491  | 1.15× |

*(14 more rows 1.01×–1.15×; full log in tree.)*

Short-T shapes gain most from Unpack/Repack skip; long-T shapes are
dominated by FA compute time.

#### Varlen prefill (12 shapes, halving q_lens = `{max_T, max_T/2,
max_T/4, …}`)

Range **1.14×–1.73×**, geomean ~1.29×.

| Shape (B/nH/nKV/H/maxT) | q_lens | this PR (µs) | #31611 (µs) |
Speedup |
|---|---|---:|---:|---:|
| 4/16/16/128/512  | `{512,256,128,64}`   | 5987  | 10375 | **1.73×** |
| 4/14/2/128/512   | `{512,256,128,64}`   | 5312  | 7815  | **1.47×** |
| 4/32/4/128/512   | `{512,256,128,64}`   | 11165 | 15427 | **1.38×** |
| 4/16/16/128/1024 | `{1024,512,256,128}` | 20127 | 27661 | **1.37×** |
| 2/32/4/128/512   | `{512,256}`          | 8345  | 10732 | 1.29× |
| 2/16/16/128/1024 | `{1024,512}`         | 15121 | 19084 | 1.26× |
| 4/14/2/128/1024  | `{1024,512,256,128}` | 17685 | 21562 | 1.22× |
| 4/32/4/128/1024  | `{1024,512,256,128}` | 39571 | 47468 | 1.20× |
| 2/14/2/128/1024  | `{1024,512}`         | 13141 | 15430 | 1.17× |
| 2/16/16/128/512  | `{512,256}`          | 4528  | 5235  | 1.16× |
| 2/32/4/128/1024  | `{1024,512}`         | 28871 | 33383 | 1.16× |
| 2/14/2/128/512   | `{512,256}`          | 4067  | 4645  | 1.14× |

Wins grow with batch size — bigger B means more of the padded-BSNH
round-trip gets eliminated (B=4/maxT=512 packs only 46.9% of `B·maxT`
tokens; the padded scratch #31611 allocates is >2× bigger than the
actual data).

### Tests

- `onnxruntime/test/contrib_ops/paged_attention_op_test.cc`
`PagedAttention.EndToEnd_*` — 12/12 non-CUDA tests pass. Covers MHA,
GQA, single/multi-batch, variable past lengths, empty tokens, packed
QKV, rotary, mixed prefill+decode, cache aliasing via IO-binding. New:
`EndToEnd_Prefill_MultiBatch_Varlen_Fused` (B=2, token_count=48 with
q_lens (32,16), head_size=128, MHA) — regression test for the fused
varlen prefill path.
- Micro-benchmark harness
`onnxruntime/test/onnx/microbenchmark/paged_attention.cc` — 52
registered shapes (16 decode + 24 uniform prefill + 12 varlen prefill).

### Related

- Phase 1 (v1 fallback): #31611 (merged)
- Schema extensions: #29912 (merged)
- Design doc: `docs/design/webgpu_paged_attention.md` (updated in this
PR)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tianlei Wu <tlwu@microsoft.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants