Skip to content

qwen4exp : gather-based sparse attention for QSA decode - #28130

Closed
abdel-darwish-27 wants to merge 2 commits into
ggml-org:masterfrom
abdel-darwish-27:qwen4exp-qsa-gather
Closed

qwen4exp : gather-based sparse attention for QSA decode#28130
abdel-darwish-27 wants to merge 2 commits into
ggml-org:masterfrom
abdel-darwish-27:qwen4exp-qsa-gather

Conversation

@abdel-darwish-27

Copy link
Copy Markdown

Problem

At decode time the qwen4exp QSA path builds a full-n_kv mask and attends over the entire KV cache, so the lightning indexer's top-k saves memory only, not compute: nsys at 141K context shows flash_attn_ext_f16 at ~15 ms per QSA layer per token, and decode throughput collapses with depth.

Change

Decode-shaped ubatches (n_tokens == n_stream) take a gather path instead: block-level top-k on the per-block indexer scores, winners mapped to cell indices via blk_cells, then K/V and the mask values for the selected top_k cells are gathered with ggml_get_rows and attended densely — the attention works over ~2K cells instead of the whole cache. Prefill and mixed batches keep the existing masked path. QWEN4EXP_QSA_GATHER=0 opts out at runtime.

The second commit feeds the gather path from a compact per-cell mask row instead of the FA-padded full-n_kv kq_mask, removing ~18 MB of host->device mask traffic per token at 141K, and skips the O(n_kv) host-side cell_blk fill when the graph does not reference it.

Correctness

Retrieval and factual prompts are byte-identical between masked and gather at all tested depths (needle retrieval buried at 15% of a 129.6K-token document: correct and md5-identical in both modes; a 12-primes answer: md5-identical in both modes at 30.8K/61.8K/129.6K). Long open-ended generations (200+ tokens) can diverge after ~150 tokens — but the same divergence occurs between two runs of unpatched master with identical settings (measured), i.e. run-to-run fp nondeterminism, not a property of this change.

Performance

Qwen3.8-Flash-Next UD-IQ4_XS, 2x RTX A6000 (sm_86), q8_0 KV, single stream, temp 0, master e4b9af00. Two reps per cell, contention-free samples only (foreign GPU activity sampled at 1 Hz during every measurement):

KV depth masked (master) gather (this PR)
129.6K 16.9-17.0 tok/s 23.8-24.7 tok/s (+41-45%)
61.8K 25.4-26.9 tok/s 27.6-34.3 tok/s
30.8K 36.4 tok/s 39.3-42.4 tok/s

Combined with #28128 and #28129, 129.6K-deep decode reaches 29.8 tok/s (+75% vs master).

Known limitation / follow-up

Both paths still recompute pooled block keys from the raw indexer cache every QSA layer per token, which keeps some O(n_kv) depth scaling. An incremental pooled-key cache would make QSA decode ~O(top_k); happy to discuss or attempt it.

Notes

This is the master port of unslothai#165 (same change against their staging branch, measured there before #27742 merged). nsys profiles and full benchmark methodology available on request.

At decode time build_attn_qsa built a full-n_kv mask (-INF everywhere with the
top-k positions unmasked) and ran attention over the entire KV cache, so the
indexer's top-k selection saved no attention compute. A kernel profile at 141K
context shows flash_attn_ext_f16 at ~15 ms per QSA layer per token (~180 ms
per token over 12 QSA layers); decode collapses from 53 tok/s shallow to
~7 tok/s at 141K on 2x RTX A6000.

Add a decode-only gather path, taken for single-token-per-stream ubatches once
the cache is at least twice the top-k width:

- select winning *blocks* directly on the per-block indexer scores (the block
  bias already carries visibility), avoiding the O(n_kv) expansion of block
  scores to token scores and sorting n_blocks entries instead of n_kv
- map winning blocks to cell indices via blk_cells and gather the selected
  cells' K/V (whole-cell rows; a cell's heads are contiguous in the cache)
  plus their kq_mask values, then attend densely over r*K_blk cells (2048 for
  Qwen3.8-Flash-Next), a multiple of 256 so flash attention padding holds
- skip the O(n_kv) host-side cell_blk fill when the graph never references it

QWEN4EXP_QSA_GATHER=0 restores the masked path (same binary A/B lever).

Correctness: greedy outputs byte-identical to the masked path at 75K and 141K
depth; mid-context needle retrieval passes in both modes at all tested depths.

Decode throughput, UD-IQ4_XS, q8_0 KV, single stream (repeats within 0.1 t/s):
  depth   masked   gather
   34K     17.4     19.4   (+11%)
   68K     12.0     13.9   (+16%)
  141K      7.1      8.8   (+23%)

The remaining depth scaling in both modes is the indexer recomputing pooled
block keys from the raw cache every layer per token; caching those
incrementally is a follow-up.
The gather path only reads one row of the attention kq_mask (to carry each
selected cell's visibility into the gathered attention), but referencing it
kept the whole FA-padded tensor alive: an O(n_kv x GGML_KQ_MASK_PAD) host fill
plus an n_kv x 64 x 2-byte upload every decode step (~18 MB/token at 141K ctx,
measured as the largest H2D stream during decode, with the staging copy
attributed to the driver at ~14% of decode CPU).

Add a compact F32 [n_kv, n_tps, n_stream] visibility row to the QSA input set,
filled in set_input_qsa alongside the existing per-token pass and gathered in
place of the kq_mask row. The attention kq_mask then goes unreferenced in
gather graphs and is neither filled nor uploaded; llm_graph_input_mem_hybrid
now skips it when unallocated, matching llm_graph_input_attn_kv.

Upload drops 18 MB -> 1 MB per token. Outputs remain byte-identical to the
masked path; mid-context needle retrieval passes at 68K and 141K.
@ggml-gh-bot

ggml-gh-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

Hi @abdel-darwish-27, thanks for your contribution!

Per our contribution guidelines, the automated PR checker found the following issue(s) that need your attention:

  • PR Template not respected: Please respect the template when creating a new pull request. Make sure to fill out all required sections.

  • Multiple open PRs from a new contributor: We limit new contributors (those without a previously merged PR) to 1 open PR at a time. You currently have 3 open PRs.

  • AI-generated content: While code is allowed to be generated by AI, please write the PR description and commit messages on your own without the help of AI.


Please note that maintainers reserve the right to make final decisions on PRs. If you believe there is a mistake, please comment below.

@ggml-gh-bot ggml-gh-bot Bot added the draft PR will be changed to draft by github-actions bot label Sep 1, 2026
@github-actions
github-actions Bot marked this pull request as draft September 1, 2026 00:08
@github-actions github-actions Bot removed the draft PR will be changed to draft by github-actions bot label Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

model Model specific

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant