Skip to content

vulkan : keep masked keys from leaking NaN through flash attention - #127

Merged
gaetan-puleo merged 2 commits into
halo-box:masterfrom
Nathanw1014:hb/vk-fa-nansafe
Oct 3, 2026
Merged

gaetan-puleo merged 2 commits into
halo-box:masterfrom
Nathanw1014:hb/vk-fa-nansafe

Conversation

@Nathanw1014

Copy link
Copy Markdown

This fixes the poison=1 QSA test failures on Vulkan. If a masked key's K or V row holds a NaN, flash attention still lets it through (the mask is added to the score, and V is multiplied by a probability of 0, which stays NaN).

On a Strix Halo (gfx1151, RADV): QSA_PREFILL 22/26 -> 26/26, QSA_DECODE 82/92 -> 92/92, FLASH_ATTN_EXT all passing. Prefill on 27B and 35B was unchanged at depth 0, 12k and 32k when I measured it before #111.

Written with Claude, tested by me.

Assisted-by: Claude Opus 5.5

Masked keys are added to the score and multiplied by P = 0, so a non-finite
K or V row of a masked key poisons the output (-inf + NaN, 0 * NaN). Only
whole-block masks were skipped. The CPU reference and the HIP selected-key
kernels skip masked keys per key; the QSA_PREFILL / QSA_DECODE poison tests
check exactly that.

- scalar and coopmat1: a masked score (mask <= -65504, the f16 -inf or the
  lowest f16 some writers use for it) is set to -inf instead of dot + mask,
  so non-finite K cannot reach the softmax. Free: the pipelines keep their
  registers and occupancy.
- V (flag NAN_SAFE_V, a new Flags bit): the scalar path skips the P * V
  update for masked (row, key) pairs; coopmat1 marks keys hidden from every
  row of the tile (4-lane subgroup AND, per-key stamps, no reset) and stages
  V with those rows zeroed. The host sets the flag for selected-key attention
  (src[5]), whose selections can name any cell, and for every masked FA with
  GGML_VK_FA_NAN_SAFE=1 (e.g. a unified multi-sequence cache). Dense attention
  relies on the KV cache zeroing freed cells.

gfx1151 RADV, test-backend-ops -b Vulkan0: QSA_PREFILL 22/26 -> 26/26,
QSA_DECODE 82/92 -> 92/92, FLASH_ATTN_EXT 5339/5339 (also with
GGML_VK_FA_NAN_SAFE=1).

RADV shader stats, cm1 hs256 f16: dense pipeline 120 VGPRs / 12 waves per
SIMD, unchanged; NAN_SAFE_V pipeline 192 / 8 (the V staging).
llama-bench pp2048 ub2048, ABBA n=4, before -> after:
- Qwen3.8-27B d0/12k/32k: 371.1/318.4/262.2 -> 370.2/318.4/261.6 (flat)
- Qwen3.6-35B-A3B d0/12k/32k: 1727/1336/975 -> 1727/1339/972 (flat)
- Qwen3.8-27B tg128 @ d8192: 12.03 -> 12.03
- Flash-Next REAP-320 (QSA, flag set) d0/6k: 647.8/564.9 -> 641.1/543.1 (-1.0/-3.9%)
- Qwen3.8-27B d32k with GGML_VK_FA_NAN_SAFE=1: 263.0 -> 235.5 (-10.5%)
Flash-Next wikitext PPL -c 4096: 3.9310 -> 3.9310.

Note: a subgroupShuffleXor on a bool value gave wrong results in this shader
on RADV/ACO (partial-tile FA tests failed); the shuffles use uint.

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 with -DGGML_VULKAN=ON at the PR head 07afdcf0c. To compare against master I reverted the five changed files to 1531bf5c4 and relinked in the same tree, so both runs use identical build settings (GGML_NLOOP=1, GGML_N_THREADS=1, coopmat enabled).

test master 1531bf5c4 PR 07afdcf0c
-o QSA_PREFILL 22/26 26/26
-o QSA_DECODE 82/92 92/92
-o FLASH_ATTN_EXT — 5344/5344

The four prefill failures on master are exactly the poison=1 cases (513,512,17), (640,4096,257), (128,512,17), (129,4096,257) with ERR ≈ 1.0. This reproduces your 22→26 and 82→92 numbers.

Code review

The diagnosis is right, and so is the fix. The masked score is dot(Q, K) computed before the bias is added; a NaN in the poisoned K row makes it NaN, and adding an -inf mask bias leaves it NaN (then exp and 0 * NaN propagate it). Setting masked scores straight to -inf and dropping V contributions for keys hidden from every row is the correct way to break the chain.

  • FA_MASK_DEAD = -65504 catches both f16 -inf and the saturated lowest finite value; no legitimate additive bias is that negative.
  • The dead_stamp[c] == j + 1 / dead_blk == j + 1 stamping avoids clearing shared state every block. The writes land before the existing barrier() (line ~254) that precedes the dead_blk read, and dead_stamp is read in the V phase after later barriers, so the cross-thread access is ordered. atomicMax handles the multiple-keys-per-tile case.
  • Forcing mv[k] = FA_MASK_DEAD on rows past N is a good catch: without it a partial tile's zero mask fill would count as visibility and the key would never be marked dead.
  • The cm1 per-key vote assumes Br / 4 == 4, i.e. Br == 16. That holds today because the coopmat path fixes block_rows = 16, and the shuffle groups don't straddle a subgroup boundary (subgroup size is a multiple of 4). Worth a comment/assert if cm1 Br is ever allowed to change, since subgroupShuffleXor(1,2) would then be wrong.
  • ggml_vk_flash_attn_coopmat_shmem_support now counts (Bc + 1) * sizeof(uint32_t) for every coopmat FA pipeline, not just NAN_SAFE_V ones. That mirrors the unconditional dead_stamp/dead_blk declarations, but it can in principle flip a borderline config off the coopmat path; I didn't hit one. Normal attention is untouched behaviourally (nan_safe_v is only set with a mask and src[5], or via GGML_VK_FA_NAN_SAFE=1), which the 5344/5344 FLASH_ATTN_EXT run supports.

Note: the windows (CI vulkan) check is red, but only on the pre-existing test-qsa-fast.cpp setenv build error — it is not from this PR and #138 fixes it.

LGTM. Real correctness fix, covered by tests that fail without it, reproduced on independent hardware.

@gaetan-puleo
gaetan-puleo merged commit b79812d into halo-box:master Oct 3, 2026
6 of 9 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.

2 participants