Skip to content

vulkan : selected-key flash attention for QSA block selection - #119

Merged
Nathanw1014 merged 2 commits into
halo-box:masterfrom
rcfa:pr/vulkan-qsa-sel
Oct 6, 2026
Merged

Nathanw1014 merged 2 commits into
halo-box:masterfrom
rcfa:pr/vulkan-qsa-sel

Conversation

@rcfa

@rcfa rcfa commented Sep 30, 2026 •

Copy link
Copy Markdown

Overview

Depends on #115. This branch contains it; the change here is the last commit.

#115 stops Vulkan from running the maskless selected-key op with its dense kernel, which ignored the selection list
and attended to every cell. Vulkan then falls back to the masked per-cell top-k path. That path is correct but slow
at depth: it costs 18% of prompt speed at 32k cells and 25% at 64k (numbers in #115).

This PR adds the missing kernel. Vulkan can then take the maskless block-selection path, the same one the HIP
selected-key kernels take on RDNA3.5, and attend only to the cells in the list.

  • flash_attn_sel.comp:
    • One workgroup per query x K/V head, with one 64-lane subgroup for each query head of that K/V head. Each K/V
      row is read once per query. There are pipelines for GQA 4, 8 and 12, and head size 256 (shared memory limits
      the GQA).
    • The workgroup sorts the query's list in shared memory and skips invalid (-1) and duplicate entries. This is
      what the CPU path does and what the FP64 reference in test-backend-ops expects.
    • It then gathers K and V in blocks of 32 cells, with K transposed in shared memory. Two lanes compute one key's
      dot product (half the dimensions each), so the online softmax needs two subgroup reductions per block instead
      of one per key. Accumulation is in f32.
    • With fewer than 64 workgroups (decode), each row is split into chunks of at least 64 keys, and
      flash_attn_split_k_reduce combines them. An empty chunk writes M = -FLT_MAX instead of -inf, so the reduce
      never computes exp(-inf - -inf).
  • supports_op accepts a maskless selected-key op exactly when this kernel takes it, and still declines everything
    else. So the dense kernel can never run the op.
  • The model side (llama_memory_hybrid_idx) no longer checks for the ROCm backend by name. It asks each QSA layer's
    device with a probe op built from the layer's shapes, as llama.cpp already does for weight buffer types. HIP stays
    accepted as before. CUDA is not asked, because its supports_op does not look at the selection list, and on a
    device without the QSA kernels ggml-cuda aborts on the op (qwen4exp: GGML_ABORT ("maskless selected-key attention was not taken by a QSA kernel") on KV-cache exhaustion under --kv-unified with concurrent slots #85).

Measurements

Device:     Ryzen AI 9 HX PRO 370 / Radeon 890M (gfx1150, Strix Point - NOT Strix Halo) / Minisforum N5 PRO, BIOS 1.05
Memory:     96 GB DDR5-5600 (2 x 48 GB SO-DIMM, ECC)
Power:      BIOS defaults; amdgpu power_dpm_force_performance_level=auto; cpufreq governor powersave (amd-pstate)
BIOS:       UMA split 48 GB
Kernel:     7.1.5+kali-amd64, amdgpu params: amdgpu.gttsize=73728 ttm.pages_limit=8650752
Backend:    Vulkan RADV, Mesa 26.1.6 (libvulkan1 1.4.357)
Build:      -DGGML_VULKAN=ON -DLLAMA_CURL=OFF -DCMAKE_BUILD_TYPE=Release -DCMAKE_BUILD_RPATH_USE_ORIGIN=ON (gcc 16.2)
Baseline:   307c50d26 (master) and 72f0f12b7 (#115), built and run in the same session
Change:     3bf53565b7d5d96f855eb33e9351e528d6f255ee
Model:      unsloth/Qwen3.8-Flash-Next-GGUF, UD-IQ4_XS

test-backend-ops -b Vulkan0:

test master #115 this PR
QSA_PREFILL_MASKLESS 0/6 not supported 6/6
QSA_DECODE_MASKLESS 0/25 not supported 25/25
QSA_PREFILL, QSA_DECODE (masked) 22/26, 82/92 same same (the poison=1 cases fail on master too)
TOPK_QSA, FLASH_ATTN_EXT 4/4, 5344/5344 same same

The maskless cases compare against a host FP64 reference over the listed cells. The largest NMSE this kernel gives
is 6.9e-13 (12643af); the CPU backend gives 1e-7 to 5e-5 on the same cases. Queries with at most 31 rows go through the split
path (1 query: 3e-14).

llama-bench -m MODEL -p 2048 -d 0,12000,32000,64000 -b 2048 -ub 512 -n 0 -r 4 -ngl 99 -fa on -ctk f16 -ctv f16 -lm none -lzm on-direct -o jsonl

Master: (307c50d; wrong past 2051 cells, see #115)

model size params backend ngl n_batch n_ubatch fa load_mode lazy_mode test t/s
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d0 202.93 +- 1.03
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d12000 149.98 +- 0.63
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d32000 113.50 +- 0.30
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d64000 80.60 +- 0.14

#115: (72f0f12, the masked per-cell path)

model size params backend ngl n_batch n_ubatch fa load_mode lazy_mode test t/s
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d0 202.12 +- 0.95
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d12000 153.01 +- 0.67
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d32000 93.26 +- 0.68
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d64000 60.81 +- 0.19

After: (3bf5356)

model size params backend ngl n_batch n_ubatch fa load_mode lazy_mode test t/s
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d0 206.14 +- 1.81
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d12000 158.27 +- 0.79
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d32000 149.75 +- 0.35
qwen4exp A3B IQ4_XS - 4.25 bpw 87.24 GiB 176.94 B Vulkan 99 2048 512 1 none on-direct pp2048 @ d64000 144.12 +- 0.16
depth #115 t/s this PR t/s samples change beyond both spreads
0 202.12 +- 0.95 206.14 +- 1.81 4 / 4 +2.0% yes
12000 153.01 +- 0.67 158.27 +- 0.79 4 / 4 +3.4% yes
32000 93.26 +- 0.68 149.75 +- 0.35 4 / 4 +60.6% yes
64000 60.81 +- 0.19 144.12 +- 0.16 4 / 4 +137.0% yes
depth master t/s this PR t/s samples change beyond both spreads
0 202.93 +- 1.03 206.14 +- 1.81 4 / 4 +1.6% yes
12000 149.98 +- 0.63 158.27 +- 0.79 4 / 4 +5.5% yes
32000 113.50 +- 0.30 149.75 +- 0.35 4 / 4 +31.9% yes
64000 80.60 +- 0.14 144.12 +- 0.16 4 / 4 +78.8% yes

With the kernel, prompt speed hardly depends on depth: 158 t/s at 12k and 144 t/s at 64k cells, because every query
attends to 2051 cells however long the cache is. At 64k this is 2.4x #115 and 1.8x master's dense (and wrong) path.

Decode at depth, llama-bench -p 0 -n 128 -d 0,12000,32000,64000 -r 2, run in the order #115, #117, this PR, this
PR, #117, #115 (4 samples each):

depth #115 tg128 t/s this PR tg128 t/s change
0 10.64 +- 0.02 10.65 +- 0.03 +0.0%
12000 9.16 +- 0.03 9.06 +- 0.05 -1.1%
32000 7.85 +- 0.02 7.90 +- 0.02 +0.7%
64000 6.45 +- 0.03 6.61 +- 0.03 +2.5%

Decode at depth is limited by the indexer, which scores the whole cache for each token. #117 (the incremental QSA
cache) is what speeds that up; the two combine.

Device memory near a full context: llama-server -c 262144 -np 4 -kvu, MTP n=1, GGML_VK_MEMORY_LOGGER=1, 174k
cells resident, slot 0 reads a 4096-token prompt:

build ggml-vulkan device total, before -> peak allocations and frees >= 64 MiB prompt speed
#115 74.05 -> 73.45 GiB 63 (the scratch and two compute buffers of ~1.0 GiB and ~425 MiB, every ubatch) 88 t/s
this PR 72.59 -> 72.59 GiB 24: a ~810 MiB and a ~175 MiB compute buffer grow by 1-2 MiB every ubatch 107 t/s

The maskless path needs about 1.5 GiB less device memory than the per-cell path. Two compute buffers still grow with
the cache and are reallocated every prompt ubatch. The buffers reserved at load are most likely sized for the masked
graph, not for this one. That is left for a follow-up; it does not change results.

Correctness (307c50d):

Requirements

  • I have read and agree with the contributing guidelines
  • This change is Strix Halo specific, or justified by measurements on Strix Halo: it adds a Vulkan kernel for the
    fork's qwen4exp QSA attention. Our measurements come from Strix Point (gfx1150), not Strix Halo.
  • AI usage disclosure: AGENT-AUTHORED. Claude Opus 5.5 wrote the shader, the integration and this description, and
    ran every measurement. Every commit carries Assisted-by: Claude Opus 5.5.
  • What was NOT verified:
    • Strix Halo (gfx1151) by us (see @Nathanw1014's results in the comments), other Vulkan drivers than RADV, and
      subgroup sizes other than 64: the pipelines are only created with subgroup size 64.
    • ROCm and CUDA behaviour. The gate keeps HIP as before and excludes CUDA, but neither was run.

@Nathanw1014

Copy link
Copy Markdown

Tested this on a Strix Halo (gfx1151, Vulkan), on current master with #115: the maskless QSA tests pass (6/6 and 25/25) and Flash-Next perplexity is in line with the masked path. Looks good to me.

It needs a rebase after #111 too, since the Vulkan backend is split into several files now. I have a rebased copy here if it saves you the work.

I'm also working on a speed-up on top of it (loading K and V straight into the matrix registers). I'll open it as a separate PR stacked on this one once it's tested.

Assisted-by: Claude Opus 5.5

rcfa and others added 2 commits October 1, 2026 15:03
…ernels run

Complete-block selection builds a maskless flash-attention op whose visibility
lives only in the selected-cell list (src[5]). Only the selected-key kernels
read that list, and they exist only in the HIP backend (RDNA3.5); the gate did
not check the device. ggml-cuda aborts on the op when no QSA kernel takes it.
The Vulkan backend accepted it and ran its dense kernel, which ignores src[5],
so the QSA layers attended to the whole K/V view, unmasked, as soon as the
context exceeded indexer_top_k + ratio - 1:

- other sequences' cells: with several conversations in one unified cache, a
  conversation's output depended on another conversation's content (a
  2048-token prompt, greedy, changed at the 2nd token next to related text,
  where it had been 6.6 nats ahead; unchanged next to unrelated text)
- no sparsity: every cell instead of the top-k blocks

test-backend-ops shows it directly: QSA_PREFILL_MASKLESS fails on Vulkan
(0/6, ERR 0.48 to 0.98 against the FP64 reference).

- The memory decides once per model whether its QSA layers all run on ROCm
  (LLAMA_QSA_SELECTED_KEY=0/1 overrides that), and
  qwen4exp_use_block_selection() requires it. Everywhere else the masked
  top-k path is used, which the dense kernels compute correctly
  (QSA_PREFILL and QSA_DECODE pass on Vulkan except their poison=1 cases,
  which fail on master as well).
- Vulkan no longer claims flash attention with a selection list and no mask,
  so the op can never be computed wrongly there.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Complete-block selection builds a maskless FLASH_ATTN_EXT whose visibility
is only in the selection list (src[5]). Vulkan had no kernel that reads the
list: halo-box#115 makes it decline the op, and Vulkan then takes the masked
per-cell top-k path, which is correct but slow at depth.

flash_attn_sel.comp computes the op: one workgroup per query x K/V head,
one 64-lane subgroup per query head of that K/V head (GQA 4/8/12, head 256).
It sorts the query's list in shared memory and skips invalid and duplicate
entries, as the CPU path does, then gathers K and V in blocks of 32 cells.
Two lanes score one key, so the online softmax needs two subgroup
reductions per block. With fewer than 64 workgroups the rows are split,
and flash_attn_split_k_reduce combines the parts.

supports_op takes a maskless selected-key op exactly when this kernel does.
llama_memory_hybrid_idx asks each QSA layer's device with a probe op
instead of checking for ROCm by name. HIP stays accepted; CUDA is not
asked, because its supports_op ignores the list.

Radeon 890M (gfx1150), Qwen3.8-Flash-Next UD-IQ4_XS:
- test-backend-ops: QSA_PREFILL_MASKLESS 6/6 and QSA_DECODE_MASKLESS 25/25
  (master: 0/6 and 0/25), NMSE <= 6.9e-13 against the FP64 reference
- pp2048 at depth 12000 / 32000: 140.9 / 134.1 t/s (masked path, halo-box#115:
  139.0 / 88.7)

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rcfa

rcfa commented Oct 1, 2026

Copy link
Copy Markdown
Author

Thanks for testing on gfx1151. Good to see 6/6 and 25/25 there too.

Rebased onto 307c50d: the pipeline array went to ggml-vulkan-types.h and the push constants to ggml-vulkan-push-constants.h, as in your hb/pr119-rebase. The content is identical, so your stacked PR can sit on top: git rebase --onto 3bf53565b 4429170ab <your-branch> should apply unchanged.

On 307c50d (gfx1150), pp2048 at 32k / 64k is 149.8 / 144.1 t/s (#115: 93.3 / 60.8; master's wrong path: 113.5 / 80.6).

One thing that may matter for the speed-up: on this path two compute buffers (~810 MiB and ~175 MiB) are reallocated every prompt ubatch, growing by 1-2 MiB each time. The reserve at load is probably sized for the masked graph. Results don't change, but you'll see the churn in a memory logger.

Assisted-by: Claude Opus 5.5

@Nathanw1014

Copy link
Copy Markdown

Reproduced the buffer churn on gfx1151, on both Vulkan and HIP (ROCm 7.14). The load-time reserve only builds the maskless graph, which has no KQ mask. While the context is short (n_kv up to 2048 on Flash-Next), ubatches take the dense shortcut instead, so the buffers grow by the mask size until they've seen it.

Reserving that dense graph at load as well fixes it: no runtime reallocations in llama-server on either backend, about 8 MiB extra at ub2048, and identical perplexity. On Vulkan, perplexity also runs about 2% faster. HIP perplexity still grows the device buffer once (about 38 MiB) with or without the fix; I haven't tracked that one down yet.

It's llama-side only and applies cleanly on this PR: hb/qsa-reserve-dense. Happy for you to pull it in here, or I can open it separately.

Assisted-by: Claude Opus 5.5

@rcfa

rcfa commented Oct 2, 2026

Copy link
Copy Markdown
Author

Thanks, that's a clear diagnosis. I tried hb/qsa-reserve-dense on top of this PR on gfx1150 (Vulkan, 307c50d):

  • llama-perplexity -c 4096: identical result (4.6348), about 1% faster (25.80 -> 25.47 s per pass), +8 MiB at load. So it does what you describe for the short-context graphs.
  • The churn I mentioned is a different case, though, and it doesn't change. With 174k cells of other conversations resident in llama-server (-np 4 -kvu, MTP), a 4096-token prompt still reallocates the ~810 MiB and ~175 MiB buffers. The probe gives the same 24 events of at least 64 MiB with and without the fix. The first grows by ~2 MiB per 512-token ubatch, the second by ~1 MiB every other ubatch. That looks like tensors sized by the whole pool (n_kv) rather than by the sequence's own positions, while the load-time reserve sizes them from a single sequence. I haven't pinned it down yet.

I'd keep your fix as its own PR, since it's llama-side and covers HIP too. Stacking it on this one is fine.

Assisted-by: Claude Opus 5.5

@rcfa
rcfa marked this pull request as ready for review October 4, 2026 16:51
@Nathanw1014
Nathanw1014 merged commit 9e22f8c into halo-box:master Oct 6, 2026
1 check passed
rcfa added a commit to rcfa/strix-llama.cpp that referenced this pull request Oct 7, 2026
On master, Flash-Next takes the selected-key path on Vulkan (halo-box#119), where
the QSA block selection is a GGML_OP_TOP_K over the KV cells.
ggml_vk_topk sizes its double buffer in the shared scratch (prealloc_x)
to the exact need, so the scratch was still reallocated as the context
grew: 172, 173, 174, 176 MiB during one 4096-token prompt next to 174k
resident cells. Size it with ggml_vk_scratch_size() like the other
context-sized scratches; below 64 MiB (any ordinary top-k) nothing
changes. Its passes bind dbl_buf_size subranges, not the whole buffer.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rcfa added a commit to rcfa/strix-llama.cpp that referenced this pull request Oct 9, 2026
On master, Flash-Next takes the selected-key path on Vulkan (halo-box#119), where
the QSA block selection is a GGML_OP_TOP_K over the KV cells.
ggml_vk_topk sizes its double buffer in the shared scratch (prealloc_x)
to the exact need, so the scratch was still reallocated as the context
grew: 172, 173, 174, 176 MiB during one 4096-token prompt next to 174k
resident cells. Size it with ggml_vk_scratch_size() like the other
context-sized scratches; below 64 MiB (any ordinary top-k) nothing
changes. Its passes bind dbl_buf_size subranges, not the whole buffer.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants