Skip to content

qwen4exp: gather-based sparse attention for QSA decode (+ cuda top-k batching fix) - #165

Open
abdel-darwish-27 wants to merge 4 commits into
unslothai:qwen4exp/qwen3.8-flash-nextfrom
abdel-darwish-27:qsa-gather-decode
Open

qwen4exp: gather-based sparse attention for QSA decode (+ cuda top-k batching fix)#165
abdel-darwish-27 wants to merge 4 commits into
unslothai:qwen4exp/qwen3.8-flash-nextfrom
abdel-darwish-27:qsa-gather-decode

Conversation

@abdel-darwish-27

Copy link
Copy Markdown

Summary

At decode time the 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. On 2x RTX A6000 (sm_86), UD-IQ4_XS, decode falls from 53 tok/s shallow to ~7 tok/s at 141K context; nsys shows flash_attn_ext_f16 at ~15 ms per QSA layer per token.

This PR adds a decode-only gather path (541f63a, details in the commit message): block-level top-k -> gather the selected 2048 cells + their mask values -> dense attention over the gathered set. QWEN4EXP_QSA_GATHER=0 opts out at runtime.

Three supporting fixes surfaced while profiling the same path:

  • aaa6297 cuda top-k batching: with CCCL >= 3.2, ggml_cuda_op_top_k looped DeviceTopK::MaxPairs per row; a 512-token prompt ubatch makes that 512 serial invocations per QSA layer. Now dispatches to the segmented-sort path for nrows > 4 (until DeviceSegmentedTopK lands, [RFE] Add DeviceSegmentedTopK NVIDIA/cccl#6391).
  • ede17be kv-cells scan: for_each_token_in iterated the used std::set (RB-tree) and tested all LLAMA_MAX_SEQ bits per cell; via the PLE predecessor lookup this was ~58% of decode CPU at 141K. Now a bounded contiguous array scan with the queried seq ids hoisted. Not qwen4exp-specific — happy to split this into its own PR.
  • e89368c compact mask: the gather path now feeds from a compact per-cell mask row instead of the FA-padded full-n_kv kq_mask (~18 MB of host->device mask traffic per token at 141K).

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

Decode throughput (single stream, q8_0 KV cache), all four commits vs baseline: 34K 17.4 -> 27.8, 68K 12.0 -> 28.5, 141K 7.1 -> 18-22 tok/s (2.5-3.1x at 141K). Shallow decode unchanged (53 tok/s). As a control, dense models on the same box (a 35B and a 27B) decay only 24-29% over the same depth range, vs 59% for this arch before the patch.

Known limitation / follow-up: both paths still recompute pooled block keys from the raw indexer cache every 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.

nsys profiles and the full benchmark methodology (contention-tagged interleaved A/B, byte-exact output comparison) 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.
…iceTopK

With CCCL >= 3.2 available, ggml_cuda_op_top_k ran cub::DeviceTopK::MaxPairs in
a serial per-row loop. Decode-shaped calls (nrows == 1) are fine, but prompt
processing hands this op hundreds of rows: profiling one 141K-token prefill of
a qwen4exp model showed 903,702 DeviceTopK invocations (512-row batches x 12
QSA layers x ubatches), each launching 3-4 kernels.

Keep DeviceTopK for nrows <= 4 and fall back to the segmented-sort path for
larger batches until DeviceSegmentedTopK exists (NVIDIA/cccl#6391).
llama_kv_cache::get_prev_tokens (the n-gram/PLE predecessor lookup, called once
per ubatch) iterated the `used` std::set — an RB-tree walk over every used cell —
and then tested all LLAMA_MAX_SEQ (256) bits of each matching cell's sequence
bitset. At 141K context this made a single call cost ~40 ms and consume 58% of
total decode CPU time (perf: 50.9% llama_kv_cache::get_prev_tokens +
7.1% std::_Rb_tree_increment), leaving both GPUs ~15% utilized.

Replace the tree walk with a contiguous scan of the pos/seq/ext arrays (same
cells, same index order; empty cells have pos == -1) and hoist the queried seq
ids out of the per-cell loop (queries carry a handful of ids, usually one).

Decode at 141K context (Qwen3.8-Flash-Next UD-IQ4_XS, 2x RTX A6000):
  masked attention path: 9.0  -> 13.1 tok/s
  gather path:           11.6 -> 21.7 tok/s
Outputs byte-identical before/after (greedy, fixed seed).

This helps every model that uses get_prev_tokens (all PLE/n-gram architectures)
at long context.
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.
@abdel-darwish-27

Copy link
Copy Markdown
Author

Heads-up: qwen4exp landed on ggml-org master via ggml-org#27742 while this was in flight, so I have ported this work to mainline and re-measured everything there (master e4b9af00):

On master the combined fixes take 129.6K-context decode from 16.9 to 29.8 tok/s (+75%) and long prefill +25%, with retrieval/factual outputs byte-identical.

Happy to keep this PR open for the fork branch or close it in favor of the mainline ones — your call.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant