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
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%.
|
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. |
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