Skip to content

Vulkan: fuse Q@K^T and softmax on the SDPA decode path - #22941

Open
msluszniak wants to merge 2 commits into
pytorch:mainfrom
msluszniak:ms/vulkan-sdpa-fused-decode
Open

msluszniak wants to merge 2 commits into
pytorch:mainfrom
msluszniak:ms/vulkan-sdpa-fused-decode

Conversation

@msluszniak

@msluszniak msluszniak commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

At sequence length 1, sdpa_impl runs the attn-weights matmul and the softmax that follows it as two dispatches, with a round trip through attn_weights in between. This fuses them for the decode path: one work group per (s, q_h) walks the whole context row, keeps it in shared memory, reduces to row max and exp sum, and writes softmax-normalized weights straight to attn_weights_softmax.

Selecting it

The sequence length is only known once sizes are current: a dynamic-shape export reports the max bound at graph build time, never 1. A shader pick can be deferred to trigger_resize, but adding or dropping a node cannot, so all three nodes are built and each global work group picker collapses to zero for the path it is not serving. DispatchNode::encode already skips a zero dispatch.

use_fused_qk_softmax() is the single predicate all three pickers call, so the fused node and the separate pair can never both run.

Bound

use_fused_qk_softmax() also gates on the live context length, read from the input_pos symint at resize, so the choice is remade every token. The crossover measured at roughly 220 tokens on Adreno 840, so the cutoff is 256 and past it the separate pair is selected. A static_assert keeps that cutoff inside the shader's shared attn_weights row (kFusedSoftmaxMaxContextTexels, matching MAX_CONTEXT_TEXEL_LEN).

Measured

Qwen3-0.6B, Adreno 840 (Galaxy S26 Ultra), same binary and same .pte, arms interleaved and selected by an env toggle so nothing else differs. Medians.

dispatches/token decode tok/s, 16-token prompt decode tok/s, 400-token prompt
separate pair 1668 90.9 69.4
fused 1640 97.1 67.5

28 fewer dispatches is exactly one per layer for 28 layers.

The gain is at short context: trading context-axis parallelism for a single work group only pays while the row is short, which is what the cutoff encodes. With the gate in place decode runs the fused path below 256 tokens and the separate pair above it, taking the better of the two columns in either regime.

cc @SS-JIA @manuelcandales @digantdesai @cbilgin

@msluszniak
msluszniak requested a review from SS-JIA as a code owner September 18, 2026 20:56
@pytorch-bot pytorch-bot Bot added the module: vulkan Issues related to the Vulkan delegate and code under backends/vulkan/ label Sep 18, 2026
@pytorch-bot

pytorch-bot Bot commented Sep 18, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22941

Note: Links to docs will display an error until the docs builds have been completed.

❗ 1 Active SEVs

There are 1 currently active SEVs. If your PR is affected, please view them below:

❌ 1 Awaiting Approval, 2 New Failures

As of commit 8c348f9 with merge base 3887eea (image):

AWAITING APPROVAL - The following workflow needs approval before CI can run:

NEW FAILURES - The following jobs have failed:

  • Cadence Build & Test / Resolve CI docker image / resolve (gh)
    ##[error]Refusing to check out fork pull request code from a 'pull_request_target' workflow. This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch cache scope, and runner access. Fetching and executing a fork's code in that trusted context commonly leads to "pwn request" vulnerabilities. To opt in, review the risks at https://gh.io/securely-using-pull_request_target and set 'allow-unsafe-pr-checkout: true' on the actions/checkout step.
  • pull / unittest-buck / macos / macos-job (gh)
    torchaudio

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 18, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

At sequence length 1 the attn-weights matmul and the softmax that follows
it are two dispatches with a round trip through attn_weights in between.
Fuse them: one work group per (s, q_h) walks the whole context row, keeps
it in shared memory, and writes softmax-normalized weights directly.

The sequence length is only known once sizes are current, because a
dynamic-shape export reports the max bound at graph build time. Adding or
dropping a node cannot be deferred to resize time the way a shader pick
can, so all three nodes are built and each global work group picker
collapses to zero for the path it is not serving; DispatchNode::encode
skips a zero dispatch.

The fused shader holds the attn_weights row in shared memory, so it is
only selected while that row fits (see kFusedSoftmaxMaxContextTexels).

Qwen3-0.6B on Adreno 840, same binary, arms interleaved and selected by
an env toggle: 1668 -> 1640 dispatches per token, one per layer for 28
layers, and decode 90.9 -> 97.1 tok/s at a 16-token prompt. At a
400-token prompt the two are within noise of each other (69.4 vs 67.5),
which is what the loss of context-axis parallelism predicts.
The fused QK+softmax dispatch reduces a whole attn_weights row inside one
work group, so its parallelism is capped at num_q_heads work groups while
the separate pair gets one work group per context texel. Measured on
Qwen3-0.6B/Adreno, it wins 3.6-6.2% below ~192 tokens and costs 9-11%
beyond ~550, crossing over around 220.

The gate now reads the live context length instead of the K cache bound,
so it re-decides every token: the pickers are re-run from
DynamicDispatchNode::trigger_resize.
@msluszniak
msluszniak force-pushed the ms/vulkan-sdpa-fused-decode branch from fddd6ff to 8c348f9 Compare September 21, 2026 09:31
@executorch-triage executorch-triage Bot added the community: contribution PRs coming from community (excluding hardware partners) label Sep 22, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. community: contribution PRs coming from community (excluding hardware partners) module: vulkan Issues related to the Vulkan delegate and code under backends/vulkan/

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants