Skip to content

webgpu: Fix TurboQuant quantized KV cache for batch>1 with per-batch seqlens - #29752

Merged
Jiajia Qin (qjia7) merged 6 commits into
microsoft:mainfrom
qjia7:fix/turbo-quant-batch-support
Aug 7, 2026
Merged

webgpu: Fix TurboQuant quantized KV cache for batch>1 with per-batch seqlens#29752
Jiajia Qin (qjia7) merged 6 commits into
microsoft:mainfrom
qjia7:fix/turbo-quant-batch-support

Conversation

@qjia7

@qjia7 Jiajia Qin (qjia7) commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fix TurboQuant WebGPU kernels to use seqlen_k[batch] instead of seqlen_k[0], enabling correct quantized KV-cache handling for batch_size > 1.
  • Clamp per-batch past sequence lengths to prevent unsigned underflow for right-padded prefills.
  • Preserve the host dispatch and cache layouts by passing the batch-wide copy length and physical past-cache length through uniforms.
  • During graph capture, read the batch-global total sequence length from total_sequence_length_input when preparing indirect dispatch.
  • Select the concatenated multi-RoPE cache using the global total sequence length while retaining per-batch lengths for positioning and padding.
  • Apply the graph-capture total-length handling to the standard and TurboQuant packed-QKV rotary paths.
  • Remove the previous TurboQuant batch_size == 1 restriction.

Motivation

The TurboQuant KV-cache copy kernels previously used seqlen_k[0] for every batch. Consequently, batches 1..N-1 could use the wrong past sequence length and write to incorrect cache locations.

Right-padded prompts introduce another case where a batch’s logical total length can be shorter than the padded K/V input length. Direct unsigned subtraction would underflow in that case.

Graph capture also requires special handling because the host-side total sequence length uniform can remain zero while the current value is supplied through a GPU input. This GPU value must be used for indirect dispatch sizing and for selecting the concatenated multi-RoPE cache bank. The multi-RoPE selection is batch-global, while rotary positions and padding checks remain per-batch.

Test plan

Regression coverage added for:

  • Per-batch TurboQuant decode:
    • WebGPU_TurboQuant_Decode_MultiBatch_UsesPerBatchSeqlensK
    • WebGPU_TurboQuant_Decode_MultiBatch_NoRotary_UsesPerBatchSeqlensK
  • Right-padded TurboQuant prefill:
    • WebGPU_TurboQuant_Prefill_MultiBatch_RightPadding_NoRotary
    • WebGPU_TurboQuant_Prefill_MultiBatch_RightPadding_Rotary
  • Graph-capture indirect dispatch:
    • WebGPU_TurboQuant_IndirectDispatch_UsesGlobalLength_NoRotary
    • WebGPU_TurboQuant_IndirectDispatch_UsesGlobalLength_Rotary
  • Concatenated multi-RoPE cache selection:
    • WebGPU_IndirectDispatch_MultiRotaryCache_UsesGlobalLength
    • WebGPU_TurboQuant_IndirectDispatch_MultiRotaryCache_UsesGlobalLength
    • WebGPU_MultiRotaryCache_UsesGlobalLength_NonStaticCache

Verification:

  • GroupQueryAttentionTest.WebGPU_TurboQuant*: 22 passed
  • git diff --check

…seqlens

The TurboQuant copy-to-quantized-KV-cache kernels previously read seqlen_k[0]
for every batch, so batches 1..N-1 used the wrong past sequence length and
produced corrupted output. genai decode runs batch_size==1 so this was not
caught, but right-padded batched GQA (batch>1) needs per-batch seqlens.

Fixes:
- turbo_quant_hadamard.cc / turbo_quant_hadamard.wgsl.template: remove the
  batch_size==1 restriction and read seqlen_k[batch]. batch/head/seq are
  unflattened from the uniform copy sequence length (matching the host dispatch
  layout), then total_seq_length is derived per batch from seqlen_k[batch].
- turbo_quant_fused_rotary_hadamard.wgsl.template: compute the batch id (per
  Q/K/V workgroup type) before accessing seqlen_k, then read seqlen_k[batch].

Tests (WebGPU, TurboQuant-4bit EP):
- WebGPU_TurboQuant_Decode_MultiBatch_UsesPerBatchSeqlensK (rotary path,
  fused rotary+Hadamard kernel)
- WebGPU_TurboQuant_Decode_MultiBatch_NoRotary_UsesPerBatchSeqlensK (plain
  Hadamard kernel)
Both use a swap-invariance check: running batches [A,B] with seqlens [sA,sB]
and the physically-swapped [B,A] with [sB,sA] must yield swapped outputs;
this fails if the kernel reads seqlen_k[0] for all batches.

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

Fixes the WebGPU TurboQuant (quantized KV cache) path to correctly honor per-batch seqlens_k[b] when batch_size > 1, removing the previous hard restriction to batch_size == 1 and adding regression coverage to catch seqlen indexing mistakes.

Changes:

  • Update TurboQuant WGSL kernels to index seqlen_k[batch] (not seqlen_k[0]) and to compute batch/head/seq consistently with the host dispatch layout.
  • Remove the batch_size == 1 validation guard in TurboQuant WebGPU host code.
  • Add multi-batch “swap-invariance” decode tests to validate per-batch seqlens_k behavior for both rotary and non-rotary TurboQuant copy paths.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
onnxruntime/test/contrib_ops/group_query_attention_op_test.cc Adds swap-invariance multi-batch decode tests for TurboQuant rotary and non-rotary paths.
onnxruntime/contrib_ops/webgpu/bert/turbo_quant_hadamard.wgsl.template Unflattens (batch, head, seq) using uniform dispatch layout and switches to seqlen_k[batch] for per-batch totals.
onnxruntime/contrib_ops/webgpu/bert/turbo_quant_hadamard.cc Removes the previous batch_size == 1 rejection when seqlen_k is provided.
onnxruntime/contrib_ops/webgpu/bert/turbo_quant_fused_rotary_hadamard.wgsl.template Computes batch id before reading seqlen_k, switching total length to seqlen_k[batch].

Comment thread onnxruntime/contrib_ops/webgpu/bert/turbo_quant_hadamard.wgsl.template Outdated
Clamp the per-batch past sequence length calculation so right-padded prefills cannot underflow u32 when their valid sequence length is shorter than the padded K/V input.

Add multi-batch static-cache regression coverage for both the plain Hadamard copy path and the fused rotary path.

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

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

onnxruntime/test/contrib_ops/group_query_attention_op_test.cc:3841

  • RunTurboQuantMultiBatchSwapInvariance(do_rotary=true) is intended to cover the fused TurboQuant rotary+Hadamard kernel, but it currently feeds separate Q/K/V inputs. In the WebGPU GQA implementation, non-packed inputs take the path that applies rotary to Q/K before calling ApplyFlashAttention(...) with cos_cache/sin_cache == nullptr, so TurboQuant uses the non-rotary copy kernel instead. To actually test the fused rotary+Hadamard shader in the multi-batch/per-batch-seqlens scenario, provide packed QKV input and mark key/value as optional when do_rotary is true (similar to the existing packed-QKV tests).
    tester.AddInput<float>("query", {batch_size, sequence_length, hidden_size}, concat(q0, q1));
    tester.AddInput<float>("key", {batch_size, sequence_length, kv_hidden_size}, concat(k0, k1));
    tester.AddInput<float>("value", {batch_size, sequence_length, kv_hidden_size}, concat(v0, v1));
    tester.AddInput<float>("past_key", {batch_size, kv_num_heads, past_seq_len, kv_head_dim}, concat(pk0, pk1));
    tester.AddInput<float>("past_value", {batch_size, kv_num_heads, past_seq_len, kv_head_dim}, concat(pv0, pv1));

Use per-batch sequence lengths in both TurboQuant copy shaders while preserving uniform host dispatch indexing and clamping right-padded prompt lengths.

Size graph-capture indirect dispatch from the batch-wide total sequence length input, and pass the physical past-cache stride from past_key shape instead of reconstructing it in WGSL.

Add rotary and non-rotary regression coverage for multi-batch cache addressing, right padding, and static-cache graph capture.

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

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Select the concatenated rotary cache bank from the batch-global total sequence length while retaining per-batch lengths for positioning and padding. Read the GPU total-length input during graph capture across standard and TurboQuant paths, and add coverage for static and non-static packed QKV variants.

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

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (3)

onnxruntime/contrib_ops/webgpu/bert/turbo_quant_hadamard.cc:244

  • TurboQuantApplyRotaryAndCopyToQuantizedKVCache adds total_seqlen as a ProgramInput whenever prepare_indirect_dispatch is true, but does not enforce that total_seqlen is non-null. A null total_seqlen would be dereferenced by ProgramInput construction.
  if (prepare_indirect_dispatch) {
    program.AddInput({total_seqlen, ProgramTensorMetadataDependency::None});
  }

onnxruntime/contrib_ops/webgpu/bert/turbo_quant_hadamard.cc:113

  • When prepare_indirect_dispatch is enabled, total_seqlen is unconditionally added as a ProgramInput, but the code does not validate total_seqlen is non-null. ProgramInput constructors dereference the Tensor pointer, so a missing total_sequence_length input would crash instead of returning a clean error.

This issue also appears on line 242 of the same file.

  if (prepare_indirect_dispatch) {
    program.AddInput({total_seqlen, ProgramTensorMetadataDependency::None});
  }

onnxruntime/contrib_ops/webgpu/bert/group_query_attention.cc:102

  • If use_total_sequence_length_input is true, total_seqlen is added as a ProgramInput without checking it is non-null. ProgramInput dereferences the Tensor pointer in its ctor, so this can crash if the optional total_sequence_length input is omitted.
  if (use_total_sequence_length_input) {
    program.AddInput({total_seqlen, ProgramTensorMetadataDependency::None});
  }

@hariharans29

Copy link
Copy Markdown
Member

Review: PR #29752 — webgpu: Fix TurboQuant quantized KV cache for batch>1 with per-batch seqlens (head 5414ba5)

Author: @qjia7 (Intel, WebGPU EP maintainer). Branch qjia7:fix/turbo-quant-batch-supportmain. 6 commits, +813 / −89 across 11 files. CI: 86 / 87 checks OK (one outstanding, no reported failures). Reviewers requested: @hariharans29, @sushraja-msft. Three rounds of Copilot AI review; earlier comments resolved, no new comments on the final commit.

Verdict: approve. Correctly-scoped, well-layered fix for a real correctness bug (seqlen_k[0] used for every batch in the TurboQuant KV-cache copy WGSL) plus four related hardenings: right-padded prefill underflow, physical-vs-logical past-cache stride, graph-capture GPU-side length routing for indirect dispatch, and multi-RoPE bank selection. Retires the pre-existing batch_size == 1 guard now that the kernel is per-batch-correct. Excellent test design — the new "swap-invariance" tests would positively fail on the old code.


The bug and the layered fix

Primary bug (seqlen_k[0] for every batch)

Old code in both TurboQuant WGSL kernels:

let total_seq_length = u32(seqlen_k[0u]) + 1u;  // ← always batch 0
let past_seq_length = total_seq_length - uniforms.kv_sequence_length;
// past_seq_length then drives write offset into present_key/value

For any batch b > 0 with a different history length, this used batch 0's past to compute the write offset — corrupting other batches' caches. The ORT_ENFORCE(batch_size == 1) host guard existed exactly to prevent the silent-corruption case; it also blocked all legitimate batched inference.

Fix layer 1 — per-batch indexing

Batch derivation moved before the seqlen_k read in both TurboQuant WGSL kernels; seqlen_k[0]seqlen_k[batch]. In turbo_quant_fused_rotary_hadamard.wgsl.template, batch is computed at the top of $MAIN (in both Q and KV paths) so it's available for the read. Copilot AI flagged this exact ordering on an earlier commit and it's now correct.

Fix layer 2 — right-padded prefill underflow

// Before: let past_seqlen = select(total_seqlen - uniforms.sequence_length, 0u, total_seqlen <= uniforms.sequence_length);
// After:
let past_seqlen = per_batch_total_seq_length - min(per_batch_total_seq_length, uniforms.sequence_length);

Semantically identical, branchless. Applied consistently in split_packed_qkv_with_rotary_embedding.wgsl.template, split_packed_qkv_with_rotary_embedding_and_copykv.wgsl.template, and turbo_quant_fused_rotary_hadamard.wgsl.template.

For right-padded prefill batches whose real length is shorter than the padded sequence_length uniform, direct subtraction would wrap to ~4.29e9 and cause OOB writes. Correct fix.

Fix layer 3 — physical vs logical past-cache stride

New past_input_seq_length uniform:

const uint32_t past_input_seq_length = has_past ? static_cast<uint32_t>(past_key->Shape()[2]) : 0u;

Used in the past-copy branch of turbo_quant_hadamard.wgsl.template:

// Before: let past_base = ((batch * kv_num_heads + head) * past_seq_length + seq) * COMPRESSED_HEAD_U32;
// After:
let past_base = ((batch * uniforms.kv_num_heads + head) * uniforms.past_input_seq_length + seq) * COMPRESSED_HEAD_U32;

Real bug fix: past_seq_length is per-batch (from the corrected seqlen_k[batch] read), but the physical past cache has a batch-wide seq dimension. Using the per-batch value as the stride would read from wrong rows once heterogeneous batches are allowed. past_key->Shape()[2] is the correct physical stride.

Fix layer 4 — graph-capture GPU-side length routing

Under graph capture, uniforms.total_sequence_length is baked in and may be stale across replay. The authoritative value is total_sequence_length_input[0] (a GPU tensor).

  • Indirect dispatch sizing: #if prepare_indirect_dispatch branch in turbo_quant_fused_rotary_hadamard.wgsl.template reads total_sequence_length_input[0] for num_total_seq_length_tile. The host in turbo_quant_hadamard.cc binds this input only when prepare_indirect_dispatch is set. Comment is well-written: "batch 0 is not necessarily the longest batch."
  • Multi-RoPE cache bank selection: gated on use_total_sequence_length_input = context.IsGraphCaptureEnabled() && multi_rotary_cache_concat_offset > 0 in group_query_attention.cc. This is the exact and-condition for when both bits of the fix are needed. ✓

Fix layer 5 — batch-wide copy_sequence_length uniform

Previously the WGSL kernel picked copy_seq_length from an #if past_present_share_buffer / elif has_past / else triage using the (batch-0-wrong) total_seq_length. Now the host passes a batch-wide copy_sequence_length uniform, and the shader adds a per-slice skip:

if (seq >= per_batch_total_seq_length) {
    return;
}

For batches with shorter real length than the batch-wide copy extent, the tail slices no-op instead of writing garbage. This is the correct pattern for dispatch-uniform + per-batch-clamp shaders.

Removal of batch_size == 1 guard

// Before, in TurboQuantCopyToQuantizedKVCache and TurboQuantApplyRotaryAndCopyToQuantizedKVCache:
ORT_RETURN_IF_ERROR(
    (!use_seqlen_k || parameters.batch_size_ == 1)
    ? Status::OK()
    : ORT_MAKE_STATUS(ONNXRUNTIME, INVALID_ARGUMENT,
                      "TurboQuant graph-capture decode path reads seqlen_k[0] for all batches and "
                      "currently supports batch_size == 1 only; got batch_size = ",
                      parameters.batch_size_));

Deleted from both host functions. Correct: the guard was a safety net for the exact bug this PR fixes. Not needed once the kernel is per-batch-correct.


Correctness spot-checks

Uniform ordering

New uniforms added in the middle of the existing list, not at the end. Verified header (turbo_quant_hadamard.h) WEBGPU_PROGRAM_DEFINE_UNIFORM_VARIABLES and host .AddUniformVariables({...}) match one-to-one after both copy_sequence_length and past_input_seq_length insertions:

batch_size, compressed_head_size_u32, copy_sequence_length, kv_num_heads, kv_sequence_length,
num_heads, num_q_tiles, num_slices_per_kv, past_input_seq_length, present_seq_length,
tile_size, total_sequence_length

Both sides in the same order. ✓ Uniform-index desync (which is silent and produces bit-arbitrary reads) is a classic footgun in WebGPU; this PR avoids it.

Input ordering

turbo_quant_hadamard.cc binds:

if (use_seqlen_k)                { AddInput(seqlen_k); }
if (prepare_indirect_dispatch)   { AddInput(total_seqlen); }

.GenerateShaderCode declares:

if (use_seqlen_k_)              { shader.AddInput("seqlen_k", ...); }
if (prepare_indirect_dispatch_) { shader.AddInput("total_sequence_length_input", ...); shader.AddOutput("indirect_buffer", ...); }

Matches. ✓ Same pattern applied consistently in TurboQuantFusedRotaryProgram.

CacheHint

SplitPackedQKVWithRotaryEmbeddingProgram cache-hint updated to include the new use_total_sequence_length_input bool:

.CacheHint(params.rotary_interleaved_, multi_rotary_cache_concat_offset, use_total_sequence_length_input)

So graph-capture-enabled and graph-capture-disabled sessions land in separate shader cache entries. ✓

Multi-RoPE cache selection uses global (not per-batch) length

#if use_multi_rotary_cache_concat
    let base_position = select(0u, multi_rotary_cache_concat_offset,
                                global_total_seq_length > multi_rotary_cache_concat_offset);
#else
    let base_position = 0u;
#endif

Deliberately uses global_total_seq_length (batch-wide) rather than per_batch_total_seq_length. This is correct — the multi-RoPE concatenated cache is populated at a batch-wide time threshold, so all batches must agree on which bank they're reading from. Positional lookups (past_seq_length + seq_idx) remain per-batch as they should. Well thought out.


Test coverage

706-line addition to group_query_attention_op_test.cc, covering the exact fault classes fixed:

  • Swap-invariance decode (WebGPU_TurboQuant_Decode_MultiBatch_UsesPerBatchSeqlensK, ..._NoRotary_...): run with batches [A,B], then with [B,A], compare per-batch outputs. Would fail on old code because batch-1 always used batch-0's past length.
  • Right-padded prefill (WebGPU_TurboQuant_Prefill_MultiBatch_RightPadding_NoRotary, ..._Rotary): exercises the underflow guard directly.
  • Graph-capture indirect dispatch (WebGPU_TurboQuant_IndirectDispatch_UsesGlobalLength_NoRotary, ..._Rotary): asserts the GPU-input length is used, not the stale uniform.
  • Multi-RoPE cache selection (WebGPU_..._MultiRotaryCache_UsesGlobalLength, ..._UsesGlobalLength_NonStaticCache): asserts global length drives bank selection.

Swap-invariance is the exactly-right pattern for this bug class. Author reports 22/22 passing on GroupQueryAttentionTest.WebGPU_TurboQuant*.


Nits (all non-blocking)

  1. past_input_seq_length naming — it's the physical past_key seq dim, not an "input" per se. past_key_seq_stride or past_seq_length_capacity reads more naturally. Bikeshed.
  2. Deleted ORT_ENFORCE(batch_size == 1) guard — a one-line comment near the deletion site noting "the previous batch_size == 1 restriction is retired now that seqlen_k is indexed per batch" would help future git-archaeologists reading blame. Minor.
  3. Q-branch batch derivation in the fused kernel uses q_slice / (kv_sequence_length * num_heads) — this pre-existing convention assumes sequence_length == kv_sequence_length on this path (which is true for the prefill path this kernel serves). Not a bug introduced or exacerbated here, but flagging in case a later kernel refactor loosens that invariant.
  4. kv_slice boundary check appears twice in the fused kernel now (once when computing batch up front, and later where the workgroup enters the KV path). Compiler will DCE the duplicate but reading it twice is slightly awkward. Non-issue at runtime.
  5. Test file size — 706 lines for one file addition is large. Not blocking, and every test is directly justified, but a future PR could split the swap-invariance harness into a shared helper if more variants land.

Merge state

  • CI: 86 / 87 checks OK on 5414ba5. One outstanding — no reported failures.
  • Approvals: none yet. Reviewers requested: @hariharans29, @sushraja-msft.
  • Marked ready for review: 4 hours ago.
  • Copilot AI: three rounds; earlier comments resolved (batch derivation ordering, WGSL clarity), no new comments on 5414ba5.

Bottom line

Correct, well-layered fix for a real cache-corruption bug affecting all TurboQuant batch_size > 1 decode/prefill paths. Every visible correctness leg is addressed: per-batch seqlen indexing, right-padded underflow, physical-vs-logical stride, graph-capture GPU-input routing, and multi-RoPE bank consistency. The batch_size == 1 guard is retired at exactly the right moment. Test coverage uses the correct swap-invariance and right-padding patterns to positively assert the fix.

Action items:

  1. @hariharans29 or @sushraja-msft — please review and stamp.
  2. Merge on green (last CI leg pending).
  3. Optional follow-up: consider renaming past_input_seq_length to something more self-documenting (e.g., past_key_seq_stride) in a subsequent cleanup PR.

Ready to merge once a Microsoft-side reviewer signs off.

@qjia7
Jiajia Qin (qjia7) merged commit cf5e9eb into microsoft:main Aug 7, 2026
88 of 89 checks passed
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