vulkan: support sparse Flash Attention - #28105
Conversation
On gfx1151 RADV the gather loses at every depth (11k -4.4, 115k -2.7 t/s vs masked, draft-mtp n-max 2): masked FA already skips fully-masked tiles, the per-block bias avoids the mask upload the gather exists to dodge, and gather mode forces the per-cell bias whose upload costs more than the gather saves. QWEN4EXP_QSA_GATHER=1 re-enables it for A/B. Revisit when Vulkan sparse FA (upstream ggml-org#28105) lands.
|
@0cc4m Hi, I tried this PR on a Strix Halo machine with the Vulkan backend. Short contexts seem to work fine, but with long contexts global attention appears to break. The generation stays locally coherent, but the model intermittently loses parts of the previous history/messages. |
bad0e3e to
5cfc32c
Compare
|
Please check with latest version. I'll try to reproduce it. |
|
I tested it and didn't see an issue, it gives correct responses with long context input. |
|
@0cc4m it seems that draft had prefill-sparse. There was an issue with |
|
qwen4exp is not supported by sparse FA yet, as far as I know. |
jeffbolznv
left a comment
There was a problem hiding this comment.
I haven't gone through all the code yet, but wanted to get some early feedback out..
Port PR ggml-org#28105's sparse flash-attention compaction and wire it to the qwen4exp QSA prefill mask. The mask of a QSA layer is exactly the top-k selection intersected with causality, so only n_kv_max (= top-k width) cells per row are finite; the backend now compacts those positions per mask row and flash attention reads K/V/mask through the per-row index list instead of scanning the whole cache. - ggml_flash_attn_ext_set_sparse stores the per-row finite bound in op_params[5] (op_params[4] stays the fork's n_kv_raw); CUDA fattn passes the hint through for reference. - flash_attn_sparse_compact.comp builds the per-row index list with a deterministic subgroup-ballot scan (ascending position order, -1 padded). Upstream's atomic slot assignment is a race: the list order is the softmax accumulation order, so the run-to-run bits differ; the scan makes the sparse path bit-stable and identical between the cache on/off arms, which the A/B harness requires. - vulkan FA pipelines gain USE_SPARSE (bit 16) and the fork's DYNAMIC_KV moves to bit 32; cm2's sparse-only tensor-layout updates and gather offsets stay behind USE_SPARSE so the dense specialization keeps its codegen (an unguarded runtime KV select halved dense throughput on gfx1151). - The sparse gate follows the tiling contract of the shader: one index list and one mask row are resolved per DISPATCH TILE, so every row of a tile has to be the same query. That holds when the rows are the gqa heads of one token (gqa_ratio > 1, i.e. decode); large-N shapes run with gqa_ratio == 1 and correctly decline to dense. It also declines when the cache is under max(4096, min_ratio * n_kv_max) cells. FA_SPARSE_DISABLE reverts to dense for A/B. - Extend flash_attn_union/gather_union with a KV-head dimension and a batch offset, plus a grouped prefill driver (64-row groups, opt-in via GGML_VK_FA_TOPK_UNION_GQA): one compact set per group with the scratch reused per group. Inert by default; the per-row sparse path measured ahead of any shared-set compaction. That pp512 measurement was the broken configuration: it took the shared-tile path, which is fast and wrong. With the tiling fixed (see the following commit), the sparse path serves decode (gqa_ratio > 1) and prefill declines to dense. Micro model pp512 and A/B figures above therefore do not describe this commit as merged; re-measure before quoting them.
| const uint v_row = j * Bc + row; | ||
| uint32_t vcol; | ||
| bool kv_active = fa_kv_index(j * Bc + row, vcol); | ||
| const uint v_row = USE_SPARSE ? vcol : (j * Bc + row); |
There was a problem hiding this comment.
Can this just use vcol unconditionally? It doesn't follow the same pattern as other replacements.
There was a problem hiding this comment.
Yes, that wasn't necessary. Fixed.
| const int r = data_sparse[sparse_base + blockCoords[0]]; | ||
| if (r < 0) { return f16vec4(0); } | ||
| const uint32_t o = g_k_off_elem + uint(r) * k_stride + blockCoords[1] * FA_GATHER_BS + coordInBlock[1]; | ||
| return f16vec4(data_kf16[o], data_kf16[o + 1], data_kf16[o + 2], data_kf16[o + 3]); |
There was a problem hiding this comment.
I think it should be possible to declare an f16vec4 binding and just do one load.
| const bool k_use_decode = (bs_k > 1u); | ||
| if (k_use_decode) { | ||
| if (USE_SPARSE) { | ||
| coopMatLoadTensorNV(K_T, data_k, k_offset, sliceTensorLayoutNV(tensorLayoutK, j * Bc, Bc, 0, HSK_pad), tensorViewTranspose FAGATHERK); |
There was a problem hiding this comment.
Should FAGATHERK just be called FADECODEK now and remove the branch?
There was a problem hiding this comment.
They use different addressing, so I don't think that's possible here.
There was a problem hiding this comment.
I don't understand. I'm just talking about renaming the #define, and then combining line 392 with line 394. I think this should work?
There was a problem hiding this comment.
They are distinguished by a spec constant flag in the same shader compile, I can only separate them if I compile them separately.
Overview
Vulkan support for #27970
Requirements