Skip to content

qsa : enable the incremental QSA cache on Vulkan - #117

Merged
dzannotti merged 2 commits into
halo-box:masterfrom
rcfa:pr/qsa-vulkan
Oct 9, 2026
Merged

dzannotti merged 2 commits into
halo-box:masterfrom
rcfa:pr/qsa-vulkan

Conversation

@rcfa

@rcfa rcfa commented Sep 30, 2026 •

Copy link
Copy Markdown

Overview

Depends on #116. This branch contains it; the change here is the last commit.

The incremental QSA cache keeps the tracked sequence's pooled, normalised and rotated indexer keys between ubatches. It then scores only the new cells, instead of pooling the whole cache again every time. Until now it was enabled only when the QSA layers run on ROCm. On Vulkan it gives bit-identical results on master's maskless selected-key path (below), so this PR enables it there too.

LLAMA_QSA_INCREMENTAL=0 disables the cache, and =1 enables it on any backend.

Measurements

Device:     Ryzen AI 9 HX PRO 370 / Radeon 890M (gfx1150, Strix Point - NOT Strix Halo) / Minisforum N5 PRO, BIOS 1.05
Memory:     96 GB DDR5-5600 (2 x 48 GB SO-DIMM, ECC)
Power:      BIOS defaults; amdgpu power_dpm_force_performance_level=auto; cpufreq governor powersave (amd-pstate)
BIOS:       UMA split 48 GB
Kernel:     7.1.5+kali-amd64, amdgpu params: amdgpu.gttsize=73728 ttm.pages_limit=8650752
Backend:    Vulkan RADV, Mesa 26.1.6 (libvulkan1 1.4.357)
Build:      -DGGML_VULKAN=ON -DLLAMA_CURL=OFF -DCMAKE_BUILD_TYPE=Release -DCMAKE_BUILD_RPATH_USE_ORIGIN=ON (gcc 16.2)
Baseline:   4e0d9177597bb47e2dae3bd7b00835f0a1f3b35a (master, with #115 and #119), built and run in the same session
Change:     24c927633d9e40ed840efed71f7864aa88598be5
Model:      unsloth/Qwen3.8-Flash-Next-GGUF, UD-IQ4_XS

Decode at depth is what this PR changes. Master and this PR were run in the order master, this PR, this PR, master, each time with -r 2:

llama-bench -m MODEL -p 0 -n 128 -d 0,12000,32000,64000 -b 2048 -ub 512 -r 2 -ngl 99 -fa on -ctk f16 -ctv f16 -lm none -lzm on-direct -o jsonl

depth master tg128 t/s this PR tg128 t/s samples change
0 10.66 +- 0.00 10.67 +- 0.00 4 / 4 +0.1%
12000 9.12 +- 0.09 9.80 +- 0.03 4 / 4 +7.4%
32000 7.92 +- 0.00 9.57 +- 0.05 4 / 4 +20.8%
64000 6.57 +- 0.03 9.51 +- 0.03 4 / 4 +44.6%

Without the cache, decode slows from 10.7 to 6.6 t/s between an empty cache and 64k cells. With it, decode only goes
from 10.7 to 9.5.

Prompt speed of a single conversation does not change beyond about 1%. For example, a 22.5k-token prompt runs at 151.8 t/s with the cache off and 152.0 t/s with it, and a 53.4k-token prompt at 147.3 t/s on master and 146.4 t/s with this PR.

Several conversations in one unified cache: llama-server -c 65536 -np 4 -kvu --cache-ram 0 --no-cache-idle-slots, no MTP. Slot 0 reads a 2048-token prompt and generates 64 tokens with the cache empty, then again with three 16k-token conversations resident:

slot 0 cache off (LLAMA_QSA_INCREMENTAL=0) this PR without #116 this PR
empty cache: prompt / decode 178.5 / 9.68 t/s 174.4 / 9.59 t/s 177.1 / 9.52 t/s
48k resident: prompt 149.1 t/s (84%) 147.8 t/s (85%) 160.7 t/s (91%)
48k resident: decode 7.01 t/s (72%) 6.93 t/s (72%) 9.27 t/s (97%)
alone again after decoding next to slot 1 7.24 t/s (75%) 7.17 t/s (75%) 9.76 t/s (102%)

Without the cache, every decode step pools the indexer keys of the whole unified cache, so the other conversations slow it down. With the cache, a step scores only its own new cells. The middle column is this PR's gate without #116: master cannot rebuild the tracked prefix while another conversation has cells in the cache, so next to the residents the cache is not used.

Correctness, with the cache on vs LLAMA_QSA_INCREMENTAL=0 on the same build, all on the maskless selected-key path:

  • Repository corpora. The four corpora go through llama-server (1 slot, no MTP), each as is (570-876 tokens) and repeated to 12.7k-53.4k tokens. We compare the prompt token IDs, the complete next-token distribution after prefill (all 248,320 vocabulary entries) and 64 greedy steps with top-20 log-probabilities. The cache on and off are bit-identical on all eight prompts. With the cache off, the build is also bit-identical to master.
  • Resident conversations. In the table above, all three columns generate the same 64 tokens, with bit-identical top-5 log-probabilities, both with the cache empty and next to the residents.
  • Isolation. A 2048-token probe runs next to two different sets of three resident 16k-token conversations, X and Y (leak-diag.py). With this PR, X and Y give identical tokens, with no log-probability difference over the top 20 (0.0000), the same as on master.
  • Unit test. test-qsa-recover (qsa : track a sequence's prefix while other sequences share the unified cache #116) passes.
  • Earlier checks. On qwen4exp : use maskless block selection only where the selected-key kernels run #115's masked path (7a9196d to 307c50d), the same corpora were bit-identical with the cache on and off.
  • No ggml op is touched, so test-backend-ops does not apply.

Requirements

@Nathanw1014

Copy link
Copy Markdown

Tested #116 + #117 on top of #133 (selected-key WMMA, with #115/#119 under it) on a Strix Halo (gfx1151, RADV, stock Mesa), Flash-Next REAP-320 Q3_K_XL, -ub 512:

  • llama-perplexity -c 4096: identical with the cache on and off, on every chunk, on both the WMMA kernel (3.7983) and the scalar fallback (3.8046).
  • llama-server -np 2 -kvu, two conversations alternating over 4 turns, greedy: identical text with the cache on and off (and on a repeat run).
  • At 16k depth: decode 24.8 → 26.7 t/s (+8%), pp512 flat (525 → 522).

So the cache stays exact on the maskless path too. One gap: our second-turn prompts were re-processed in full, so rebuilding a partly cached prefix after another conversation's turn wasn't exercised.

With this, #115/#119 (tested earlier) and #116/#117 all check out on gfx1151. Would you be OK taking the stack out of draft so we can start merging, #115 first?

Assisted-by: Claude Opus 5.5

@rcfa
rcfa marked this pull request as ready for review October 4, 2026 16:51
@rcfa

rcfa commented Oct 4, 2026

Copy link
Copy Markdown
Author

Thanks for testing the stack on gfx1151, and for #133.

The gap you mention, rebuilding a partly cached prefix after another conversation's turn, is what #116 adds. test-qsa-recover covers it at the unit level: prefixes are rebuilt next to other sequences, and holes, shared cells and duplicates are refused. On gfx1150 we also checked it end to end (on 7a9196d, see #116): after a turn of the other conversation, the first one decodes alone again at 99% of its empty-cache speed, with the same 64 greedy tokens as with an empty cache.

I've taken #115–#119 out of draft. #115 is the one to start with; it also fixes #130.

Assisted-by: Claude Opus 5.5

@dzannotti

Copy link
Copy Markdown
Collaborator

The conflict with master is only this branch's copy of #115 (c20ba6fe6), which master already has, now extended by #119. Dropping that commit and keeping master's version leaves just the enable gate. b05815912 applies cleanly on top of #116.

Please also re-check after rebasing: your bit-identical runs were on #115, where Vulkan took the masked top-k. On master, Vulkan now takes the maskless selected-key path, which also reads qsa_prefix. We'll test it once it's rebased.

Comment generated via automated review of clanker.

rcfa and others added 2 commits October 7, 2026 14:42
…ed cache

The incremental QSA state (qsa_prefix: one sequence's cells in position order,
the pooled indexer-key cache, the compact metadata) could only be rebuilt when
the tracked sequence was the only occupant of the cache: qsa_recover() gave up
at the first cell of another sequence. A multi-slot server with a unified KV
pool therefore never regained the incremental path once a second conversation
had cells in the pool.

- qsa_rebuild_prefix() (src/prefix.h, unit-tested in test-qsa-recover) skips
  other sequences' cells; holes, shared cells and duplicate positions are
  still refused.
- qsa_apply() recovers the prefix of whichever single-sequence ubatch comes
  next, including right on a switch between sequences, and tries a sequence
  that cannot form a prefix only once until the state is invalidated again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-by: Claude Opus 5.5
The incremental QSA cache keeps the pooled indexer keys of the tracked
sequence and scores only new cells. It was enabled only when the QSA layers
run on ROCm. Validated on Vulkan (Radeon 890M, gfx1150, Mesa 26.1.6) with
Qwen3.8-Flash-Next UD-IQ4_XS, on the maskless selected-key path:

- exact: the four repository corpora, as is and repeated to 12.7k-53.4k
  tokens, give bit-identical prompt tokens, complete next-token
  distributions and 64 greedy steps with top-20 log-probabilities with the
  cache on and off
- decode with one conversation (llama-bench tg128): +7.4% / +20.8% /
  +44.6% at 12k / 32k / 64k cells, unchanged on an empty cache
- with three other 16k-token conversations in the unified cache: the same
  64 tokens as with an empty cache; decode at 97% of the empty-cache speed
  (72% without the cache), and at 102% (75%) once the other conversation
  has stopped

LLAMA_QSA_INCREMENTAL=0 disables it, =1 enables it on any backend.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rcfa

rcfa commented Oct 7, 2026

Copy link
Copy Markdown
Author

Rebased onto master 4e0d917. c20ba6f is dropped, so the branch is now #116 plus the gate, 24c927633; only the commit message changed, with the numbers below. Re-checked on master's maskless selected-key path (gfx1150, RADV, Flash-Next UD-IQ4_XS), comparing the cache on with LLAMA_QSA_INCREMENTAL=0 on the same build:

  • Repository corpora: the four corpora, each as is (570-876 tokens) and repeated to 12.7k-53.4k tokens. Prompt tokens, the full next-token distribution and 64 greedy steps with top-20 log-probabilities are bit-identical with the cache on and off. With the cache off, the build is also bit-identical to master.
  • Resident conversations: a 2048-token prompt next to three resident 16k-token conversations gives the same 64 tokens with bit-identical top-5 log-probabilities, with the cache on and off. Decode runs at 97% of the empty-cache speed with the cache and at 72% without it.
  • Isolation: the log-probability probe (leak-diag.py, a 2048-token prompt next to two different sets of resident conversations, X vs Y) gives identical tokens with no log-probability difference, the same as on master.
  • Unit test: test-qsa-recover passes.

Decode at depth vs master (llama-bench -n 128, master / this PR / this PR / master, -r 2 each): +0.1% on an empty cache, +7.4% at 12k, +20.8% at 32k and +44.6% at 64k. The description is updated.

Assisted-by: Claude Opus 5.5

@dzannotti
dzannotti merged commit 6aa81b8 into halo-box:master Oct 9, 2026
1 check passed
Nathanw1014 added a commit that referenced this pull request Oct 17, 2026
…e kernel (halo-box #117), dropping the six visibility ops per strip

On the maskless path qwen4exp_apply_compact_visibility turns the raw head sum into
score + log(step(tail[q] - start[b])) with cast(starts), cast(tails), reshape, repeat,
sub, step, log and add (plus a get_rows with several sequence rows): seven or eight
dispatches and a [n_blocks, n_query] f32 temporary per QSA layer per strip, in front
of the block select. The block-select kernel now takes the limits buffer (binding 4)
and applies the same test per element while it loads the row (positions stay below
2^24, so the f32 subtraction the chain does is exact and the integer compare is the
same test); the fused range starts at the first visibility cast and ends at the
chain's CONCAT, so none of those ops runs. The scores it reads are the raw QSA_SCORE
output; the matcher checks the whole visibility subgraph (one or several rows) and
that every visibility op sits inside the range, and still accepts the TOP_K entry
(scores carrying the visibility, v1).

Chain validation no longer uses ggml_can_fuse_subgraph_ext: it rejects a view of a
tensor outside the subgraph unless that is a weight, and does not count a cast's
self-reference in src[1], so the chain (views of the score and of the block cells,
three casts) could never pass it. The matcher counts the readers of every node
itself: all inside the chain, only the CONCAT read outside.

QSA_SCORE on the same path: its keep pattern required a trailing bias ADD, which the
compact path does not have adjacent to the head sum, so the scorer chain was not kept
together there; qsa_score_pattern_nobias (the chain without the ADD) is kept and
protected too, and the scorer matcher leaves a log(step)-fed ADD to the block select
when that fits the row.

Knobs: GGML_VK_QSA_BLOCK_SELECT (default on), GGML_VK_QSA_BLOCK_SELECT_LIMITS=0 keeps
the visibility ops and enters at the TOP_K (the v1 arm). Tests: five limits-mode cases
(one and two sequence rows, budget past the visible blocks, a decode row), exact
compare against the CPU chain, each asserting exactly one QSA_BLOCK_SELECT dispatch.

Assisted-by: Claude Opus 5.5
Nathanw1014 added a commit that referenced this pull request Oct 17, 2026
The comments the QSA select / score / radix / flat GET_ROWS changes added
named ledger items (A2, E11, B-SEL, Hi 4, halo-box #117, fork hashes, dated
debug runs) and ran to 3-11 hard-wrapped lines restating the commit
messages. They now give the what, the why and the measurement in 1-2
lines; the attribution for the QSA_SCORE idea stays (review C1, C2).

Assisted-by: Claude Opus 5.5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants