Skip to content

qsa : track a sequence's prefix while other sequences share the unified cache - #116

Merged
1 commit merged into
halo-box:masterfrom
rcfa:pr/qsa-multi-seq
Oct 9, 2026
Merged

1 commit merged into
halo-box:masterfrom
rcfa:pr/qsa-multi-seq

Conversation

@rcfa

@rcfa rcfa commented Sep 30, 2026 •

Copy link
Copy Markdown

Overview

The incremental QSA state is qsa_prefix: the tracked sequence's cells in position order, the pooled indexer-key
cache and the compact block metadata. It could be rebuilt only when the tracked sequence was the only occupant of
the cache: qsa_recover() gave up at the first cell of another sequence. So a multi-slot server with a unified KV
cache (-np N -kvu) lost the incremental path as soon as a second conversation had cells in the cache, and never
got it back. After that, every ubatch pooled the whole cache again.

  • qsa_rebuild_prefix() (src/prefix.h) skips the cells of other sequences. It still refuses holes, shared cells
    and duplicate positions. The new unit test tests/test-qsa-recover.cpp covers these cases.
  • qsa_apply() follows whichever single-sequence ubatch comes next: when another sequence takes over, it rebuilds
    the prefix from that sequence's cells. A sequence whose cells cannot form a prefix is tried only once, until the
    state is invalidated again.

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, merge-base), built and run in the same session
Change:     93f17cd4ccf21100e9667ad62c39d16a751a4c7f
Model:      unsloth/Qwen3.8-Flash-Next-GGUF, UD-IQ4_XS

On master the incremental QSA cache runs only when the QSA layers are on ROCm, so on our Vulkan machine this PR alone
changes nothing. With the cache off, #117's build (this PR + the Vulkan gate) is bit-identical to master on the four
repository corpora, each as is (570-876 tokens) and repeated to 12.7k-53.4k tokens. We compared the prompt tokens, the
complete next-token distributions after prefill, and 64 greedy steps with top-20 log-probabilities. The same held on
7a9196d, 12643af and 307c50d.

The code this PR changes is backend-independent cache bookkeeping. We exercised it on Vulkan with #117, which enables
the incremental cache there, and measured this PR's own share by building #117's gate with and without it.

  • Server: 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, first with the cache empty, then next to three resident
    16k-token conversations.
  • Then slots 0 and 1 decode together. Slot 1 stops, and slot 0 goes on alone.
slot 0 decode, t/s cache off #117's gate without this PR #117 with this PR
empty cache 9.68 9.59 9.52
48k resident 7.01 (72%) 6.93 (72%) 9.27 (97%)
alone after decoding next to slot 1 7.24 (75%) 7.17 (75%) 9.76 (102%)

All three generate the same 64 tokens, with bit-identical top-5 log-probabilities. Without this PR, qsa_recover()
gives up at the first cell of another conversation, so next to the residents the cache is not used at all.

--no-cache-idle-slots matters. By default, llama-server with -kvu saves every idle slot to the RAM prompt cache and
clears it from the KV cache whenever a new task starts. A conversation then rarely sits next to another one's cells,
except while requests overlap.

Correctness: test-qsa-recover: PASS (4e0d917). Corpora: bit-identical to master, see above. No ggml op is touched.

Additional information

  • qsa : enable the incremental QSA cache on Vulkan #117 builds on this PR and enables the cache on Vulkan.
  • Several conversations decoding in one ubatch (a mixed ubatch) still invalidate the tracked prefix and take the
    whole-cache path. This PR only makes sure the next single-sequence ubatch gets the incremental path back.

Requirements

  • I have read and agree with the contributing guidelines
  • This change is Strix Halo specific, or justified by measurements on Strix Halo: it concerns the fork's qwen4exp /
    QSA incremental cache. Our measurements come from Strix Point (gfx1150), not Strix Halo.
  • AI usage disclosure: AGENT-AUTHORED. Claude Opus 5.5 wrote the change, the unit test and this description, and ran
    every measurement. Every commit carries Assisted-by: Claude Opus 5.5.
  • What was NOT verified:
    • ROCm, which is the only backend where this code runs by default. We have no ROCm build of this fork for
      gfx1150, and ROCm's GTT limit cannot hold this model on our machine. The code was exercised on Vulkan only, with
      qsa : enable the incremental QSA cache on Vulkan #117's switch.
    • CUDA.
    • Strix Halo (gfx1151) hardware.

@rcfa
rcfa force-pushed the pr/qsa-multi-seq branch from f51bdb9 to e306436 Compare October 1, 2026 23:11
@rcfa
rcfa marked this pull request as ready for review October 4, 2026 16:51
@dzannotti

Copy link
Copy Markdown
Collaborator

Tested on Strix Halo (gfx1151), master ad219f329 vs master + this PR:

  • test-qsa-recover, test-qsa-prefix and test-qsa-fast pass on HIP and RADV.
  • Single-sequence llama-perplexity logits are byte-identical to master (Flash-Next, 8k).

I couldn't reproduce the speedup. In a -np 2 -kvu -c 65536 server I sent A (22.5k tokens), then B, then A extended. The server reprocessed the whole extended prompt in both builds, so the path this PR changes wasn't reached. Results were identical: HIP 747 t/s, RADV 417 t/s.

Could you share the exact server flags and request sequence you measured with? With that I'll verify it.

Comment generated via automated review of clanker.

…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
@rcfa
rcfa force-pushed the pr/qsa-multi-seq branch from e306436 to 93f17cd Compare October 7, 2026 18:08
@rcfa

rcfa commented Oct 7, 2026

Copy link
Copy Markdown
Author

Thanks for testing. Your sequence does not reach this path because of llama-server's default --cache-idle-slots. With -kvu, every new task saves each idle slot to the RAM prompt cache and then clears it from the KV cache ([TAG_IDLE_SLOT_CLEAR] in server-context.cpp). By the time A ext starts, B's cells are already gone. A is alone in the cache, so master's qsa_recover() succeeds as well. #116 matters only while another conversation's cells stay in the cache: with --no-cache-idle-slots, which is what we run, or when requests overlap.

Two more points:

What we measured with (fn-test-qsa-resident):

  • Model: Flash-Next UD-IQ4_XS, no MTP, GGML_VK_MMV_NO_SPLIT=1.
  • Server:
llama-server -m Qwen3.8-Flash-Next-UD-IQ4_XS-00001-of-00003.gguf -ngl 99 --load-mode none --lazy-mode on-direct \
  -c 65536 -np 4 -kvu --cache-ram 0 --no-cache-idle-slots

Every request goes to /completion with temperature: 0 and ignore_eos: true:

  1. Slot 0 (id_slot: 0): a 2048-token prompt, n_predict: 64, cache_prompt: false. This is the empty-cache reference.
  2. Slots 1-3: one 16k-token prompt each, n_predict: 1, cache_prompt: true. 48k cells stay resident.
  3. Step 1's request again. Compare timings.predicted_per_second with step 1.
  4. Slot 0 gets a 256-token prompt with n_predict: 192, streamed. 0.5 s later, slot 1 gets 64 tokens with n_predict: 32. We measure slot 0's decode rate after slot 1 has stopped.

Results on master 4e0d917 (gfx1150, RADV), decode t/s:

build empty cache 48k resident (step 3) alone after step 4
#117, cache off (LLAMA_QSA_INCREMENTAL=0) 9.68 7.01 (72%) 7.24 (75%)
#117's gate without #116 9.59 6.93 (72%) 7.17 (75%)
#117 with #116 9.52 9.27 (97%) 9.76 (102%)

In steps 1 and 3, all three builds give the same tokens with bit-identical top-5 log-probabilities.

Your sequence, with A = 22.5k tokens, B = 4k tokens, and A ext = A + its output + 512 tokens, 64 tokens generated each time. Server: -c 65536 -np 2 -kvu, otherwise defaults. Decode t/s:

build A ext, default flags A ext, --no-cache-idle-slots B next to A, --no-cache-idle-slots
#117, cache off 8.13
#117's gate without #116 9.57 8.11 8.19
#117 with #116 9.56 9.55 9.53

With the default flags, B is cleared before A ext starts, so both builds take the incremental path. In our run, A ext came back from the RAM prompt cache (22563 tokens reused) rather than being reprocessed; either way, A is alone in the KV cache at that point. With --no-cache-idle-slots, B stays resident, and only #116 rebuilds A's prefix next to it.

On HIP, master vs master + #116 should show the same split in steps 3 and 4, or in your sequence with --no-cache-idle-slots.

Assisted-by: Claude Opus 5.5

@dzannotti dzannotti closed this pull request by merging all changes into halo-box:master in 6aa81b8 Oct 9, 2026
iTechMedic added a commit to iTechMedic/strix-llama.cpp that referenced this pull request Oct 10, 2026
…mages

An mrope image puts all its cells at one position, so qsa_apply rejected its ubatch and qsa_recover refused its
cells: after the first image in a conversation every later decode and verify step re-pooled all indexer blocks on
the GPU and rebuilt the block metadata with the per-cell scan on the host, both in proportion to the context.

The tracked prefix now keeps the cells in the mask's order (position, then y, then x), the order the selection
ranks them in once an image is present, and the ranks of a 1-D ubatch after the image are the rank blocks the
selection uses. Tails and block starts are ranks; a block's pooled key takes the rope position the selection gives
it (its first cell's key for rank blocks, the block start otherwise). Image ubatches are tracked but do not match,
so they keep the scan. A prefix with a position hole and no image still matches no selection layout and stays
invalid, the prefill scoring bound stays position-only, and a ranked prefix only matches while its sequence is all
the cache holds.

Recovery (qsa_rebuild_prefix, which skips the cells of other sequences since halo-box#116) orders the cells of the sequence the same way: a counting sort by position, then y and x among an image's cells. It builds the prefix through apply, so a recovered prefix has the same ranks, keys and blocks as one kept ubatch by ubatch. A cell shared with another sequence is still refused. A ranked prefix is used only while its sequence is alone in the cache; with other sequences resident the selection does not rank, so those ubatches keep the scan.

Text-only caches keep rank == position and are unchanged.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants