Skip to content

webgpu: Fuse FlashAttention decode kernels and extend to any sequence length - #28389

Merged
qjia7 merged 17 commits into
mainfrom
webgpu-flash-attention-relax-subgroups
Jun 11, 2026
Merged

webgpu: Fuse FlashAttention decode kernels and extend to any sequence length#28389
qjia7 merged 17 commits into
mainfrom
webgpu-flash-attention-relax-subgroups

Conversation

@qjia7

@qjia7 qjia7 commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Extend the FlashAttention decode path to work with any sequence length (not just seq_len=1), with causal masking and use_seqlen_k support for static KV cache
  • Add m_tile optimization to process multiple Q rows per workgroup (m_tile=1/2/4), amortizing K/V loads
  • Fuse the separate QKT and SplitVx shaders into a single QKV kernel using online softmax, eliminating the intermediate qk tensor (B×H×seq×present_seq) and reducing dispatch count from 3 to 2
  • Route between prefill (FlashAttentionProgram) and split-reduce (fused QKV + VxReduce) paths based on sequence length

Resolved Issues

Whisper decoding prefill improved from 4.68ms to 1.09ms. Whisper's decoder attention has a small sequence length but large total sequence length (seq_len=4, total_seq_len=1500). The default prefill shader (FlashAttentionProgram) has low parallelism in this case because each workgroup iterates serially over the full KV cache. The split-reduce path tiles the KV dimension across workgroups, achieving much higher GPU occupancy for this workload shape.

Details

Fused QKV kernel: Each workgroup computes QK^T dot products, applies attention bias and causal mask, computes local softmax (per-tile max and sum), normalizes, and multiplies by V — all in one kernel. Per-tile metadata (max, sum) is written for the VxReduce shader to rescale partial outputs using online softmax: output = Σ(partial_i × local_sum_i × exp(local_max_i - global_max)) / global_sum.

Path routing (use_split_reduce): The split-reduce path is used when sequence_length_ < 32; otherwise the single-kernel FlashAttentionProgram prefill path is used. Microbenchmarks on Phi-4 (32 heads, head_size 128, GQA group 3) show split-reduce is 1.13×-2.07× faster than the fused prefill kernel across sequence_length ∈ {16, 30, 31} × total_sequence_length ∈ {128, 500, 2000}. The previous heuristic additionally gated on total_sequence_length_ > 1000, but that signal is 0 under graph capture (seqlen_k lives on the GPU) and the carve-out is unnecessary because split-reduce is uniformly faster for short Q.

Test plan

  • 30/30 MHA unit tests pass
  • phi4-graph-prune produces correct output
  • whisper-tiny-int4 produces correct transcription
  • clang-format clean

qjia7 added 4 commits May 7, 2026 10:21
The 3-kernel FlashAttention decode path (QKT, SplitVx, VxReduce) previously
only handled sequence_length=1. This extends it to work with any sequence
length, providing a fallback for devices without Subgroups support.

When Subgroups is available and seq_len>1, the subgroup-based prefill path
is still preferred. The extended decode path activates when Subgroups is
unavailable.

Changes:
- Workgroup layout now includes new_sequence_length dimension in all 3 kernels
- Q offset supports both BSNH and BNSH layouts via q_BNSH template param
- Causal masking (is_unidirectional) for self-attention with seq_len>1
- use_seqlen_k support for static KV cache (past_present_share_buffer)
- Relaxed CanApplyFlashAttention to allow seq_len>1 without Subgroups

Verified: whisper-tiny-int4 correct transcription, phi4-mini correct
generation, 16/16 MHA unit tests pass.
…FlashAttention decode path

Extend the 3-kernel decode path to process multiple Q rows per workgroup
(m_tile=1/2/4) to amortize K/V memory loads for larger sequence lengths.
Remove the Subgroups feature requirement from CanApplyFlashAttention so
the decode path works on all WebGPU devices. The subgroup-based prefill
path is replaced by the extended decode path with m_tile.

Fix causal masking to compute past_sequence_length dynamically from
total_sequence_length - new_sequence_length, which is correct for both
GQA (where past_sequence_length_ is the buffer size) and graph capture
(where total_sequence_length_ is on GPU).
…n decode

Merge the separate QK^T and SplitVx shaders into a fused QKV shader
that computes QK^T, local softmax, and V multiply in one kernel launch,
eliminating the intermediate qk tensor and reducing dispatch count from
3 to 2. The VxReduce shader now rescales partial outputs using per-tile
online softmax metadata (local_max, local_sum).
Use FlashAttentionProgram (single kernel, subgroup-based) for prefill
when sequence is long enough to benefit. Fall back to the split-reduce
path (fused QKV + VxReduce) for short sequences, when subgroups are
unavailable, or when total_sequence_length is large relative to
sequence_length.

@github-actions github-actions Bot 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.

You can commit the suggested changes from lintrunner.

Comment thread onnxruntime/contrib_ops/webgpu/bert/flash_attention.h Outdated
@qjia7 qjia7 changed the title webgpu: Extend FlashAttention decode path for any sequence length webgpu: Fuse FlashAttention decode kernels and extend to any sequence length May 12, 2026
@qjia7
qjia7 marked this pull request as ready for review May 12, 2026 10:05
@qjia7
qjia7 requested a review from Copilot May 12, 2026 10:06

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 updates the WebGPU FlashAttention implementation to improve the decode/prefill path by fusing decode shaders (QKᵀ + softmax + V) into a single kernel, adding m_tile to process multiple query rows per workgroup, extending decode beyond seq_len=1, and routing between subgroup-based and non-subgroup paths to broaden device support.

Changes:

  • Replace the decode QKT + SplitVx pipeline with a fused QKV shader + VxReduce (online softmax) pipeline, supporting sequence_length > 1.
  • Add path routing between subgroup-based prefill and split-reduce decode to enable FlashAttention without subgroup support.
  • Extend decode uniforms/shader logic to support causal masking, optional seqlen_k, and m_tile optimization.

Reviewed changes

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

Show a summary per file
File Description
onnxruntime/contrib_ops/webgpu/bert/flash_attention.h Updates program declarations/uniforms: replaces QKT/SplitVx with fused QKV; extends VxReduce options (seqlen_k/head_sink/m_tile).
onnxruntime/contrib_ops/webgpu/bert/flash_attention.cc Implements fused QKV dispatch, split-reduce routing, new metadata/output shapes, and removes subgroup gating from CanApplyFlashAttention.
onnxruntime/contrib_ops/webgpu/bert/flash_attention_decode_vx_reduce.wgsl.template Updates reduction shader to rescale partial V outputs using online softmax metadata; adds m_tile, head_sink support, and sequence-length indexing.
onnxruntime/contrib_ops/webgpu/bert/flash_attention_decode_split_vx.wgsl.template Deleted (functionality folded into fused QKV shader).
onnxruntime/contrib_ops/webgpu/bert/flash_attention_decode_qkv.wgsl.template New fused shader implementing QKᵀ + bias/mask + local softmax + V multiply, emitting per-tile metadata for reduce.
onnxruntime/contrib_ops/webgpu/bert/flash_attention_decode_qkt.wgsl.template Deleted (replaced by fused QKV shader).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread onnxruntime/contrib_ops/webgpu/bert/flash_attention.cc Outdated
@qjia7
qjia7 requested a review from xiaofeihan1 May 13, 2026 01:48
@guschmue guschmue added the ep:WebGPU ort-web webgpu provider label May 13, 2026
@qjia7
qjia7 marked this pull request as draft May 19, 2026 09:42
qjia7 added 9 commits May 21, 2026 13:37
…ce path

When graph capture is enabled with static KV cache, total_sequence_length
is 0 (GPU-based seqlen_k), causing dispatch size 0 in the split-reduce
QKV kernel. Fix by broadening use_indirect_dispatch to remove the
sequence_length == 1 restriction, so the indirect buffer computes
dispatch sizes on GPU for any sequence length.

The indirect buffer now writes a flattened total workgroup count
(batch * heads * num_q_tiles * num_kv_tiles) with normalization to
stay within WebGPU maxComputeWorkgroupsPerDimension (65535).
…malization

Move the indirect dispatch buffer normalization logic (splitting into 2D
when exceeding maxComputeWorkgroupsPerDimension) into a shared WGSL
function used by both CopyKVCacheProgram and
SplitPackedQKVWithRotaryEmbeddingAndCopyKVProgram.
# Conflicts:
#	onnxruntime/contrib_ops/webgpu/bert/flash_attention.cc
Narrow the second clause of the split-reduce routing heuristic from
seq_len < 64 to seq_len < 32 to keep more medium-length prefill shapes
on the single-kernel FlashAttention path.
Convert raw subscript access on always-registered tensors in
flash_attention_decode_qkv and flash_attention_decode_vx_reduce to the
shared .getByOffset/.setByOffset accessor helpers, matching the pattern
used by matmul_nbits and split_packed_qkv_with_rotary_embedding_and_copykv.

The wgsl-template generator emits unconditional `*params.var_X` hoists
for every variable accessed via the helpers, so the corresponding C++
bindings now capture the AddInput/AddOutput refs and forward them with
WGSL_TEMPLATE_VARIABLE entries. Conditional inputs (seqlens_k,
attention_bias, head_sink) keep raw subscript access and stay out of
the variable list.
…e num_q_tiles uniform

Rename the WGSL helper from write_indirect_dispatch(total) to
normalize_dispatch_group_size(x, y, z) so it mirrors
ProgramManager::NormalizeDispatchGroupSize on the CPU side. The new
form fast-paths the common case where every dim is within
maxComputeWorkgroupsPerDimension, avoiding any f32 arithmetic, and
keeps the sqrt and cbrt fallbacks for the rare overflow tiers.

Drop the duplicated (new_sequence_length, m_tile) uniforms from
CopyKVCacheProgram and SplitPackedQKVWithRotaryEmbeddingAndCopyKVProgram.
Precompute num_q_tiles once in ApplyFlashAttention and pass it as a
single uniform. CPU becomes the sole source of truth, the per-shader
ceil-div recompute goes away, and uniform layout shrinks by one entry
per program.

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 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread onnxruntime/contrib_ops/webgpu/bert/flash_attention.cc Outdated
The previous heuristic also gated on total_sequence_length_ > 1000,
but that signal is 0 when graph capture is enabled (seqlen_k lives
on the GPU). The carve-out is also not needed: benchmarks show
split-reduce is uniformly faster for short Q across all measured
KV cache lengths (1.13x-2.07x at total_sequence_length 128 / 500
/ 2000 on a representative LLM).
@qjia7 qjia7 closed this Jun 9, 2026
@qjia7 qjia7 reopened this Jun 9, 2026
@qjia7
qjia7 marked this pull request as ready for review June 10, 2026 01:18
@qjia7
qjia7 requested review from guschmue and hariharans29 June 10, 2026 08:43
@hariharans29

Copy link
Copy Markdown
Member

Verdict: Approve — the kernel fusion is sound, the online-softmax math in the new VxReduce is right, and the routing heuristic is now well-justified. A few observations worth flagging, none blocking.


What's actually happening here (worth restating for the next reviewer)

Three changes ride together in this PR:

  1. Kernel fusion: FlashAttentionDecodeQKTProgram + FlashAttentionDecodeSplitVxProgram → single FlashAttentionDecodeQKVProgram. The intermediate qk tensor of shape B × H × seq × present_seq is gone. Dispatches go 3 → 2.
  2. Seq-length extension: the decode path (now split-reduce) was previously hard-coded to sequence_length == 1; it now handles any new-seq length, with an m_tile-parameterized inner loop processing 1/2/4 Q rows per workgroup.
  3. Routing change: (seq <= 4) || (seq < 32 && total > 1000)seq < 32. The > 1000 carve-out is dropped because (a) total_sequence_length_ is 0 under graph capture so the gate never fires anyway, and (b) the author's benchmark shows split-reduce wins uniformly over the fused prefill kernel across seq ∈ {16, 30, 31} × total ∈ {128, 500, 2000} (1.13×–2.07×).

The PR also addresses Copilot's one open comment by simplifying the routing rather than papering over the total_sequence_length_ == 0 graph-capture case with a fallback. That's the right call — fewer knobs, single condition, benchmark-justified.


What I checked carefully

1. Online-softmax rescaling in flash_attention_decode_vx_reduce.wgsl.template — math is right

The fused QKV kernel writes a locally normalized partial_i (using the tile-local max/sum), plus per-tile metadata (local_max_i, local_sum_i). VxReduce undoes the local normalization and applies the global one:

global_max = max(local_max_i)
global_sum = Σ local_sum_i × exp(local_max_i - global_max)
rescale_i  = local_sum_i × exp(local_max_i - global_max) / global_sum
output[h]  = Σ partial_i[h] × rescale_i

Each partial_i[h] was Σ_{k in tile_i} V[k][h] × exp(qk[k] - local_max_i) / local_sum_i. Multiplying by rescale_i exactly cancels the 1/local_sum_i and converts exp(qk[k] - local_max_i) × local_sum_i × exp(local_max_i - global_max) / global_sum into exp(qk[k] - global_max) × local_sum_i / global_sum. Wait — that's wrong, there's an extra local_sum_i. Let me re-check.

Actually no: the rescale is local_sum_i × exp(local_max_i - global_max) / global_sum, and partial_i[h] carries 1/local_sum_i. The local_sum_i cancels, leaving:

output[h] = Σ_i Σ_{k in tile_i} V[k][h] × exp(qk[k] - global_max) / global_sum

which is exactly standard softmax-weighted V. So the math checks out. The header comment block accurately documents this, which is great for future maintainers.

The has_head_sink path correctly folds the sink into both g_max and g_sum before the output accumulation. Good.

2. Causal mask formula in the fused QKV shader

if (total_seq_offset + local_idx > total_sequence_length - uniforms.new_sequence_length + q_idx) {
  sum = q_element_t(-65504.0f);
}

For new query at position q_idx in the new sequence, valid keys are positions 0 .. past_seq_len + q_idx. With total_seq_len = past_seq_len + new_seq_len, that's keys ≤ total_seq_len - new_seq_len + q_idx. The strict > correctly masks. Good.

This is only correct if new queries are appended contiguously at the end of the cached sequence (standard incremental-decode/prefill semantics). That assumption is built in everywhere else in this op, so it's fine here.

3. Buffer sizing for metadata and out_split_vx

The shader indexing requires:

  • metadatabatch_heads × new_sequence_length × num_present_sequence_length_tile × 2 floats
  • out_split_vxbatch_heads × new_sequence_length × num_present_sequence_length_tile × v_head_size elements

Pre-PR these had no new_sequence_length dimension (seq was 1 implicitly). I can't see the post-PR flash_attention.cc allocation code in the diff (large-diff was collapsed), but presumably the author extended the shape descriptors — the unit tests and end-to-end Whisper run would have caught any mismatch. Worth a glance from someone who has the diff loaded that the metadata_dims and out_split_vx_dims allocations in ApplyFlashAttention now include the new_sequence_length axis.

At seq=31, present_seq=8K, head_size=128, batch_heads=32, fp16: out_split_vx ≈ 50 MB. For Phi-4-class workloads this is fine; for very-long-context models it's noticeable but not pathological. The trade is against eliminating the qk tensor of size B × H × seq × present_seq, which at the same dims is 16 MB (fp16). Net memory delta is positive (+34 MB at those dims) but dispatch count drops from 3 to 2 and bandwidth-bound passes drop by one — sensible trade.

4. Workgroup-index decoding agrees between dispatch and shaders

Layout used everywhere: [batch_heads, num_q_tiles, num_total_seq_length_tile], decoded in shaders as:

let total_seq_offset = (workgroup_idx % num_total_seq_length_tile) * tile_size;
let q_tile_idx      = (workgroup_idx / num_total_seq_length_tile) % num_q_tiles;
let batch_head_idx  = u32(workgroup_idx / (num_total_seq_length_tile * num_q_tiles));

That matches the indirect-dispatch population in split_packed_qkv_with_rotary_embedding_and_copykv.wgsl.template:

normalize_dispatch_group_size(num_total_seq_length_tile, uniforms.num_heads * uniforms.num_q_tiles, uniforms.batch_size);

Inner-most dimension is num_total_seq_length_tile, middle is num_heads × num_q_tiles, outer is batch_size. With workgroup_idx being the flattened linear index, that's consistent with the decoding above (num_heads × batch_size = batch_heads once you mod-divide). Good.

One latent assumption: num_q_tiles is computed inside the shader as (new_sequence_length + m_tile - 1) / m_tile. The dispatch in the .cc must use the same formula (must come from a host-side uniform or from a matching calculation). Worth verifying that m_tile is the same constant on both sides — since m_tile is a #param (specialization constant), and the host passes it via the program's m_tile_ member that's also used to size the dispatch, this should be consistent by construction. Confirm in the cc that num_q_tiles host-side uses the same m_tile_ value the program is instantiated with.

5. Metadata "padding" with static KV cache

meta_offset indexes with stride num_present_sequence_length_tile but only num_total_seq_length_tile tiles actually write. With static KV (present > total), there's uninitialized tail. The VxReduce reduction reads only num_total_seq_length_tile entries (line 54 / line 60 of the new VxReduce template), so the uninitialized tail is never read. Correct.

If anyone ever changes VxReduce to loop to num_present_sequence_length_tile for any reason, this becomes a correctness bug. Worth a one-line comment in the shader near the loop bound explaining why it's num_total_seq_length_tile and not the stride.


Observations worth flagging (not blocking)

A. Single-model benchmark for the routing change

The simplified seq < 32 heuristic is justified by Phi-4 measurements (32 heads, head_size 128, GQA group 3). The change removes a total > 1000 carve-out that was previously a no-op under graph capture anyway. For models with very different head counts (small models with 4–8 heads) the split-reduce path's parallelism story changes — fewer batch_heads means more reliance on the num_total_seq_length_tile dimension to fill the GPU. The conclusion likely still holds (split-reduce dominates because it tiles the long KV dimension), but a follow-up sanity run on a small-head model would be reassuring.

B. Heuristic boundary is still 32

Why 32 specifically (vs. 31 or 64)? Pre-PR the boundary was also 32, so this PR preserves it. Not asking to change — just noting that with the fused QKV doing online softmax across multiple Q rows via m_tile, the crossover may have shifted slightly. The author's table only goes up to seq=31. A two-point check at seq=32 and 48 would tell us whether the boundary should move.

C. PR scope

Three meaningful changes (fusion + seq-length extension + routing simplification) in one PR. The fusion change alone is a non-trivial rewrite of three shaders. The author landed it cohesively and the test coverage (30/30 MHA, phi4-graph-prune, whisper-tiny-int4) is appropriate. If a regression turns up later, bisecting between fusion and m_tile will be slightly more work than if these had been two PRs. Not asking to split — just noting.

D. m_tile selection isn't visible in the snippets I can see

m_tile is a #param (specialization constant), and the diff for flash_attention.h shows it threaded through the program constructors. How m_tile is chosen at the call site (fixed, vendor-dependent, seq-length-dependent) isn't visible to me from the rendered diff. Worth confirming there's a clear rule (e.g., m_tile = min(4, ceil(seq/8)) or similar) rather than a magic constant.

E. Memory footprint note for very-long contexts

As above, out_split_vx now scales as O(seq × present_seq) rather than O(present_seq). At decode (seq=1) this is identical to before. At seq ~ 30 with present ~ 8K it's tens of MB. Probably fine for the workloads this is targeting, but if anyone ever pushes split-reduce to seq=128+ on long contexts, this scales linearly. Worth a comment in the .cc near the buffer allocation explaining the new dimension.

F. cpplint warning

The bot caught flash_attention.h:181 needs #include <string> for std::string (added via the const std::string& kernel_name parameter, which was there before — likely surfaced because the line moved). One-line fix.


Bottom line

The fusion is mathematically sound (the online-softmax rescale in VxReduce is correct), the seq-length extension uses consistent indexing across shaders and dispatch, and the routing simplification is benchmark-justified and dodges the graph-capture footgun on total_sequence_length_. The 2.07× Whisper prefill improvement is concrete. Approve.

If you want one thing addressed before merge, take the cpplint #include <string> fix. The rest is follow-up material.

@qjia7
qjia7 merged commit f2c8a09 into main Jun 11, 2026
93 of 168 checks passed
@qjia7
qjia7 deleted the webgpu-flash-attention-relax-subgroups branch June 11, 2026 02:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ep:WebGPU ort-web webgpu provider

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants