Skip to content

vulkan : WMMA selected-key attention for QSA block selection - #133

Merged
dzannotti merged 3 commits into
halo-box:masterfrom
Nathanw1014:hb/vk-qsa-sel-wmma
Oct 8, 2026
Merged

dzannotti merged 3 commits into
halo-box:masterfrom
Nathanw1014:hb/vk-qsa-sel-wmma

Conversation

@Nathanw1014

Copy link
Copy Markdown

Stacked on #119 (and #115 under it); only the last commit is new here. This makes the selected-key attention from #119 faster on Vulkan. The change is limited to the selected-key dispatch and its pipelines.

Two pieces:

  • A small prep pass cleans each selection row once (drops invalid and repeated cells, keeps order, stores the count) instead of every K/V head and split redoing it. The scalar kernel reads these rows, so its sort goes away and its shared memory at GQA 12 drops from 63 KB to 47 KB.
  • A WMMA kernel: one wave per (query, KV head), with the GQA heads as the tile columns, and K/V loaded straight into the matrix registers. It needs 64-lane subgroups and 16-byte aligned K/V; anything else falls back to the scalar kernel, and GGML_VK_SEL_CM=0 forces the fallback.

On a Strix Halo (gfx1151, RADV) with Flash-Next, the attention op at 12k depth goes from about 68 ms to about 8 ms, and pp2048 goes from 568 to 711 t/s at 12k and from 538 to 679 t/s at 32k. Depth 0 doesn't use this path and is unchanged. Decode at 32k is slightly faster (21.8 to 22.4 t/s).

Perplexity moves within the error bars. The scalar fallback gives identical output to #119. The WMMA kernel sums in a different order, which gives a KLD of about 0.027 against #119 on Flash-Next. A near-exact variant I tried (hi/lo split) gave 0.026, so that looks like this model's floor for any change in summation order rather than precision loss. New test cases cover complete, partial and empty rows, GQA 4/8/12, and 32k/64k cells; scrambling the output order makes them fail.

Written with Claude, tested by me.

Assisted-by: Claude Opus 5.5

flash_attn_sel ran scalar QK and PV, and every workgroup bitonic-sorted the
whole selection row. On gfx1151 (Flash-Next REAP-320, pp2048 at depth
12000) one QSA attention op took 68 ms; the masked per-cell gather branch
took 13-14 ms for the same layer.

Same op, same attended keys:
- flash_attn_sel_prep.comp cleans each selection row once (not once per
  K/V head and split): it drops invalid entries and repeated cells (a
  shared-memory hash, first occurrence kept), keeps the survivors in their
  order, pads the row with a live cell and stores the count. Both attention
  kernels read the cleaned rows, so the scalar kernel loses its sort and its
  16 KiB sorted row (shared memory at GQA 12: 63,104 -> 46,720 bytes).
- flash_attn_sel_cm.comp (RADV, gfx11 WMMA layout): one 64-lane subgroup per
  (query, K/V head). The GQA heads are the 16 columns of every tile, keys
  go in steps of 16: S^T = K Q^T and O^T += V^T P^T, so each head's softmax
  state lives in the lanes of its column, with no shared memory or barrier
  in the key loop. K and V fragments are built in registers from the
  gathered rows with the gfx11 WMMA layout (as GATHER_FRAG does): K in
  16-byte loads, V in 16-byte loads that each fill one element of 8 tiles
  (the output store undoes that dim permutation). Padded columns read a live
  row and score -inf, so no load is bounds-tested. The rescale of O is
  skipped when no head's max changed. The K/V heads of a query are
  neighbours in the grid, so they run together on the same cells.
  8.4 KB shared memory, 192 VGPRs.
- Pipeline gate: 64-lane subgroups must be available (required size within
  min..max, or 64 by default without size control), plus subgroup
  arithmetic and fp16; shared memory is checked against the device limit.
  The WMMA kernel needs RDNA3 + RADV, coopmat 16x16x16 f32-acc, ballot and
  16-byte aligned K/V; otherwise the scalar kernel runs. Only tested on
  gfx1151.
  GGML_VK_SEL_CM=0 forces the scalar kernel.

Perf logger, FA per QSA op at d12000 (kv 14080, 2048 queries):
68.2-68.6 ms -> 8.0-8.4 ms. Kept/dropped steps:
- WMMA kernel: 9.7-14.0 ms. With every gather pointed at one row it took
  5.6 ms, so about half of the time was the memory side.
- rescale skip: 149.8 -> 144.6 ms summed over 12 ops (kept, cheap).
- K/V heads adjacent in the grid: 9.8-13.8 -> 8.0-8.4 ms (kept).
- V in 8-byte quarter loads: still 192 VGPRs, more instructions, same time
  (dropped).
- Q and P split into f16 hi + lo (near-exact, NMSE 1.7e-10): 13.6-13.9 ms,
  and the same Flash-Next KLD as plain f16 (dropped).

llama-bench pp2048 ub2048 ABBA, 4 samples per arm (base 4429170):
d0 782.3 -> 776.7 (kernel not used), d12000 568.4 -> 710.6,
d32000 537.8 -> 678.7; tg128 at d32000 21.80 -> 22.36.

Tests (test-backend-ops -b Vulkan0): FLASH_ATTN_EXT 5344/5344,
QSA_PREFILL 22/26 and QSA_DECODE 82/92 as on the base (the poison cases),
QSA_PREFILL_MASKLESS 13/13 and QSA_DECODE_MASKLESS 33/33, the same with
GGML_VK_SEL_CM=0 and with the base kernel. New cases: complete-block rows
(512 blocks of 4 cells + tail, partial and empty rows, scattered cells),
GQA 4/8/12, caches of 32768 and 65536 cells. A wrong output permutation in
the WMMA kernel fails 15 prefill and 16 decode cases.

Flash-Next c8192: PPL 4.0081 -> 4.0378, KLD 0.0271 (top-1 93.8%). The
near-exact hi/lo variant gives KLD 0.0261 and PPL 4.0067, so this is the
model's response to any non-bit-identical attention output (later
selections flip), not f16 error. c4096: 3.9289 -> 3.9352.

Assisted-by: Claude Opus 5.5
@gaetan-puleo

Copy link
Copy Markdown
Collaborator

Independent verification on a second Strix Halo (strix-halo-002, gfx1151, RADV)

Built test-backend-ops at head f1b0c44d1 in the kyuz0 RADV container.

Correctness

Full QSA suite, -b Vulkan0:

op result
QSA_PREFILL 26/26
QSA_DECODE 92/92
QSA_EXPAND 18/18
QSA_PREFILL_MASKLESS 13/13 (NLOOP=1 and NLOOP=3)
QSA_DECODE_MASKLESS 33/33 (NLOOP=1 and NLOOP=3)

All pass. The maskless tests are the strong ones here: their err() override builds a host FP64 oracle over the listed cells and returns max(gpu, cpu) NMSE against the 5e-4 threshold. The WMMA kernel comes in at ~4.1e-8 to 4.6e-8 (e.g. q=9 keys=65536 → 4.35e-8), versus ~5e-14 to 1.8e-13 for the scalar kernel. So the f16 Q*f16-exp path is a couple orders worse than the scalar path but still four orders under the oracle tolerance — the KLD you measure is the model's summation-order floor, not a precision bug.

The WMMA path is really used

I temporarily printed use_cm in ggml_vk_flash_attn_sel (reverted afterwards): it is 1 for all 46 maskless cases (both op filters), and 0 for all of them with GGML_VK_SEL_CM=0. So the new flash_attn_sel_cm_f16 pipeline is created on gfx1151 and selected, and the tests above genuinely exercise it rather than silently falling back to the scalar kernel.

Review

The two pieces look right to me:

  • Prep pass. The open-addressing table with atomicCompSwap / atomicMin correctly keeps the first occurrence regardless of thread order, the Hillis-Steele scan preserves the original order over the contiguous per-thread ranges, and padding with key_sh[first_sh] is always a surviving cell (the lowest valid position can never be a duplicate). The termination argument (at most n_sel <= TABLE distinct cells, so a probe either finds its key or an empty slot) holds. I also checked the "read a whole key block" edge: kb_begin + 15 can only reach into the row when list_stride == n_sel, and there both n_sel and chunk are multiples of 32, so the final chunk always ends at least 32 entries early — no overrun.
  • WMMA kernel. I walked the fragment assumptions against the host comment (A: lane L%16 = key, element i = dim; B: lane L%16 = head, element i = dim; acc: lane L%16 = head, rows L/16 + 4i): Kᵀ fill, the Qᵀ fill, the column-shared max/sum via xor 16/xor 32, the Pᵀ rebuild from own/par with the grp parity, and the Vᵀ 8-tile permutation (dim = (t/8)*128 + 8*r + t%8) that the store undoes all line up. Heads >= GQA are zeroed in q_sh and discarded on store, and because cooperative-matmul columns are independent they cannot contaminate the live heads. The output store is race-free (the four grp lanes of a column write disjoint dim sets), the split-K partial layout matches flash_attn_split_k_reduce, and an empty split writes M = -FLT_MAX to avoid exp(-inf - -inf).

Points worth considering, none blocking:

  • The cm kernel hard-codes RADV's gfx11 WMMA register layout, which the KHR cooperative-matrix spec does not guarantee. The gate checks vendor/arch/driver but no driver version, so a future RADV lowering change would silently produce wrong numbers rather than falling back. A version guard, or a one-time tiny self-check at pipeline creation, would make this fail safe.
  • ggml_vk_flash_attn now takes the selected-key branch whenever src[5] != nullptr && mask == nullptr and GGML_ASSERT(done). That is safe under the scheduler because supports_op uses the same predicate, but it aborts rather than declining if the op ever reaches the backend without that check first.
  • qsa_selected_key_supported probes a representative op per QSA layer at memory-construction time. It uses the same supports_op, so it should track the runtime op, but it is a shape approximation — if a layer's real op is declined for a reason the probe doesn't reproduce, the maskless graph would be built anyway. Given the mctx gate only forms the graph when the probe passes, this is the one place I'd keep an eye on.

I did not reproduce the end-to-end prefill t/s or the KLD; only the op-level tests and the oracle.

CI

windows (both) and ubuntu pass; ubuntu-arm64 passes. ubuntu-llvmpipe fails the same three tests as master b79812d59 (test-recurrent-state-rollback, test-recurrent-state-rollback-qwen35moe, test-save-load-state, all Subprocess aborted), and metal is still queued. Nothing here is from this PR.

LGTM. The WMMA kernel is exercised on gfx1151, matches the FP64 oracle by ~4e-8, and the scalar fallback is preserved.

@Nathanw1014

Copy link
Copy Markdown
Author

Thanks for the oracle numbers and for confirming the WMMA pipeline is really selected. On your three points:

@dzannotti dzannotti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified on Strix Halo (gfx1151, RADV), current master ad219f329 vs master + this PR. Qwen3.8-Flash-Next GSQ-RCO-Q2_0, -fa 1 -b 2048 -ub 512, ABCCBA.

test master PR Δ
pp2048 596.2 596.0 ±0
pp2048 @ d12000 458.9 544.3 +18.6%
pp2048 @ d32000 433.4 521.9 +20.4%
tg64 @ d32000 25.7 26.6 +3.6%

Correctness:

  • QSA_PREFILL_MASKLESS 13/13 and QSA_DECODE_MASKLESS 33/33, with WMMA and with GGML_VK_SEL_CM=0.
  • QSA_PREFILL, QSA_DECODE and QSA_EXPAND pass. FLASH_ATTN_EXT 5344/5344, same as master.
  • KLD against master at 8k context: 0.0011 (PPL ratio 1.0002). Against the HIP reference, master's Vulkan is at 0.00244 and this PR at 0.00249, so no measurable loss.
  • The scalar fallback gives master's output exactly (KLD 0).

Two small follow-ups: put the prep pipeline (~34 KB shared memory) under the same shared-memory check as the scalar kernel, and track the RADV-layout self-check you mentioned, since the WMMA fragment layout isn't version-guarded.

Comment generated via automated review of clanker.

@dzannotti
dzannotti merged commit bed1b8a into halo-box:master Oct 8, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants