Skip to content

qwen4exp : use maskless block selection only where the selected-key kernels run - #115

Merged
Nathanw1014 merged 1 commit into
halo-box:masterfrom
rcfa:pr/qsa-vulkan-leak
Oct 6, 2026
Merged

Nathanw1014 merged 1 commit into
halo-box:masterfrom
rcfa:pr/qsa-vulkan-leak

Conversation

@rcfa

@rcfa rcfa commented Sep 30, 2026 •

Copy link
Copy Markdown

Overview

Fixes #130.

Complete-block selection builds a maskless FLASH_ATTN_EXT op, where the visibility is only in the selection list
(src[5]). Only the selected-key kernels read that list, and those kernels exist only in the HIP backend for
RDNA3.5. The gate does not check the device. On Vulkan the dense kernel runs the op, ignores the list, and attends
to every cell of the K/V view: other sequences' cells, the later tokens of the query's own ubatch (look-ahead), and
all cells instead of the top-k blocks.

  • llama_memory_hybrid_idx decides once per model whether all QSA layers run on ROCm (selected_key_attn, logged
    at load). LLAMA_QSA_SELECTED_KEY=0/1 overrides that decision for diagnosis.
  • qwen4exp_use_block_selection() requires selected_key_attn. On any other backend the masked top-k path is
    used, and the dense kernels compute it correctly.
  • The Vulkan backend no longer accepts flash attention with a selection list and no mask, so the op cannot be
    computed wrongly there, whatever the caller does.

When all QSA layers run on ROCm, the gate gives the same result as before. When only some of them do (partial
offload), all of them now use the masked path, because the maskless op must not reach a layer on another backend.

This PR adds one clause to the gate, and #110 turns the gate into a reason classifier. The two PRs touch the same
lines. Whichever merges second, we rebase it: here the clause becomes one more refusal reason ("no selected-key
kernels on this backend").

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:   307c50d26cf9f0c56d135b281e57b14296b07ceb (master, merge-base), built and run in the same session
Change:     72f0f12b7caff3b8b1e09074d17f994807079752
Model:      unsloth/Qwen3.8-Flash-Next-GGUF, UD-IQ4_XS

The baseline is not a correct reference past 2051 cells: that is the bug. So speed at depth compares the wrong
(dense, unmasked) path with the correct masked top-k path.

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

Baseline: (307c50d)

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

After: (72f0f12)

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
depth baseline t/s after t/s samples change beyond both spreads
0 202.93 +- 1.03 202.12 +- 0.95 4 / 4 -0.4% no
12000 149.98 +- 0.63 153.01 +- 0.67 4 / 4 +2.0% yes
32000 113.50 +- 0.30 93.26 +- 0.68 4 / 4 -17.8% yes
64000 80.60 +- 0.14 60.81 +- 0.19 4 / 4 -24.5% yes

The fix costs prompt speed at depth, 18% at 32k and 25% at 64k. The baseline was faster there because it skipped
the top-k selection and attended to every cell in one dense pass. The correct path is the masked per-cell top-k,
which builds [n_kv, strip] tensors that grow with the context. Decode was 4-18% faster after the fix in the
llama-server runs below (one conversation, 12.7k-53.4k tokens). #119 adds a Vulkan kernel that reads the selection list, like the HIP
selected-key kernels. With it, prompt speed is 149.8 t/s at 32k and 144.1 t/s at 64k (this PR: 93.3 and 60.8), and
the masked cells are skipped instead of weighted with zero.

Correctness:

  • test-backend-ops -b Vulkan0, baseline -> change:

    test baseline change
    QSA_PREFILL_MASKLESS 0/6 passed (ERR 0.48 to 0.98) 6 not supported, none run
    QSA_DECODE_MASKLESS 0/25 passed 25 not supported, none run
    QSA_PREFILL 22/26 22/26, the same 4 poison=1 cases fail
    QSA_DECODE 82/92 82/92, the same 10 poison=1 cases fail
    TOPK_QSA 4/4 4/4
    FLASH_ATTN_EXT 5344/5344 (11 not supported) 5344/5344 (11 not supported)

    The poison=1 cases put NaN into masked cells. Vulkan fails them before and after this change: its dense kernel
    still reads masked cells. The masked path is correct for finite cache contents, but it does not isolate a sequence
    from a NaN in another sequence's cells. The selected-key kernels would not read those cells at all. That gap
    already exists and is not addressed here.

  • The four repository corpora (tests/corpus/correctness-*.txt) go through llama-server (1 slot, no MTP). For
    each one we compare the prompt token IDs, the complete next-token distribution after prefill (every
    vocabulary entry) and 64 greedy steps with their top-20 log-probabilities:

    • as is (570-876 tokens, below 2052 cells, so the path does not change): bit-identical to the baseline for
      all four corpora, including every log-probability and all 64 decode steps.
    • repeated to 12.7k-53.4k tokens (block selection active): they differ, as expected, because the baseline runs the
      wrong path there. The top token after prefill is the same for all four, and so are all 64 greedy tokens. The
      distributions move by KL 0.002 (prose, 12.7k), 0.004 (code, 22.8k), 0.098 (structured, 27.1k) and 0.013
      (numeric, 53.4k).
  • Look-ahead. llama-perplexity -c 4096 -b 2048 -ub 512 scores positions 2048-4095 of each chunk, where block
    selection runs. The text is one the model cannot have memorised: this fork's own docs, written in August and
    September 2026 (docs/development/DSV4-vulkan-sparse-prefill-progress.md, backend-scheduler.md,
    DSV4-vulkan-lightning-indexer-progress.md, rocm-mmb-context.md, vulkan-sparse-fa-deferred.md,
    docs/speculative-prefill.md and docs/build-profiling.md at 307c50d, concatenated in that order, sha256
    a3b9cd54...; 5 chunks at -c 4096, 10 at -c 2048):

    context baseline this PR vulkan : selected-key flash attention for QSA block selection #119
    4096 (block selection) 1.1422 +/- 0.0071 4.6444 +/- 0.1079 4.6348 +/- 0.1076
    2048 (below 2052 cells, control) 5.2977 +/- 0.1273 5.2977 +/- 0.1273 5.2977 +/- 0.1273

    A perplexity of 1.14 on text the model has never seen means the baseline sees the tokens it is asked to predict:
    the dense kernel attends to the later tokens of the query's own ubatch. Below the block-selection threshold all
    three builds agree to the last digit. @Nathanw1014 measured the same on Strix Halo (gfx1151): 1.06 on master,
    3.94 with this PR, at -c 4096 (comment below).

  • llama-perplexity, 16384 context, -b 2048 -ub 512, 2 chunks of Python standard-library source (the first
    400k characters of /usr/lib/python3.14/*.py in sorted order, sha256 108e868c...): baseline 1.0303 +/- 0.0030,
    after 1.0664 +/- 0.0046. The model has largely memorised this text, so every value is close to 1, and look-ahead
    can lower it only a little. The baseline's lower value is the same look-ahead, not a better result.

  • Isolation, -c 65536 -np 4 -kvu, no MTP: slot 0 generates 48 greedy tokens from a 2048-token prompt alone, next to
    three 16k-token conversations X (related text) and next to three Y (unrelated text):

    • baseline on 12643af: alone vs X differ at token 2 (top-2 gap 6.6 when alone, so not a near-tie), alone vs Y
      identical, X vs Y differ at token 2. The output depends on the other conversations' content.
    • baseline on 307c50d: with the same prompts the 48 tokens no longer differ, so we added a second set: the probe
      is the four repository corpora (2048 tokens), X repeats them, Y is Python standard-library source. X vs Y differ
      at token 9, where X's top-2 gap is 1.86, so not a near-tie: the output still depends on the other conversations'
      content. Instrumentation shows the Vulkan kernel running the maskless op without a mask over all 50176 cells of
      the view, the residents' cells included, for the probe's prompt and for every decode step.
    • after: alone, next to X and next to Y give the same 48 tokens with the first set. With the second set, X and Y
      give the same 48 tokens with identical log-probabilities. Alone vs next to residents differ at token 26, a
      near-tie (top-2 gap 0.04) that X and Y share, so it comes from the larger cache's rounding, not from the other
      conversations.
  • Ubatch size: a 6000-token prompt with -ub 512 and with -ub 256 gives the same 32 tokens after the change (max
    top-1 log-prob difference 0.0010). That test compares only the tokens generated after the prompt, so it does not
    see look-ahead inside the prompt; the perplexity test above does.

  • All results above are from master 307c50d after the rebase, except where a revision is named.

Additional information

Requirements

  • I have read and agree with the contributing guidelines
  • This change is Strix Halo specific, or justified by measurements on Strix Halo: it fixes a correctness bug in the
    fork's qwen4exp / QSA code on the Vulkan backend. It does not depend on the device. The measurements come from
    Strix Point (gfx1150), not Strix Halo.
  • AI usage disclosure: AGENT-AUTHORED. Claude Opus 5.5 found the bug, wrote the change, the tests and this
    description, and ran every measurement. Every commit carries Assisted-by: Claude Opus 5.5.
  • What was NOT verified:
    • ROCm/HIP. The change is meant to leave it unchanged, but it was not run: there is no ROCm build of this fork
      for gfx1150 here, and ROCm's GTT limit cannot hold this model on our machine.
    • CUDA and the CPU backend.
    • Strix Halo (gfx1151) hardware: none of our numbers come from it; @Nathanw1014's are in the comments.

@Nathanw1014

Copy link
Copy Markdown

Tested this on a Strix Halo (gfx1151, Vulkan) and it fixes it for us. With Flash-Next on current master, perplexity at -c 4096 is 1.06, and with this PR it's back to 3.94.

It needs a small rebase after #111: both changed the same line in qwen4exp_use_block_selection. Keeping both terms is all it takes:

        ggml_type type_k, ggml_type type_v, bool selected_key_attn) {
    return selected_key_attn && blk_bias && cparams.causal_attn && n_stream==1 && ratio>1 && hparams.indexer_top_k%ratio==0 &&

The rebased commit is on my fork if you want it, and I'm happy to push it to your branch if that saves you a step.

Assisted-by: Claude Opus 5.5

…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>
rcfa added a commit to rcfa/strix-llama.cpp that referenced this pull request Oct 1, 2026
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 force-pushed the pr/qsa-vulkan-leak branch from 2b74a36 to 72f0f12 Compare October 1, 2026 23:11
@rcfa

rcfa commented Oct 1, 2026

Copy link
Copy Markdown
Author

Thanks for testing this on gfx1151, and for the rebase.

I rebased onto 307c50d with the resolution you suggested (cparams.causal_attn kept next to selected_key_attn). The result matches your hb/pr115-rebase line for line, so there's no need to push to this branch.

Your perplexity numbers were the clue we had missed. Our ubatch-size check only compares the tokens generated after a prompt, so it could not see look-ahead inside the prompt. On gfx1150, with text the model cannot have memorised (this fork's own August/September docs), at -c 4096: master 1.14, this PR 4.64, #119 4.63. At -c 2048, below the block-selection threshold, all three give 5.30. The description now includes this, and every result in it was measured again on 307c50d after the rebase.

One more thing changed with the new master: our token-level isolation test no longer flips on master, but the leak is still there. With a second set of prompts, master's output depends on the other conversations' content: it diverges at token 9, where the gap is 1.86, so not a near-tie. Instrumentation shows the Vulkan kernel reading all 50176 cells of the view, the residents' cells included. Details are in the description.

Assisted-by: Claude Opus 5.5

@Nathanw1014
Nathanw1014 merged commit e2fd88f into halo-box:master Oct 6, 2026
6 of 9 checks passed
gaetan-puleo pushed a commit to rcfa/strix-llama.cpp that referenced this pull request Oct 16, 2026
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>
(cherry picked from commit 3bf5356)
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.

Vulkan: wrong results with flash attention + f16 KV cache on Qwen3.8-Flash-Next (perplexity 1.05 vs 1.55 reference), reproduced on b1004 and b1005

2 participants