[WebGPU] Initial PagedAttention implementation (1/n) - #31611
Conversation
Registers a NOT_IMPLEMENTED PagedAttention kernel for the WebGPU EP and lands the design doc describing the phased delivery plan. Follow-up PRs will implement the K/V writer, decode, and gather-then-flash prefill paths.
The helper is pure host code with no CUDA dependencies. Move it to contrib_ops/cpu/bert/ so it can be shared by other execution providers (CPU, WebGPU) without an EP-scope-violating include across contrib_ops/cuda/.
Replace the Phase 0 unconditional NOT_IMPLEMENTED with the full ComputeInternal control flow, minus the actual kernel launches: - Fetch all 10 inputs and route them through the shared paged_attention_helper::CheckInputs, populating a PagedAttentionParameters. - Populate the three non-helper fields (local_window_size, do_rotary, rotary_interleaved) from constructor state, matching the CUDA implementation. - Enforce the do_rotary => cos_cache && sin_cache invariant with a specific error. - Allocate output 0 with shape (token_count, hidden_size) and the two optional cache outputs with the paged shape (num_blocks, block_size, kv_num_heads, head_size). - Enforce the schema-declared alias between input caches and output caches at compute time via a raw-pointer equality check (matches CUDA; no Alias/MayInplace on the KernelDef for now). - Fast-path token_count == 0 to Status::OK. - Branch the final NOT_IMPLEMENTED into distinct decode-vs-prefill messages that reference the design doc phase, so failures are informative. Phase 1b (upcoming) will replace the two NOT_IMPLEMENTED tails with real WGSL kernel dispatch. See docs/design/webgpu_paged_attention.md §5.
… (Phase 1b.1) Adds the first per-program CUDA-parity kernel for the WebGPU PagedAttention op: a plain (non-packed, non-rotary) scatter of new K/V tokens into the block-based paged cache. * onnxruntime/contrib_ops/webgpu/bert/paged_attention_scatter_kv.wgsl.template: WGSL template. One invocation per (token, kv_head, dim); linear-scan cumulative_sequence_length to find seq_idx, then abs_slot = past_seqlens[seq] + local_tok, block_id = block_table[seq, abs_slot/block_size], slot = abs_slot%%block_size. * onnxruntime/contrib_ops/webgpu/bert/paged_attention.h: adds ScatterKVToPagedCacheProgram with 8 Uint32 uniforms. * onnxruntime/contrib_ops/webgpu/bert/paged_attention.cc: wires the scatter program from ComputeInternal, adds .MayInplace(3,1).MayInplace(4,2) hints on the KernelDef for the aliased GenAI fast path, and a copy-fallback for the non-aliased OpTester path (mirrors GroupQueryAttention). Output tensor is zero-filled until the attention path lands in Phase 1b.3/1b.4. * onnxruntime/test/providers/webgpu/paged_attention_test.cc: 3 gtest cases covering single-token/no-past, multi-token with past, and multi-batch/multi-head with per-sequence past lengths and non-contiguous block_table. Phase 1b.1 of docs/design/webgpu_paged_attention.md.
… (Phase 1b.2)
Adds a rotary embedding WGSL program used by the WebGPU PagedAttention op
to rotate Q and K in the non-packed layout before scattering K/V into the
paged cache. Mirrors paged_attention_impl.cu::RotaryEmbeddingTNH: same
interleaved-vs-split math, same position_id = past_seqlens[b] + s formula,
and dims >= rotary_dim are copied through unchanged.
ComputeInternal flow when do_rotary=1:
1. Rotate query into output(0) (temporary layering until 1b.3 attention
lands and overwrites output with real attention results).
2. Rotate key into a GPU temp tensor.
3. Scatter rotated key + untouched value into the paged cache via the
existing ScatterKVToPagedCacheProgram from Phase 1b.1.
Value is not rotated. Packed-QKV + rotary path still returns NOT_IMPLEMENTED
(deferred to Phase 1b.2b).
Adds 3 gtests covering full-head non-interleaved, full-head interleaved,
and rotary_dim < head_size tail pass-through with multi-batch + GQA broadcast.
All 6 WebGpuPagedAttention.* tests pass.
Adds packed-QKV support to the WebGPU PagedAttention op. When `key` and `value` are absent and the `query` input carries all three projections concatenated per token (cols `[0, Q_hidden)` = Q, `[Q_hidden, Q_hidden + KV_hidden)` = K, `[Q_hidden + KV_hidden, Q_hidden + 2*KV_hidden)` = V), a new pre-pass kernel splits the packed tensor into three standalone Q, K, V tensors. The rest of the existing 1b.2 pipeline (optional non-interleaved / interleaved rotary followed by paged-KV scatter) then runs unchanged against the split tensors. Design: split-then-reuse is intentionally conservative for the first packed-QKV cut. It costs one extra full-tensor read/write in device memory per Q/K/V column relative to a fused approach, but avoids templating every downstream kernel on a packed-input layout and keeps the CPU-side output-shape and cache-mutation reasoning identical to the non-packed path. A fused rotary+scatter+packed variant can be revisited in Phase 1c when we have baseline perf numbers. Implementation: - `PagedAttentionSplitPackedQKVProgram` (new): one WGSL kernel, one invocation per input element. Dispatch is `ceil(token_count * packed_hidden_size / WORKGROUP_SIZE)` groups. Uniforms carry `token_count`, `q_hidden_size`, `kv_hidden_size`, `packed_hidden_size`, `dispatch_size`. - `paged_attention_split_packed_qkv.wgsl.template` (new): row-major linearization of `(token, packed_col)`, branching on the column range to route each element to the correct output tensor. - `PagedAttention::ComputeInternal` (edited): when `parameters.is_packed_qkv` is true, allocate three transient GPU tensors of shapes `(token_count, hidden_size)`, `(token_count, kv_hidden_size)`, `(token_count, kv_hidden_size)`, run the split kernel, and rebind `query`/`key`/`value` locally to the split outputs before falling through to the existing rotary + scatter path. Tests: extends the WebGPU PagedAttention test harness with a `bool is_packed` field on both `ScatterCase` and `RotaryCase`, a `PackQKV` helper (per-token concatenation of the reference float buffers), and three new tests exercising the packed path: `PackedQKV_NoRotary_MultiToken_SingleBatch`, `PackedQKV_Rotary_NonInterleaved_SingleToken`, `PackedQKV_Rotary_Interleaved_MultiBatch_GQA`. All 9 `WebGpuPagedAttention.*` tests pass.
…FA seqlens_q
Wires up the WebGPU PagedAttention kernel end-to-end for
continuous-batching / variable-Q-length workloads. Replaces the earlier
Phase 1a stub / Phase 1b.1-1b.2b sub-kernel scaffolding with the
production dispatch path:
scatter K/V into paged cache
-> gather paged K/V into padded BNSH scratch (RunGatherKV)
-> unpack packed varlen Q into LEFT-aligned BSNH scratch
(RunUnpackQuery)
-> ApplyFlashAttention over padded scratch
-> repack padded output back to (token_count, hidden_size)
(RunRepackOutput)
## FlashAttention: optional seqlens_q input
The existing FA shader clamps
past_sequence_length = total_kv_b - max_seqlen_q to 0 on underflow.
That clamp is only correct for LEFT-aligned Q with past=0 (the GQA
"BatchedRightPaddedRotaryPrefill" scenario). For PagedAttention's
continuous-batching regime, past_b can be > 0 while q_len_b <
max_seqlen_q, and the clamp silently under-counts past_len_b, causing
real Q tokens to leak future KV positions through the causal mask
(observed as 85% mismatch in the s=16 packed=True test).
Introduces an optional per-batch new-Q-length input `seqlens_q` to
FA:
- `FlashAttentionProgram` / `FlashAttentionDecodeQKVProgram` gain a
`use_seqlens_q_` template-conditional gate + `seqlens_q` shader
input.
- When set, the shader computes
past_sequence_length_b = total_kv_b - seqlens_q[b] = past_len_b
which is always non-negative and correct for any (past, q_len)
combination.
- Non-PA callers (GQA / MHA / Attention) pass nullptr, leave
`use_seqlens_q_ = false`, and the shader takes the `#else` branch
that is byte-identical to the pre-patch clamp path. Zero regression
risk.
- `use_seqlens_q_` is included in the CacheHint for both programs to
avoid pipeline-cache collision.
## PagedAttention: LEFT-aligned Q layout
`RunUnpackQuery` now places real tokens at padded slots [0, q_len_b)
with padding at [q_len_b, max_seqlen_q). `RunRepackOutput` mirrors
by reading from s = local_tok directly. This matches GQA's convention
and enables the correct per-batch past_len_b via seqlens_q above.
## Test coverage
- **32 / 32** WebGPU parity configs pass in
`TestPagedAttentionWebGpu` (batch_size in {1,2}, sequence_length
in {1,4,16}, MHA + GQA, packed on/off, block_size=256). The
previously-failing test 25 (mixed q_len + past > 0) now passes.
- **5 / 5** C++ end-to-end tests
(`WebGpuPagedAttention.EndToEnd_*`), including
`EndToEnd_MixedPrefillDecode_MultiBatch_VariablePast`.
- **31 / 31** `GroupQueryAttention` WebGPU tests, including both
`BatchedRightPaddedRotaryPrefill_WebGPU` and
`BatchedRightPaddedRotaryPrefillFlashAttention_WebGPU`, unchanged
since GQA doesn't pass seqlens_q.
## Cleanup: removed transitional Phase 1b.1 / 1b.2 / 1b.2b scaffolding
- Removed `_debug_mode` schema attribute + all three mode
branches (unpack roundtrip, gather-slice verification, and
legacy output=zeros/rotated_q).
- Removed `PagedAttentionGatherVerifyProgram` + its .wgsl.template
+ `RunGatherVerify`.
- Deleted 13 transitional gtests (`ScatterOnly_*`, `Rotary_*`,
`PackedQKV_*`, `DebugMode_*`). The 5 `EndToEnd_*` tests cover the
same functionality end-to-end; Python
`TestPagedAttentionWebGpu` covers non-Linux platforms.
## Not in scope (deferred)
- `softcap != 0`: rejected with NOT_IMPLEMENTED.
- `local_window_size != -1`: rejected with NOT_IMPLEMENTED.
- `T = bfloat16`: only MLFloat16 registered.
- Graph capture (attention_metadata): documented as Phase 2 in
`docs/design/webgpu_paged_attention.md` §4.4.
- Quantized KV cache (T_CACHE), MLA / LATENT, head_sink / QK-Norm:
Phase 3 / 4 items from the design doc, tracked alongside CUDA
parity work.
## Follow-up work (later PRs)
- Rewrite C++ Rotary_* and PackedQKV_* transitional tests to
compare against an end-to-end reference so their coverage is
restored on non-Linux CI.
- Add coverage-gap tests for `block_size != 256`, empty query
(`token_count == 0`), and explicit non-default `scale`.
- Softcap + local_window_size in FlashAttentionProgram (also lifts
GQA's `CanApplyFlashAttention` bailouts).
- Wire `TestPagedAttentionWebGpu` into a WebGPU CI leg. Today the
Python parity suite runs on zero CI legs: the two WebGPU legs
(linux_webgpu.yml, windows_webgpu.yml) are build-only, and
nightly_webgpu.yml / macos-ci run `--test` but not
`--enable_transformers_tool_test`. The C++
`WebGpuPagedAttention.EndToEnd_*` gtests DO run on
nightly_webgpu (Windows A10) and macos-ci (Metal), which is
where CI protection sits today. A ~10-LOC follow-up to
nightly_webgpu.yml can add a targeted pytest step for this file.
There was a problem hiding this comment.
Pull request overview
This PR wires up an initial end-to-end WebGPU implementation of com.microsoft::PagedAttention using a gather→unpack→FlashAttention→repack fallback pipeline for continuous-batching / variable-Q-length workloads, and extends the WebGPU FlashAttention shaders with an optional seqlens_q input to correctly compute per-batch past_sequence_length for LEFT-aligned variable q_len.
Changes:
- Add WebGPU
PagedAttentionkernel implementation + WGSL programs (scatter, split packed QKV, rotary, gather KV, unpack Q, repack output) and register the kernel with the WebGPU EP. - Update WebGPU FlashAttention (prefill + decode shaders/programs) to optionally consume
seqlens_qand include it in program cache keys. - Add WebGPU paged-attention end-to-end gtests and extend Python parity tests to cover WebGPU.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| onnxruntime/test/python/transformers/test_paged_attention.py | Adds WebGPU parity suite and EP/device plumbing; pins ONNX opset to avoid exceeding ORT max opset. |
| onnxruntime/test/providers/webgpu/paged_attention_test.cc | New WebGPU end-to-end correctness gtests for PagedAttention vs CPU reference. |
| onnxruntime/contrib_ops/webgpu/webgpu_contrib_kernels.cc | Registers PagedAttention kernel for WebGPU EP. |
| onnxruntime/contrib_ops/webgpu/bert/paged_attention.h | Declares WebGPU PagedAttention kernel and supporting Program classes. |
| onnxruntime/contrib_ops/webgpu/bert/paged_attention.cc | Implements WebGPU PagedAttention pipeline and host-side length derivation for FA. |
| onnxruntime/contrib_ops/webgpu/bert/paged_attention_unpack_query.wgsl.template | Unpacks packed varlen Q to padded BSNH scratch (LEFT-aligned). |
| onnxruntime/contrib_ops/webgpu/bert/paged_attention_split_packed_qkv.wgsl.template | Splits packed QKV into separate Q/K/V tensors. |
| onnxruntime/contrib_ops/webgpu/bert/paged_attention_scatter_kv.wgsl.template | Scatters new K/V tokens into the paged KV cache. |
| onnxruntime/contrib_ops/webgpu/bert/paged_attention_rotary.wgsl.template | Applies rotary embedding to packed 2D token tensors. |
| onnxruntime/contrib_ops/webgpu/bert/paged_attention_repack_output.wgsl.template | Repacks padded BSNH output back to packed varlen output. |
| onnxruntime/contrib_ops/webgpu/bert/paged_attention_gather_kv.wgsl.template | Gathers paged KV cache to padded contiguous BNSH scratch. |
| onnxruntime/contrib_ops/webgpu/bert/flash_attention.wgsl.template | Adds optional seqlens_q path for correct causal masking under variable q_len. |
| onnxruntime/contrib_ops/webgpu/bert/flash_attention.h | Extends FA program APIs and ApplyFlashAttention signature with optional seqlens_q. |
| onnxruntime/contrib_ops/webgpu/bert/flash_attention.cc | Wires seqlens_q through program inputs + cache hints for both prefill and decode paths. |
| onnxruntime/contrib_ops/webgpu/bert/flash_attention_decode_qkv.wgsl.template | Adds optional seqlens_q causal-bound computation in decode QKV shader. |
| onnxruntime/contrib_ops/cuda/bert/paged_attention.cc | Switches to provider-neutral helper header for input validation. |
| onnxruntime/contrib_ops/cpu/bert/paged_attention_helper.h | Introduces shared input-validation helper for PagedAttention. |
| docs/design/webgpu_paged_attention.md | Adds design doc describing scope, constraints, and phased rollout plan. |
- paged_attention_test.cc: add missing #include <limits> (uses std::numeric_limits<float>::infinity()). - paged_attention.cc: convert ORT_ENFORCE on the two optional cache outputs into ORT_RETURN_IF with a clearer error message (the scatter kernel needs both outputs, even though the schema marks them Optional). - paged_attention.cc: move the input-to-output cache copy above the token_count==0 fast path so that the non-aliased path (OpTester) leaves initialized cache outputs even when there is no scatter work to do.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (2)
onnxruntime/contrib_ops/webgpu/bert/paged_attention.cc:483
- This rejects graphs that legally omit optional outputs 1/2 (key_cache_out/value_cache_out) even though the schema marks them optional. CUDA PagedAttention allows these outputs to be null (it only enforces aliasing when they’re present) and still performs the in-place cache update via the input cache buffers. Consider matching that behavior by falling back to writing into the input cache tensors when the optional outputs are omitted.
Tensor* key_cache_out = context.Output(1, cache_shape);
Tensor* value_cache_out = context.Output(2, cache_shape);
// The schema marks these outputs Optional, but the scatter kernel needs a
// destination buffer; require both to be bound.
ORT_RETURN_IF(key_cache_out == nullptr || value_cache_out == nullptr,
docs/design/webgpu_paged_attention.md:201
- This roadmap section says Phase 0 ("this PR") adds a stub PagedAttention whose
ComputeInternalreturnsNOT_IMPLEMENTED, but this PR actually contains the functional Phase 1 implementation. The doc should not describe the stub as part of the current PR, otherwise readers will be misled about what’s shipping.
### Phase 0 — Skeleton (this PR)
- Add `contrib_ops/webgpu/bert/paged_attention.{h,cc}` with the kernel class
and `ComputeInternal` returning `NOT_IMPLEMENTED`. Same shape as the CPU
stub in #29867. Purpose: register the op with the WebGPU EP so a model
Copilot review noted that the doc's Phase 0 section was labeled '(this PR)' but this PR actually delivers Phase 1. Update Phase 0 label to '(early commits in this PR)' and move the '(this PR)' marker to Phase 1, which is the final state delivered.
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
Summary
Nice first cut. The six-program pipeline (split-packed-QKV → rotary → scatter → gather → unpack-Q → ApplyFlashAttention → repack) is a sensible reuse strategy for v1, the seqlens_q FlashAttention extension is minimal and correctly gated behind a shader #param so existing GQA/MHA callers compile identically, and the WGSL address models match paged_attention_impl.cu where I checked them (rotary interleaved/split pairing + sign + h >= rotary_dim pass-through, scatter block addressing, GQA head mapping). The paged_attention_helper.h relocation is a pure move (verified with git diff -M).
My findings are concentrated in three areas: host-side validation of device-derived values, WebGPU resource limits, and doc/comment drift. Details inline; cross-cutting items below.
Cross-cutting
1. Two blocking D→H readbacks per node per Run. context.CopyTensor(gpu → cpu) lands in BufferManager::Download, which ends the compute pass, Flush()es the queue, creates a fresh staging buffer, and blocks in context_.Wait. On a 32-layer model that is 64 full pipeline flushes and 64 staging-buffer creations per decoded token — and a blocking Wait is not viable on the browser main thread, which is the primary WebGPU target. The design doc frames this only as a graph-capture blocker deferred to Phase 2; it is also a v1 latency/viability issue and deserves a note there. A cheap partial fix available now: concatenate cumulative_sequence_length and past_seqlens into one small GPU scratch with a tiny copy program and issue a single Download — halves the flushes for a few lines of code.
2. KV-cache quantization is not guarded. ApplyFlashAttention opens with turbo_quant_enabled = context.KvCacheQuantizationEnabled(). With that session option on, the padded fp16 k_padded/v_padded fail the TurboQuant shape check and the user gets "TurboQuant KV cache shape mismatch for present_key" from deep inside FA. Worth rejecting up front alongside the existing softcap / local_window_size guards.
3. Unused uniforms across all six programs — scatter: kv_hidden_size, max_num_blocks_per_seq; gather: max_num_blocks_per_seq; unpack-query: batch_size, hidden_size; repack-output: max_seqlen_q, token_count; rotary: token_count. They inflate the uniform buffer and the program cache key for no benefit. (max_num_blocks_per_seq is the interesting one — see the scatter inline comment.)
4. Three copies of the token→batch linear scan in scatter, rotary, and repack_output. A shared #use helper (or a token→batch map computed once) would remove the duplication.
5. CanApplyFlashAttention is never consulted. Its preconditions (!is_packed_qkv_, head_size_ == v_head_size_, head_size % 4 == 0 / % 8 on Qualcomm) are all implied by the helper's head_size % 8 == 0 check and the gqa_params construction, so this is not a live bug — noting it only so the coupling is deliberate rather than accidental.
6. C++ test coverage gaps. paged_attention_test.cc does not cover do_rotary=1, packed-QKV, token_count == 0, or the softcap / local_window_size NOT_IMPLEMENTED guards — the last two are precisely the host-side enforce paths design doc §9 says are runnable under lavapipe, so they are cheap to add and would run in the Linux leg.
Things I specifically checked and found correct: the cache copy-through happens before the token_count == 0 early return; the gather reads the post-scatter key_cache_out/value_cache_out; kv_sequence_length = 0 deliberately hits FA's kv_empty aliasing path so FA never writes the padded scratch back; the batch linear scan handles zero-length batches (picks the first i with cum[i+1] > token_idx); seq_causal_length = past_sequence_length + q_idx_global + 1 is correct for LEFT-aligned Q once past_sequence_length is per-batch; and use_seqlens_q is threaded into both CacheHint calls so compiled variants do not collide.
Thanks for the detailed review and for validating the WGSL address models against the CUDA implementation. I agree with the main findings and addressed the following in the latest update:
I also corrected the documentation drift around:
The resource-limit findings are valid as well. I’ll address the checked dispatch-size, scratch-buffer, and storage-binding-limit validation in the corresponding inline comments or a focused follow-up so that resource validation remains independently reviewable. Validation against fresh build artifacts:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated no new comments.
Suppressed comments (4)
onnxruntime/test/providers/webgpu/paged_attention_test.cc:12
- This test uses std::numeric_limits but does not include , which will fail to compile on some toolchains.
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <numeric>
#include <vector>
onnxruntime/contrib_ops/webgpu/bert/paged_attention.cc:510
- This kernel currently errors out when key_cache_out/value_cache_out outputs are omitted, even though the schema marks them Optional and the CUDA kernel supports omitting them by updating the input caches in-place. Requiring the optional outputs can break valid models that only request the attention output.
// The schema marks these outputs Optional, but the scatter kernel needs a
// destination buffer; require both to be bound.
ORT_RETURN_IF(key_cache_out == nullptr || value_cache_out == nullptr,
"PagedAttention (WebGPU): key_cache_out and value_cache_out outputs "
"are required by this kernel (schema marks them Optional, but the "
onnxruntime/test/python/transformers/test_paged_attention.py:36
- This comment says the mapping is
EP -> (torch_device_when_cuda_available, ort_iobinding_device), but the dict values are only the ORT device string. This is confusing given the new Config.torch_device/ort_device split below.
docs/design/webgpu_paged_attention.md:247 - The Phase 1 plan says the WebGPU kernel should fail if cache outputs are not aliased, but the implementation now falls back to copying into separate outputs (with a warning). This doc section should be updated to match the implemented behavior.
6. **Cache-output aliasing.** Emit `key_cache_out`/`value_cache_out` and
verify `MutableData<T>() == input->Data<T>()`. Fail INVALID_ARGUMENT
otherwise, exactly as CUDA does. WebGPU EP allocator reuse should make
this straightforward.
…ention # Conflicts: # onnxruntime/test/python/transformers/test_paged_attention.py
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…o hari/webgpu_paged_attention
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
The latest head addresses the earlier metadata-validation, resource-limit, quantization, cache-copy, and documentation concerns. One blocking schema-compatibility issue remains: WebGPU rejects SEPARATE-mode nodes that omit both optional cache outputs, although the schema explicitly permits that form and CUDA updates the input caches in place. I continued the existing thread with the current-head details to avoid duplicate inline feedback. Please support omitted outputs and add a focused test.
It's fine that webgpu requires the output.
62aced3
into
main
…s via cache aliasing harness (#31687) ### Description Follow-up to #31611. This PR is intended to merge after #31611 because it builds on the shared contrib-op test relocation. Please see #31611 (comment) ### Problem The shared OpTester-based suite uses separate output buffers, but CUDA PagedAttention requires cache output tensors to alias the corresponding cache input tensors. As a result, CUDA path validation in the shared suite fails due to test harness semantics, not kernel correctness. ### Goal Enable CUDA execution for the shared contrib-op PagedAttention tests by adding an aliasing-capable test path that matches CUDA’s runtime contract. ### Scope - Keep shared operator-level tests in contrib_ops. - Preserve existing WebGPU coverage (including non-aliased fallback behavior). - Add CUDA coverage using an IO-binding based harness where cache input/output share the same underlying buffer. - Avoid changing CUDA kernel functional behavior or aliasing requirements in this PR. ### Out of Scope - Performance tuning or backend algorithm changes. - Broad refactors of existing PagedAttention test matrices. - Any schema or feature-expansion work unrelated to CUDA cache aliasing test enablement. ### Validation Plan - Run targeted shared PagedAttention C++ tests on WebGPU (regression guard). - Run new CUDA aliasing-based tests and verify pass/fail behavior matches expectations. - Confirm CI passes for affected CUDA test legs. ### Status WIP branch created; implementation and targeted CUDA test harness wiring are in progress. ### Motivation and Context PagedAttention C++ op tests --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
The env-var kill switch was scaffolding for A/B validation of the fused paged-prefill shader. The A/B against #31611 (direct-paged 2-kernel decode + fused paged prefill + Unpack/Repack skip vs. gather-then-flash) showed the fast paths are >= 1.00x on every one of 52 shapes tested (decode 1.00x-5.40x geomean ~2.0x, uniform prefill 1.01x-1.25x geomean ~1.13x, varlen prefill 1.14x-1.73x geomean ~1.29x). Removing the toggle. Changes: * flash_attention.{h,cc}: drop the env-var read + bullet from ShouldRunFusedPagedPrefill. Adapter/dtype/shape/block_size predicates remain and still route non-shm adapters (Qualcomm/AMD/Intel with subgroups), fp32, head_size > 256, and block_size < max_k_step to the gather-then-flash fallback. Drop unused core/platform/env_var.h include. * test/onnx/microbenchmark/paged_attention.cc: drop SetFusedEnv helper and the 'fused' axis from BM_PagedAttentionPrefill / BM_PagedAttentionPrefillVarlen / BM_PagedAttentionDecode. Halve each REGISTER_* macro so the total number of registered cases drops from 104 to 52. * docs/design/webgpu_paged_attention.md: drop env-var bullet from ShouldRunFusedPagedPrefill predicates and the toggle mention in the benchmark-harness section. Tests: 12/12 non-CUDA PagedAttention.EndToEnd_* pass unchanged.
Land the blocking-adjacent fix the reviewer flagged and the accompanying low-cost coverage / doc improvements. 1. NOT_IMPLEMENTED fall-through guards in ApplyFlashAttention (flash_attention.cc). Both the prefill else-branch (dense FlashAttentionProgram) and the split-reduce else-branch (dense FlashAttentionDecodeQKV) now refuse to run when use_paged_kv_cache is true. Today the invariant holds by construction (PagedAttention v1 rejects head_sink/softcap/TurboQuant/non-BSNH at input validation), so the guards cannot fire on any legitimate config; they exist to make a future Phase-2 feature wire-up that forgets shader-side support fail loud instead of silently corrupting output (paged K/V cache would otherwise be read as dense BNSH). 2. Design doc anti-drift footnote (docs/design/webgpu_paged_attention.md). Section 4.3 now names the exact three call sites ShouldRunFusedPagedPrefill covers (PagedAttention::ComputeInternal:use_direct_paged_prefill, :varlen_mode, and ApplyFlashAttention:use_paged_prefill) plus a note about the new fall-through NOT_IMPLEMENTED guard. 3. WGSL shm-budget formula (flash_attention_paged_prefill.wgsl.template). Expanded the k_tile/v_tile comment from one worked example to the general formula, showing both budget-exhausting configurations (fp16 head_size 128 x max_k_step 32 and head_size 256 x max_k_step 16). 4. EndToEnd_Prefill_ForcedFallback_BlockSizeBelowMaxKStep test. B=1, head_size=128, block_size=16, token_count=64. block_size < max_k_step (=32 for fp16 head_size<=128) forces ShouldRunFusedPagedPrefill to reject, so PagedAttention takes the gather-then-flash cascade (the merged #31611 code path). Locks the reject boundary. Tests: 15/15 non-CUDA PagedAttention.EndToEnd_* pass locally (14 previous + 1 new).
## [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>
Description
Wires up the WebGPU PagedAttention kernel end-to-end for continuous-batching / variable-Q-length workloads. Dispatch path:
This is Phase 1 of a planned 4-phase rollout; see the roadmap at the bottom for the delivery plan. The v1 kernel is
MLFloat16-only;softcap,local_window_size, andbfloat16are explicitly rejected withNOT_IMPLEMENTED.FlashAttention: optional
seqlens_qinputThe existing FA shader clamps
past_sequence_length = total_kv_b − max_seqlen_qto zero on underflow. That clamp is only correct for LEFT-aligned Q withpast = 0(the GQABatchedRightPaddedRotaryPrefillscenario). Under continuous batching,past_b > 0whileq_len_b < max_seqlen_qis common, and the clamp silently under-countspast_len_b— causing real Q tokens to leak future KV positions through the causal mask (~85% output mismatch in the mixed-q_lentest).The fix adds an optional per-batch new-Q-length input to FA:
FlashAttentionProgramandFlashAttentionDecodeQKVProgramgain ause_seqlens_q_template-conditional gate and aseqlens_qshader input.past_sequence_length_b = total_kv_b − seqlens_q[b] = past_len_b— always non-negative and correct for any(past, q_len)combination.nullptr,use_seqlens_q_ = false, and the shader takes the byte-identical#elsebranch with the pre-existing clamp. Zero regression risk.use_seqlens_q_is included in each program'sCacheHintto prevent pipeline-cache collision.PagedAttention: LEFT-aligned Q layout
RunUnpackQueryplaces real Q tokens at padded slots[0, q_len_b)with padding at[q_len_b, max_seqlen_q);RunRepackOutputmirrors that layout. Matches GQA's existing convention and lets FA'suse_seqlens_qpath compute the correctpast_len_b.Test coverage
TestPagedAttentionWebGpu(Python parity)batch_size ∈ {1, 2},sequence_length ∈ {1, 4, 16},total_sequence_length ∈ {32, 64},block_size = 256WebGpuPagedAttention.EndToEnd_*(C++)EndToEnd_MixedPrefillDecode_MultiBatch_VariablePast, the exact bug-fix pathGroupQueryAttentionTest.*_WebGPU(regression)BatchedRightPaddedRotaryPrefill_WebGPUandBatchedRightPaddedRotaryPrefillFlashAttention_WebGPU— unchanged since GQA does not passseqlens_qFiles changed
paged_attention_gather_kv.wgsl.template,paged_attention_unpack_query.wgsl.template,paged_attention_repack_output.wgsl.template.flash_attention.{cc,h,wgsl.template},flash_attention_decode_qkv.wgsl.template,paged_attention.{cc,h}, C++ + Python tests, design doc.test_paged_attention_cuda.py → test_paged_attention.py(addsTestPagedAttentionWebGpu).Roadmap
Work will be split into 4 phases and delivered incrementally as time permits (not back-to-back).
softcapandlocal_window_sizeinsideFlashAttentionProgram, which also drops GQA'sCanApplyFlashAttentionbailouts.T_CACHE), head-sink, QK-Norm — starting with quantized KV.Not planned for this PR:
T = bfloat16(blocked on Dawn stability), graph-capture withattention_metadatasizing bound (design-doc §4.4), MLA / LATENT layout (Phase 4+ as customer need materializes).CI coverage note:
TestPagedAttentionWebGpucurrently runs on zero CI legs — the two WebGPU CI workflows are build-only, andnightly_webgpu.yml/ macos-ci run--testwithout--enable_transformers_tool_test. TheWebGpuPagedAttention.EndToEnd_*C++ gtests DO run onnightly_webgpuand macos-ci. Wiring the Python parity suite into a WebGPU CI leg is a small follow-up.