qwen4exp : gather-based sparse attention for QSA decode - #28213
qwen4exp : gather-based sparse attention for QSA decode#28213abdel-darwish-27 wants to merge 1 commit into
Conversation
|
Tested on a much slower/lower-end box than the datacenter targets, and the gather path is a clear win — but it exposes the next bottleneck, which I think is worth spelling out. Setup: Qwen3.8-Flash-Next Method: 65,715-token prompt, 80-token decode, same binary toggled via
So the gather path gives +26% at 60K and no regression at short context. Confirmed working, thanks. But — with attention now O(top_k) and constant, decode at 60K is still 2× slower than at 2K (19.6 vs 39.8). The remaining per-step cost that scales with context is the selection itself: For anyone hitting the same wall: the indexer selection (not the attention) is where the next quadratic→linear win lives. A CUDA radix/partial top-k path would mirror what already landed for Vulkan (#28032) and ROCm (#27466) — CUDA still uses the generic top-k here. Happy to A/B a candidate if one shows up. |
|
tg 1024, all on master: Same gain at depth, 28213 loses 4% at 4k (full per-cell bias + width rounded to 2304). EDIT: |
|
Follow-up after running a few more days with the gather path on this box (Qwen3.8-Flash-Next UD-Q3_K_XL, 8 GPUs — 2×3090 + 6×90HX, all Gen1 x4; host 15 GB RAM, Xeon E5-2620 no AVX2). A/B on the same binary, 60K context, q8_0 KV, single slot:
Two observations that might help:
#28040 was the only thing that moved decode at depth here (+10%); thanks for splitting #27977 into reviewable pieces, the incremental commits are much easier to A/B than the original mega-PR. |
|
Just for your awareness: |
50d50ba to
d5b9e81
Compare
|
Thanks for that, it's already fixed by the rebase on the PR. |
|
I think the remaining depth scaling is probably coming from rebuilding the indexer's pooled/roped block keys every decode step over the full cached context. Since only the newest block changes, caching those block keys and updating them as you go seems like the fix, but that needs its own cache lifecycle and is a bit beyond this PR. |
The indexer used to pick the top 2048 KV cache entries, but those selections were turned into a mask over the full KV cache, so attention still ran across the entire context with unselected positions masked out. This meant the attention cost continued to grow with context length even though only ~2k tokens were actually being attended to. This patch instead gathers the selected keys and values into a compact buffer and runs regular dense attention over the gathered set. The attention mask is derived from the existing per-cell bias values, so this does not require any new model inputs. Prompt processing and batched inference are unchanged and continue to use the existing masked path. QWEN4EXP_QSA_GATHER=0 disables the gather path at runtime. On dual RTX A6000, IQ4_XS, q8_0 KV cache: 130k context decode 15.7 up to 23.6 tok/s (+50%), 62k +19%, 31k +6%.
d5b9e81 to
beed2f7
Compare
|
Data point from a PCIe multi-GPU desktop, plus one thing I found trying it with speculative decoding. Rig: 2x RTX 3090, 1x 5060 Ti, 2x 4060 Ti (96 GB VRAM total), Qwen3.8-Flash-Next UD-Q4_K_XL, q8_0 KV, part of the experts on CPU via --fit. Same binary, toggled with QWEN4EXP_QSA_GATHER, greedy, 256 tokens generated, no speculation:
Prefill unchanged, no asserts, run-to-run spread under 1.5%. Matches the shape of your A6000 numbers. With the unsloth MTP head (--spec-type draft-mtp, n-max 2) the patch does nothing, though: the gate is n_tokens == n_stream and verification passes draft+1 tokens per ubatch, so the gather path never runs. Measured 22.35 vs 22.86 t/s at 101k, inside noise. I think every number posted here so far is spec-off. I tried opening the gate for small ubatches. Each token in the ubatch already has its own top-k list from build_qsa_top_k, so in build_attn_qsa I loop over the n_tps tokens, gather K/V by that token's list and its own bias row, run build_attn_mha per token over the 2304 gathered rows, and concat. Gate becomes n_tps <= 16 && n_kv >= 2widthn_tps, which is the same condition at n_tps 1. It is exact, each query sees the same set the masked path unmasks for it. At 101k with draft-mtp n-max 2 the verification step goes from 114.3 ms to 106.5 ms (-6.8%); the gain is smaller than spec-off because the step also carries two draft forwards. I also tried a batched version, one get_rows over all the lists and one attention over width*n_tps rows with a block-diagonal mask, and it was slower (109.0 ms). Happy to put the per-token diff up if you want it in this PR or as a follow-up. One small thing for anyone cherry-picking this onto a tree without #27970: the build_attn_mha call in the gather block needs the extra n_topk argument dropped, the inverse of the master-side note above. |
|
Gather now only kicks in once the cache is over 4x the top-k width (~9k cells), below that it's the unchanged masked path. My testing shows this fixes it: |
|
Profiled where the rest of the depth cost goes on the gather path, on the 140 MB micro model from Lynxpda/micro-qwen4exp (same indexer shapes as the real model: 4 heads, key 128, top_k 2048, ratio 4, 3 QSA layers), one 3090, this PR's tree, nsys with CUDA graphs off, last 31 decode tokens of a tg32 run. Per token, 3 QSA layers:
Flash attention is flat. All of the growth is in build_qsa_top_k, per QSA layer at n_kv 131072:
For the 48-layer model that is 12 QSA layers, so roughly 14 ms per token at 131k on a 3090 before any of the attention work, which matches the gap we still see between shallow and 100k+ decode with the gather on. |
|
Metal data point, Apple M2 Ultra (192 GB). Same build for every row: this PR's head ( Decode tok/s:
Prefill is unchanged by the gather (522 / 451 / 332 vs masked 522 / 454 / 335). On Metal the gather is neutral against masked at both depths, so no regression, but also none of the CUDA gain: the FA-vec kernel here already skips fully masked 32-cell chunks, so the masked scan was never the expensive part. All three QSA paths sit 20% (32k) to 31% (128k) below dense, and |
Overview
This changes QSA sparse attention for qwen4exp during single-token decode so attention only runs over the tokens selected by the indexer.
The indexer used to pick the top 2048 KV cache entries, but those selections were turned into a mask over the full KV cache, so attention still ran across the entire context with unselected positions masked out. This meant the attention cost continued to grow with context length even though only ~2k tokens were actually being attended to. I noticed this because Qwen3.8 Flash was really slowing down at longer contexts compared to other models.
This patch instead gathers the selected keys and values into a compact buffer and runs regular dense attention over the gathered set. The attention mask is derived from the existing per-cell bias values, so this does not require any new model inputs.
Prompt processing and batched inference are unchanged and continue to use the existing masked path.
The gather path can be disabled at runtime with:
This was also used for A/B benchmarking, so the numbers below compare the same binary with the gather path enabled and disabled.
Additional information
Tested on dual RTX A6000 GPUs with an IQ4_XS model, q8_0 KV cache, and temperature 0:
You can see the improvement gets larger as the context grows. At 141k context, the old sparse attention kernel was taking roughly 15 ms per sparse layer per decoded token, across 12 sparse layers.
The gather graphs also no longer reference the full attention mask, which avoids uploading roughly 17 MB of mask data per token at 130k context.
The change is about 110 lines across src/models/qwen4exp.cpp, src/models/models.h, and a small guard in src/llama-graph.cpp. The guard skips filling the large attention mask when the graph does not allocate a buffer for it, matching the existing handling for other optional attention inputs.
I made sure to test that retrieval and short factual answers came out byte identical between the gather and masked paths at 31k, 62k, and 130k contexts every time.
Long open-ended generations can diverge after roughly 150+ tokens, but I observed the same behaviour between repeated runs of the unpatched build. Seems to be just normal GPU run-to-run nondeterminism rather than a difference introduced by the gather path.
All 53 CPU tests pass, and I also validated the CUDA build end-to-end with the real model.
Requirements