Skip to content

qsa : keep the incremental QSA prefix across mrope images (target and MTP draft) - #163

Draft
iTechMedic wants to merge 391 commits into
halo-box:masterfrom
iTechMedic:fix/qsa-image-history
Draft

iTechMedic wants to merge 391 commits into
halo-box:masterfrom
iTechMedic:fix/qsa-image-history

Conversation

@iTechMedic

@iTechMedic iTechMedic commented Oct 9, 2026 •

Copy link
Copy Markdown

Overview

One screenshot anywhere in a conversation made every later decode step of Qwen3.8-Flash-Next take the slow QSA path for the
rest of that conversation:

  • an mrope image puts all its cells at one position, so the incremental QSA prefix rejected the image ubatch and never recovered.
    Every later decode/verify step re-pooled all indexer blocks on the GPU and rebuilt the block metadata with the per-cell host
    scan, both in proportion to the context;
  • the visibility test rejected every ubatch once a 2-D cell was in the cache, so text queries also lost the maskless path;
  • the MTP draft context never receives image cells, so its positions have holes, and its prefix was invalid from the first image on.

Agent sessions (Pi, OpenCode, ...) attach screenshots routinely, so in practice long agent sessions decode at the slow rate.

Rebased (2026-10-10) onto master 6aa81b88e, which merged #116 (multi-sequence prefix recovery) and #117 (incremental QSA on
Vulkan). Four commits, each building on the previous one:

  1. qsa : keep the maskless path for text queries after an image: exact visibility test (a 2-D cell matters only to a query at its
    own linear position; single-sequence caches only). Unchanged from the first revision (same patch id).
  2. qsa : track the incremental prefix in the mask's order across mrope images: the tracked prefix keeps cells in the mask's order
    (position, y, x); after an image the selection's blocks are rank blocks, which the tracker now produces.
  3. qsa : track position blocks with holes (the MTP draft after an mrope image): complete position blocks with holes for the draft;
    pooled keys are rebuilt if the block numbering changes.
  4. tests : compare a rebuilt QSA prefix with the one kept ubatch by ubatch (new, test only).

Integration with #116 / #117:

Text-only caches are unchanged (byte-identical, below).

Intentional numerical change on image histories (unchanged by the rebase). After an image, the change selects complete blocks
plus the query's own tail
(2048 + t cells, where t is the query's own-tail length, 0-3), the rule master already uses for text
queries. For ranked image histories, t = (rank+1) % 4; for position blocks it is (position+1) % 4. Master's masked route there selects
2051 cells: the same own tail, the same 512 blocks, plus 3 - t cells of the 513th-best block, picked by a top-k tie-break among cells
that share one block score. So image-history logits differ from master by design; this is separate from the incremental pooling,
which is exact (incremental pooled keys equal a full re-pool byte for byte).

Maintainer decision requested (still open). This PR stays a draft until a maintainer agrees with this selection policy for image
histories. If master's image semantics must be kept, two alternatives are possible (neither implemented or measured): keep the
masked route after an image but feed it the new incremental prefix (removes the full re-pool and host scan, keeps the per-step
O(n_kv) mask and top-k and the tie-break nondeterminism), or let the complete-block route also take the leading 3 - t cells of the
next-best block.

Benefit in short (measured on the rebased head, below): on reconstructed screenshot histories, decode +25.5 % serial and
+22.6 % with MTP (+32 % / +26 % above 100K tokens; MTP verification round 94.5 -> 73.9 ms). No change for text-only sessions.

Measurements (rebased head)

Device:     Ryzen AI Max+ 395 / Minisforum MS-S1 MAX
Memory:     128 GB LPDDR5X-8000
Power:      tuned accelerator-performance, power_dpm_force_performance_level auto
BIOS:       UMA frame buffer 512 MiB (amdgpu boot log `VRAM: 512M`)
Kernel:     7.2.8-200.fc44, amdgpu.gttsize=126976 ttm.pages_limit=32505856, iommu=off
ROCm:       custom retained-PM4 HIP/ROCr (kyuz0/pwilkin builder image, gfx1151), HIP graphs on
Vulkan:     Mesa 26.2.3 RADV (STRIX_HALO), gcc 16.2.1, same host
Baseline:   6aa81b88e14aa753c1c7e1a9ff27c9d50fd3c673 (merge-base), built and run in the same session
Change:     0a3e66dd2f824925882b2e65986672f4aba1d4cc (tested as 92f8f78f8: identical tree; only commit messages of commits 2 and 3 were reworded)
Model:      unsloth/Qwen3.8-Flash-Next-GGUF UD-Q4_K_XL (4 shards), mmproj-F16, MTP head mtp-Qwen3.8-Flash-Next-shared-Q8_0

Isolated builds (separate source and build trees, identical flags) of base and change for ROCm and for Vulkan.

Workload (as in the first revision). 15 requests reconstructed from a real Pi coding session (an Asteroids clone with browser
screenshots): 12 requests from 11K to 143K tokens with 0-14 screenshots in the history, plus text-only controls (the same history with
the screenshots replaced by text: 104K, 119K, 136K tokens). Each request is sent on its own with its full reconstructed history; Pi's
sampling (temperature 1.0, top_p 0.95, top_k 20), fixed seed, 1,024-token cap. Server: -c 262144 -np 1 -b 4096 -ub 4096 --cache-ram 8192 -fa on --jinja --mmproj ... --lazy-mode on-direct, serial and --spec-type draft-mtp --spec-draft-n-max 3. Three pairs per
config, base and change alternating first. Decode rate = sum(predicted_n - 1) / sum(predicted_ms), pooled over the requests valid in
both builds of a pair. Five paired request instances were excluded by the valid-and-timed filter: two in serial and three
in MTP. Neither build’s result for an excluded instance contributes to the comparison.

config requests obs/build base t/s change t/s change per pair ms per MTP round
serial with screenshots 31 20.85 26.18 +25.5 % +21.7 / +26.5 / +27.3 -
serial with screenshots, >= 100K 19 19.65 26.01 +32.4 % +30.2 / +32.9 / +33.0 -
serial text only 12 26.28 26.35 +0.3 % +1.9 / -1.9 / +0.8 -
MTP n3 with screenshots 30 29.33 35.97 +22.6 % +18.2 / +28.7 / +22.4 94.5 -> 73.9
MTP n3 with screenshots, >= 100K 18 27.78 35.07 +26.3 % +23.4 / +33.7 / +22.9 100.5 -> 74.6
MTP n3 text only 12 41.04 41.03 -0.0 % +0.0 / -0.1 / -0.1 74.3 -> 74.4
MTP n3 text only, fresh servers 12 38.08 38.11 +0.1 % +0.2 / +0.0 / +0.0 74.5 -> 74.5

Text only with MTP: identical generated, drafted and accepted token counts on every request. On image histories the generated text
differs (selection change), so draft acceptance differs per pair (0.59 base, 0.55 change pooled); the per-round time carries the gain.

Vulkan (Mesa RADV on the same Strix Halo), since #117 enabled the tracker there. Same workload shape, smaller and bounded: requests
6-36 (21K-116K tokens, 1-6 screenshots) plus one text control, 512-token cap, -c 131072, otherwise the same flags; 2 pairs per config,
all requests valid in both builds:

config requests obs/build base t/s change t/s change per pair ms per MTP round
serial with screenshots 12 14.80 25.80 +74.3 % +73.7 / +74.9 -
serial with screenshots, >= 100K 4 12.14 25.46 +109.7 % +109.7 / +109.7 -
serial text only 2 25.68 25.57 -0.4 % -0.2 / -0.6 -
MTP n3 with screenshots 12 21.40 37.22 +73.9 % +73.2 / +74.6 124.3 -> 76.6
MTP n3 with screenshots, >= 100K 4 17.21 33.82 +96.4 % +94.3 / +98.7 145.5 -> 78.0
MTP n3 text only 2 43.08 43.22 +0.3 % +0.1 / +0.5 76.5 -> 76.3

The >= 100K rows rest on two requests per pass. On Vulkan the base's per-step full re-pool and host scan cost more than on ROCm, so the
gain is larger.

Memory and long context on the new base (upstream changed allocation in #164, so the first revision's headroom does not carry
over). Production MTP config, --cache-ram 8192, request 66's history (142K tokens, 14 screenshots) grown to 246K, a screenshot
question, a rollback that drops a screenshot turn, a recall, a short side conversation, the return to the long history, filled to the
end of the context, then a screenshot question there; greedy, one run per build, a client guard stops below 8,000 MiB MemAvailable:

base 6aa81b88e change
final context reached 258,173 (guard stop: 7.5 GiB free before the last step) 259,105, final screenshot answered
minimum MemAvailable (1 s samples) 7,646 MiB 20,975 MiB
peak GTT 107,659 MiB 96,620 MiB
prefill per ~24K-token append 39-47 s 22 s
re-prefill of the 242K history after the side conversation 333 s 220 s
decode during the appends 23-28 t/s 39-44 t/s

Every answer the base reached was correct; the rollback reused exactly the history before the dropped turn on both. The 8 GiB RAM
cache cannot hold the ~10 GiB long state on either build (exceeds cache size limit, skipping), hence the re-prefill; a deployment
setting, not part of this PR.

Not re-run after the rebase: the standard four-depth PP2048 and seeded llama-benchy matrices. The first revision’s tables
(base 4c6ccf20e, change c33392fc4) remain historical evidence. Fresh text-only replay controls and correctness checks on the
rebased head are reported above; they do not replace those matrices or establish that every changed path is unused during prefill.
The remaining prescribed coverage needs completion or explicit maintainer acceptance of the disclosed limitation.

Correctness (rebased head vs base 6aa81b88e)

  • test-qsa-prefix, test-qsa-fast, test-qsa-recover pass (ROCm and Vulkan builds). Full ctest: 69/73 on both builds, the same
    4 failing (test-tokenizers-ggml-vocabs: no network in the container; test-grammar-parser, test-save-load-state,
    test-jinja-py: abort/fail on the base too). Baseline parity, not an all-green ctest.
  • test-backend-ops (ROCm0), complete suite minus SOLVE_TRI: 31087/31087 on both builds; SOLVE_TRI aborts on both (pre-existing).
    No ggml source changes in this PR.
  • Text: fp32 logits, generated ids and recurrent/KV state byte-identical to the base on the four tests/corpus/correctness-*.txt
    corpora plus a 32,768-token (QSA active) and a 473-token prompt, at -ub 4096 and -ub 256: 24/24 on ROCm, 24/24 on Vulkan.
  • llama-perplexity (same corpus and model, f16 KV, FA on, b/ub 4096, QSA active), 2 runs per build, identical on every chunk:
    16384: 1.0771 +/- 0.00285 (6 chunks); 32768: 1.0666 +/- 0.00257 (3 chunks).
  • Two conversations in one unified cache (--parallel 2 --kv-unified), text only, greedy, MTP, switching between them: identical
    outputs (text, tool calls) and draft counts to the base on all 6 steps.
  • Tracker vs full scan: a diagnostic build recomputes the metadata with the scan for every ubatch the tracker serves and compares it.
    ROCm, production config with MTP and serial:
    • one conversation with screenshots, MTP draft holes, a RAM-cache restore (another conversation in between) and a rollback:
      9,088 + 4,273 comparisons, 0 mismatches;
    • two conversations in one unified cache (screenshots in one, switching, concurrent requests, a slot returning to an earlier
      prompt, slot erase, rollback; and a case where one conversation finishes while the other keeps decoding next to it): 0 mismatches and 0 selection
      differences on the change and on the base. With several sequences the scan numbers all sequences' blocks together while the tracker
      numbers its own (qsa : track a sequence's prefix while other sequences share the unified cache #116), so there the comparison is of the selection (per row the blocks' limit, cells and rope positions; per query
      tail start and tail cells), not of the bytes.
      Vulkan (RADV), same diagnostic code: one conversation (screenshots, MTP holes, RAM-cache restore, rollback) 7,942 + 4,771
      comparisons, 0 mismatches; two conversations in one unified cache (-c 131072, smaller histories; switching, concurrent requests,
      slot erase, rollback, one conversation decoding next to a finished one), MTP and serial, 0 mismatches and 0 selection differences.
  • Images (unchanged by the rebase): incremental pooled keys equal a full re-pool byte for byte; the selected cells are master's minus
    its 0-3 tie-break cells (first-revision evidence, see the history below).

Additional information

  • Pre-existing on master, not fixed here (separate issue drafted): with two conversations in one unified cache, one holding an
    image, the full scan aborts with GGML_ASSERT(group_members[slot]<0 || group_members[slot]==j) when it builds the compact metadata
    for a text ubatch of the other conversation (it ranks image cells only for single-sequence caches). Reproduced on unmodified
    6aa81b88e with LLAMA_QSA_INCREMENTAL=0 and two concurrent requests; identical with this change, which does not touch the scan.
    With the incremental cache on, qsa : track a sequence's prefix while other sequences share the unified cache #116's tracker usually serves that ubatch, and the production runs above completed.
  • Pre-existing, not fixed here: on master, image histories are not reproducible run to run (masked top-k emits the selected
    cells in atomic order).
  • The first revision's results (base 4c6ccf20e, change c33392fc4: paired replay, PP2048, llama-benchy, image numerics, QA task
    check, near-native context 258,818 tokens, 12 GiB RAM cache) are kept below for reference with their original identities.
First revision (2026-10-09, base 4c6ccf2, change c33392fc4)

Measurements (first revision)

Device:     Ryzen AI Max+ 395 / Minisforum MS-S1 MAX
Memory:     128 GB LPDDR5X-8000
Power:      tuned accelerator-performance, power_dpm_force_performance_level auto
BIOS:       UMA frame buffer 512 MiB (amdgpu boot log `VRAM: 512M`, mem_info_vram_total; BIOS setup screen not recorded)
Kernel:     7.2.8-200.fc44, amdgpu.gttsize=126976 ttm.pages_limit=32505856, iommu=off
Backend:    ROCm (custom retained-PM4 HIP/ROCr, rocm_systems 7dda3ac6cf), HIP graphs on
Build:      kyuz0/pwilkin toolbox Dockerfile (gfx1151), identical for both images; only the llama.cpp commit differs
Baseline:   4c6ccf20efcac39a9503a76823cd546ff4ca336b (merge-base), built and run in the same session
Change:     c33392fc4663e9cae35fffbb16fe232b03f69418
Model:      unsloth/Qwen3.8-Flash-Next-GGUF UD-Q4_K_XL (4 shards), mmproj-F16, MTP head mtp-Qwen3.8-Flash-Next-shared-Q8_0

The speedup only exists on conversations with images, which llama-bench cannot express, so the main measurement is end-to-end
llama-server decode on a described workload.

Workload. 15 requests reconstructed from a real Pi coding session (an Asteroids clone with browser screenshots): 12 requests from
11K to 143K tokens with 0-14 screenshots in the history, plus text-only controls (the same history with the screenshots replaced by
text, 85K; and two short text-only requests). Each request is sent on its own with its full reconstructed history; Pi's sampling
(temperature 1.0, top_p 0.95, top_k 20), fixed seed, 1,024-token cap. Server: -c 262144 -np 1 -b 4096 -ub 4096 --cache-ram 8192 -fa on --jinja --mmproj ... --lazy-mode on-direct, serial and --spec-type draft-mtp --spec-draft-n-max 3. Three pairs per config,
master and change alternating first. Decode rate = sum(predicted_n - 1) / sum(predicted_ms), pooled over the requests that are valid
in both builds of a pair (3 image requests failed a re-tokenization check in one build each and are dropped from that pair; master's
MTP server was stopped by a memory guard at the first text control, so the MTP text controls were rerun as 3 fresh-server pairs).

config requests obs/build baseline t/s change t/s change per pair ms per MTP round
serial with screenshots 31 20.98 26.11 +24.5 % +24.7 / +24.1 / +24.5 -
serial with screenshots, >= 100K 20 19.80 25.94 +31.0 % +30.9 / +30.9 / +31.3 -
serial text only 12 26.25 26.18 -0.2 % +0.2 / -1.2 / +0.3 -
MTP n3 with screenshots 32 29.51 36.89 +25.0 % +25.3 / +27.5 / +21.8 94.3 -> 74.0
MTP n3 with screenshots, >= 100K 20 27.63 36.41 +31.7 % +26.7 / +33.6 / +34.8 101.0 -> 74.6
MTP n3 text only (fresh servers) 12 38.43 38.13 -0.8 % +1.3 / -1.4 / -2.2 73.8 -> 74.4

Text only with MTP: identical generated, drafted and accepted token counts on every request (token ids were not compared); the
throughput difference is pass-to-pass spread.

PP2048 (llama-bench, unchanged as expected: prefill does not use the incremental prefix):

llama-bench -p 2048 -d 0,12000,32000,64000 -b 4096 -ub 4096 -n 0 -r 4 -fa on -ctk f16 -ctv f16, order baseline, change, change,
baseline (64K cells run separately with the same command and order):

depth baseline run 1 change run 1 change run 2 baseline run 2
0 1618.22 ± 13.57 1622.49 ± 10.20 1624.18 ± 7.85 1621.27 ± 6.31
12000 1480.93 ± 14.54 1482.92 ± 14.19 1479.83 ± 13.75 1479.88 ± 23.04
32000 1411.22 ± 31.01 1416.51 ± 15.29 1424.03 ± 9.00 1424.84 ± 9.21
64000 1360.33 ± 12.93 1373.65 ± 13.88 1373.36 ± 11.71 1386.48 ± 8.68

llama-benchy (--pp 2048 --tg 128 --depth 0 12000 32000 64000 --runs 3, MTP server, temperature 0, order baseline, change, change,
baseline), with its prompt sampler seeded so both builds see the same prompts:

depth TTFT baseline (median ms) TTFT change decode baseline (median t/s) decode change
0 1,964 1,854 34.76 35.01
12000 2,065 1,983 31.83 32.07
32000 1,968 1,955 33.07 34.30
64000 2,041 2,021 32.25 32.58

Draft acceptance is identical request by request at every depth (llama-benchy's unseeded warm-up request excepted; it differs between
baseline runs too). Decode differences are within the run-to-run spread. The lower TTFT medians at 0 and 12000 come from one change
server (first change run 1,750 / 1,898 ms; the second matches the baseline), so no TTFT change is claimed.

Memory. On image histories master's GPU memory grows by 6-7 GB above the post-load level during a replay pass, the change's by
1-2 GB (3 pairs, both configs).

Correctness:

  • Text: fp32 logits, generated ids and recurrent/KV state byte-identical to the baseline on the four tests/corpus/correctness-*.txt
    corpora plus a 32,768-token (QSA active) and a 473-token prompt, at -ub 4096 and -ub 256 (24/24). MTP on text-only requests:
    identical generated, drafted and accepted token counts (3 pairs, 4 requests; token ids not compared). These are finite fixtures, not a proof for arbitrary input.

  • llama-perplexity (same file and model, f16 KV, FA on, b/ub 4096, QSA active), 2 runs per build, identical on every chunk:

    context chunks baseline change
    16384 6 1.0771 +/- 0.00285 1.0771 +/- 0.00285
    32768 3 1.0666 +/- 0.00257 1.0666 +/- 0.00257

    Corpus: 400,000 bytes of Python standard-library source (sha256 1cdee68b...). Perplexity covers text only; it cannot carry images.

  • test-backend-ops (ROCm0), complete suite minus SOLVE_TRI: 30980/30980 on both builds (SOLVE_TRI aborts on both, pre-existing). No
    ggml source changes in this PR.

  • test-qsa-prefix (new cases for rank blocks, holes, rollback across an image) and test-qsa-fast pass.

  • Full ctest: 63/72 pass on both builds, with the same 9 failed/not-run tests. These are test-grammar-parser, test-save-load-state,
    test-jinja-py, test-download-model, test-thread-safety, test-arg-parser, test-state-restore-fragmented, test-eval-callback-download-model
    and test-eval-callback. Network-dependent tests fail or are skipped in the container; test-save-load-state aborts on minimax-01-moe
    with hipErrorNotSupported on both builds. This is baseline parity, not an all-green ctest result.

  • Tracker vs full scan: a diagnostic build recomputed the metadata with the scan for every tracked ubatch and compared it (MTP with
    rollback, and serial): 7,706 + 3,855 comparisons, 0 mismatches.

  • Images (see Overview): with the selection order fixed (see below) the incremental pooled keys equal a full re-pool byte for byte, the
    first QSA layer's pooled keys are identical to master's, and the selected cells are master's minus its 0-3 tie-break cells.
    Logit difference vs master on a 107K history with 3 screenshots: KL mean 2.2e-2, top-1 agreement 0.969; for scale, master's own
    masked vs maskless difference on the text-only version of that history is KL 1.6e-2, top-1 0.954.

  • Task check with real screenshots (13 single-screenshot questions, 9 questions inside the 150K history with 14 screenshots, greedy):
    identical scores on both builds, two rounds.

  • Near the native context (change only, one run): history grown to 246K, screenshot question, a rollback that drops a screenshot turn (the
    server reused exactly the history before it), filled to 258,818 tokens (3.3K below the n_ctx - 1 boundary) and a final screenshot
    answered correctly. Not a repeated full-boundary quality validation.

Requirements

  • I have read and agree with the contributing guidelines
  • This change is Strix Halo specific, or justified by measurements on Strix Halo: the QSA indexer path is qwen4exp code in this fork
  • AI usage disclosure: ASSISTED. Claude (Anthropic) wrote the code and ran the measurements under my direction; I reviewed the design and
    results. Commits carry Assisted-by: Claude. CodeRabbit CLI reviewed the rebased six-file diff against 6aa81b88e: 0 findings.
  • Tested base: master 6aa81b88e.
  • What was NOT verified: CPU backend; image-history equivalence to master (not claimed: the selection changes); -ub other than
    4096/256; the Q4_K_M MTP head and n_max other than 3; other models with mrope; a multi-hour live agent session on the rebased head.
    Vulkan was run on the same Strix Halo (RADV, Mesa 26.2.3) only, at -c 131072, without test-backend-ops (no ggml change), perplexity
    or the near-native context run; other Vulkan GPUs (e.g. the gfx1150 that qsa : enable the incremental QSA cache on Vulkan #117 was validated on) were not tested.

🤖 Generated with Claude Code

Mushoz and others added 30 commits September 8, 2026 11:21
The gap and its consequence were diagnosed by Jaap Buurman: the sparse prefill
path gates on batch >= 64 and gather-to-compact gated on batch == 1, so batch
2..63 fell through to dense attention over the whole compressed KV, at a cost
that grows with context. That is where a speculative draft lands (n_max 3-5),
which is why token generation dropped off sharply with DSpark enabled. This
implements the fix for that diagnosis.

Give each query token its own gathered top-k block rather than deduplicating
into a union. No dedup pass, no atomics, and the size is bounded by
n_kv_raw + n_batch*n_top_k regardless of depth. Cross-token rows are neutralised
through the mask, which already encodes each token's selection: token t reads
-inf on any block that is not its own, so the softmax cannot double count. The
compact mask is token-major [n_batch][kv_c], which is what the GQA mask path
already expects (m_stride 0, rows stepped by gqa_iq1 * m_row_len).

Measured on gfx1151, test-backend-ops perf, medians of 2 launches, n_kv_raw=2304
n_top_k=512. Gather cost is flat in depth (894 us at batch 4 at every depth);
dense is not:

  kv rows   batch   dense      gathered   speedup
   11008      2     2194 us     680 us     3.23x
   11008      4     2204 us     895 us     2.46x
   11008      8     2217 us    2217 us     1.00x  (gate declines: kv < 2*kv_c)
   35584      2     7065 us     680 us    10.38x
   35584      4     7099 us     895 us     7.93x
   35584      8     7114 us    1316 us     5.41x
  133888      2     7552 us     680 us    11.10x
  133888      4     7971 us     894 us     8.92x
  133888      8     8348 us    1320 us     6.32x

Batch 1 is unchanged in behaviour and stays on the same code path.

Not validated end to end. A deduplicated union would shrink the gathered set
further wherever adjacent draft tokens select overlapping keys, and would lift
the batch ceiling documented in the following commit.

Suggested-by: Jaap Buurman (@Mushoz)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Both additions sit close to a limit that is invisible at the call site.

The parallel Lightning Indexer uses 62720 of 65536 bytes of shared memory at
N_WAVES=8 / HEADS_PER_TILE=4, so exactly one workgroup fits per CU. That is the
intended trade, but raising either constant overruns the budget and the pipeline
then fails to create and silently falls back to the small variant. Write the
arithmetic down next to the arrays.

The small-batch gather's compact set is independent of context depth but grows
with batch, so its attention work is quadratic in batch against dense's linear.
The kv >= 2*kv_c gate already caps this at about batch 6 at 32k depth and ~30 at
128k, and beyond the cap dense runs instead, so it is never slower. But the
measured speedups do not show that ceiling and it is the main argument for
building the deduplicated union later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Replaces the per-token top-k blocks with one row per DISTINCT selected key, so
the compact set stops growing linearly with batch. Measured on the real model,
adjacent tokens share 60% of their selections over 4 tokens and 76% over 8, so
the union is materially smaller than n_batch*n_top_k.

The size is only known on the GPU, so flash-attention now reads its KV bound
from a buffer instead of the push constant, behind a new DYNAMIC_KV pipeline
flag. Every other pipeline folds the flag away at compile time and is unchanged
(13308 FLASH_ATTN_EXT cases pass identically with the union off). No indirect
dispatch is needed: FA workgroup counts come from neq1/neq2/neq3 and never from
KV, so only the loop bound moves. Padding the count to 256 keeps KV % Bc == 0,
which lets the aligned pipeline variant still apply.

Dedup marks a bitmap from the top-k lists and compacts by scanning bitmap WORDS.
An earlier version scanned the mask row by row: simpler, but it made dedup cost
scale with depth and measured 0.71x at kv=133888, ie a loss. The bitmap form is
O(n_batch*n_top_k) to mark and R/32 to compact, and is depth-independent.

test-backend-ops perf, medians of 2, n_kv_raw=2304 n_top_k=512, fixture overlap
60% (the default generator produces near-zero overlap and would make the union
look worthless by construction):

  kv       batch  per-token  union    speedup
   35584     2      680 us    632 us   1.08x
   35584     4      892 us    720 us   1.24x
   35584     8     1315 us    900 us   1.46x
  133888     2      679 us    638 us   1.06x
  133888     4      892 us    726 us   1.23x
  133888     8     1314 us    909 us   1.45x

Union cost is flat in depth (720 vs 726 us at batch 4 across a 3.8x depth
range), which the per-token form was not.

Opt-in via GGML_VK_FA_TOPK_UNION=1, single stream only, and it falls back to the
per-token blocks when the compressed region exceeds the shared bitmap. The
kv >= 2*kv_c gate still uses the worst case, so the batch ceiling is NOT yet
lifted; doing that needs a GPU-side fallback for an oversized union.

Suggested-by: Jaap Buurman (@Mushoz)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
…size

The union shrinks the compact set but the gate could not see it. kv_c is the worst
case n_kv_raw + n_batch*n_top_k, so at batch 8 and 32k depth the gate priced 6400
rows against 11008 source rows, declined, and dense attention ran over the whole
compressed KV -- even though those selections deduplicate to about 3300 rows. That
is the batch ceiling the previous commit documented and did not lift.

The union size is only known on the device, and the note on the previous commit
assumed lifting the ceiling therefore needed a GPU-side fallback for an oversized
union. It does not. The union is bounded by the source, so there is no dispatch to
recover from; the missing piece was only a measurement. The union shader now writes
its count into a small host-visible buffer and the host prices the next step from
it. The read is deliberately unsynchronised and one graph stale -- overlap is a
property of the model and the draft, not of one op -- and a wrong estimate costs
part of one step, never correctness, because every allocation and dispatch bound is
still the worst case.

Estimates are held per batch size. A speculative decode varies the batch with the
accept count, and the overlap itself varies with the batch (0.64 at 2 tokens, 0.40
at 4, 0.24 at 8), so a single slot would be invalidated on nearly every step.

Each estimate tracks the latest measurement, lightly smoothed, and a declined step
dispatches the scan in a new count_only mode that writes no index list. Both of
those are deliberate and were measured the other way round first. Holding a decaying
peak to stay conservative, and sampling the probe every 256 declines to stay cheap,
together produced 1992 us on a shape the union runs in 898: the asymmetry actually
runs the other way, because an estimate that is too high declines compaction and
forgoes 2-3x for as long as it stays high, while one that is too low costs a single
step and is corrected by the count that step produces. Sampling makes it worse still,
since a decline is exactly the state in which the compact path stops refreshing the
estimate, so the stale value stays latched for the whole sampling period. The probe
is one workgroup against the ~2.2 ms dense op it rides along with, and it costs 0.2%
of a declined step.

The same measurement settles the reverse case. Where selections do not overlap, the
union is the same size as the per-token blocks and its scan is pure cost, so those
now stay on the per-token path rather than being admitted whenever the worst-case
gate happened to allow them.

test-backend-ops perf, medians of 3 launches counterbalanced A B B A A B, spreads at
or under 0.4%, n_kv_raw=2304 n_top_k=512, at kv=11008 (~32k source) which is the
shape where the gate used to decline. The fixture's ov is a per-token share, not the
union/selected ratio the model was measured by: at nb tokens it yields
(ov + (1-ov)*nb)/nb of the selections, so ov=86 is the setting that reproduces the
0.243 measured on DeepSeek-V4-Flash over 8 adjacent tokens, and ov=60 is deliberately
more pessimistic than the model.

  batch  overlap    before     after
    8    ov=86     2215.2 us   704.8 us   3.14x
   16    ov=86     2227.3 us   841.3 us   2.65x
    8    ov=60     2214.5 us   897.9 us   2.47x
   16    ov=60     2226.2 us  2231.2 us   1.00x  union does not fit, declined
    8    ov=0      2213.5 us  2219.0 us   1.00x  nothing to deduplicate, declined

The union cost is depth-independent as before, so the lifted cells now sit alongside
the deeper ones: 897.9 / 900.7 / 906.6 us at batch 8 ov=60 across kv 11008 / 35584 /
133888. Every cell at kv 35584 and 133888 is 1.00x: this changes which shapes are
admitted, not how the union performs once admitted.

Both declines above are the estimate working rather than failing. At ov=60 and batch
16 the union really is 5888 rows against 11008 source rows and compaction would not
pay; at ov=0 there is no overlap to exploit at all. Verified from the executed graph
with GGML_VK_FA_UNION_STATS=1, added here, which reports the measured union/candidate
ratio and the resulting decision rather than leaving engagement to be inferred from a
timing. It reads 0.246 at ov=86 and batch 8, against the 0.243 measured on the model.

13310 FLASH_ATTN_EXT cases pass with the union off and on, including three new
overlap cases at the shapes the gate now admits.

End to end on a model that has no top-k tensor at all, so the compaction path is never
reached and the change should be structurally inert: Qwen3-Coder-30B-A3B UD-Q4_K_XL,
same counterbalanced order, medians of 3. pp512 1578.20 -> 1591.67 t/s and tg32 96.89
-> 97.28 at d0; pp512 700.64 -> 699.32 and tg32 53.92 -> 54.45 at d16384. All within
1%. The union path itself cannot be checked this way: it needs batch 2..63 decode,
which only a speculative draft produces.

Still measured against a synthetic fixture rather than real draft tokens: that needs
a runnable target and draft pair, which this box cannot host.

Suggested-by: Jaap Buurman (@Mushoz)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
…xture calibration

The previous commit calibrated its fixture against a PROXY, because a proxy was all that was
reachable at the time: adjacent PREFILL tokens standing in for draft tokens. With the draft
now drivable, the proxy turns out to have been optimistic, and the correction runs against
that commit's own message.

DeepSeek-V4-Flash UD-IQ3_XXS with the DSpark draft, Vulkan attention and CPU experts, 25538
tokens of context. The gate reports union/candidate 0.560 and 0.609 on the target layers and
0.484 to 0.651 on a second shape (n_kv_raw 256, most consistent with the draft's own sparse
attention, also GPU-resident). Mean about 0.56 at batch 4, against 0.397 from the proxy at a
window of 4. Draft tokens diverge from each other more than adjacent prefill tokens do, which
is what a draft exploring rather than committed text should look like.

The fixture's ov maps to (ov + (1-ov)*nb)/nb, so ov=60 predicts 0.55 at batch 4 against 0.56
measured: ov=60 reproduces real drafting almost exactly. The previous commit says ov=86 is the
setting that reproduces the model and ov=60 is deliberately pessimistic. That is backwards for
real draft tokens, and the realistic headline at batch 8 is 2.47x rather than 3.14x. The
kernel numbers in that table are unchanged and still correct for the overlap each column
states; only which column describes reality has moved. Batch 8 overlap itself is still not
measured on real drafts, for the reason below, so 2.47x is an extrapolation from batch 4.

The path does engage: 56 of 57 sampled gate decisions took the union, and the single decline is
the unseeded first call, which then probes. That is the designed bootstrap, observed on the
real model rather than a fixture.

Scope, stated plainly because the previous commit implies more: DSpark drafts 3 tokens, so the
batch is 4 and --spec-draft-n-max does not raise it, an MTP head having a fixed width. At batch
4 and this depth kv_c is 4352 against n_kv 8704, so the OLD worst-case gate admits too and the
gate change makes no difference there; the smaller compact set comes from the union commit. For
this target and draft the gate change buys a lower depth threshold instead: roughly 21.0k
tokens rather than 25.5k on the target layers, and 11.8k rather than 17.7k on the second shape.
The 2.47x and 2.65x cells need batch 8 or 16, which this pair does not generate.

GGML_VK_FA_UNION_STATS takes a period now rather than being fixed at 256, and reports running
totals. The fixed period is why this took two runs: a 64-token speculative decode over 25k of
context emitted exactly ONE line, which reported only that the first call was unseeded. A
diagnostic whose sampling rate can hide the thing it exists to measure is not a diagnostic.

Measurements and provenance: ~/strix-results/derived-dsv4-union-gate-20260814.md section 9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Every other gate in this family is on by default with =0 to disable: GGML_VK_FA_TOPK,
GGML_VK_FA_TOPK_CM, GGML_VK_FA_TOPK_SPLIT. GGML_VK_FA_TOPK_UNION=1 was the odd one out
because it began as a prototype, and it is no longer one.

What makes it safe to default is that the path declines itself rather than needing a user to
know when to avoid it. Where the selections do not deduplicate, the estimate says so and the
per-token form runs instead; where the compact set would not fit under the gate, dense runs.
Both were measured, at 1.00x, rather than assumed.

Kept as one commit of its own rather than folded into the gate change, because it is a policy
change and not a mechanism one: reverting these four lines restores opt-in behaviour without
touching the gate logic, which is what a bisect would want if a regression turns up.

13310 FLASH_ATTN_EXT cases pass with the variable UNSET, which is the configuration this
changes and the one that ships. That is the same code path as the previously validated
GGML_VK_FA_TOPK_UNION=1, but it was run again rather than argued from equivalence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
The gather rejected anything but f16 K/V, so a DSv4 run with -ctk q8_0 -ctv q8_0 took the
dense fallback for every batch 2..63 decode - the whole small-batch path, silently off. Two
community test sets were run that way before anyone noticed, which is what prompted this.

The restriction was incidental rather than essential. The gather RELOCATES rows; it never
reads a value out of one. Addressing K as raw 4-byte words instead of f16 elements makes the
same shader serve any type whose row is a whole number of words, which every block-quantised
type here satisfies at head size 512. The union shader needed no change at all - it only ever
touched top_k indices.

Zeroing an unused row now writes zero BYTES. That decodes to zero for the block-quantised
types as well as for f16, because a zero scale zeroes the block; those rows are -inf in the
mask regardless, so zeroing only keeps a garbage dot product out of the softmax as a NaN.

The type gate asks ggml_vk_fa_kv_native rather than just "is it quantised". While the compact
scratch is active the dequant/contiguize pass is disabled by construction, and the assert
guarding that combination aborts rather than falling back, so admitting a type flash-attention
has no native shader for would crash instead of degrade.

test-backend-ops perf, kv=11008 n_kv_raw=2304 n_top_k=512 ov=60, gather off vs on:

  batch   f16 dense -> gathered        q8_0 dense -> gathered
    2     2192 -> 645 us   3.40x       3917 -> 1110 us   3.53x
    4     2203 -> 726 us   3.03x       3924 -> 1257 us   3.12x
    8     2213 -> 910 us   2.43x       3936 -> 1567 us   2.51x
   16     2219 -> 2232 us  declined    3930 -> 3952 us   declined

Two things worth stating because they contradict what I expected going in.

Compaction does NOT pay more on quantised K/V. The reasoning was that a gathered set is
contiguous and would dodge the strided-read and channel-aliasing taxes that hurt quantised
reads more; the measured ratios differ by 3 to 4%, inside noise. It pays the same proportion
from a worse starting point.

That worse starting point is the real result: q8_0 attention is about 1.78x SLOWER than f16
here, dense (3917 vs 2192) and gathered (1110 vs 645) alike. Halving the bytes does not pay
for the dequant work in the inner loop at this head size. So this change makes q8_0 K/V
survivable for DSv4 rather than advisable - f16 is still the faster cache by a wide margin,
and that inverts the usual q8_0-KV-buys-speed rule of thumb for this model.

13318 FLASH_ATTN_EXT cases pass, including 8 new q8_0/q4_0 top-k cases at the shapes a DSv4
decode actually hits. test_flash_attn_ext_top_k takes a type_K parameter for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Relocating quantised rows verbatim leaves flash attention to decode them in its inner loop,
which it does once per query block that reads a row rather than once per row. Measured, that
costs 0.15 us per KV row attended - 0.1481 / 0.1510 / 0.1512 us/row at batch 8 / 4 / 2 and
0.1565 dense, so a constant across a 3.6x span of row counts and across both regimes. It is
what made q8_0 attention 1.78x slower than f16 while reading half the bytes.

The gather is the natural place to pay it instead: it already touches every selected row
exactly once. Decoding there makes the compact scratch f16, flash attention takes its f16
path, and the redundancy disappears. No extra pass - the pass already existed.

test-backend-ops perf, kv=11008 n_kv_raw=2304 n_top_k=512 ov=60, q8_0 K/V:

  batch   dense    verbatim gather   decoded gather   f16 gather
    2     3917 us      1110 us           630 us          640 us
    4     3924 us      1257 us           715 us          727 us
    8     3936 us      1567 us           891 us          908 us

q8_0 now edges f16 by 1.5 to 1.8% rather than trailing it by 78%: the FA does identical f16
work either way, and the gather's scattered read side moves half the bytes. Against the dense
fallback a q8_0 cache took before any of this work, batch 8 is 4.41x.

Flash attention picks its pipeline from what the SCRATCH holds, not from the source tensor, so
k_type_eff/v_type_eff now also key off the compact state - the same mechanism use_dequant_kv
already used, which is why that variable existed in this form.

q8_0 only, deliberately. It is the type both community test sets used and the one worth having;
every other quantised type keeps the verbatim path, which is correct for them and validated.
Generalising means one shader variant per type through dequant_funcs.glsl, which is only worth
the shader-permutation cost if this shape of win reproduces elsewhere.

Not covered: the per-token gather still relocates verbatim, so a batch where the union declines
but the per-token form fits keeps paying the inline decode. Same fix applies, and the same
measurement will say whether it is worth a second shader.

13318 FLASH_ATTN_EXT cases pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
q4_0 turns out to want this more than q8_0 did. Its inline-decode penalty is the same shape - a
constant per KV row attended, flat across batch and across dense vs gathered - but larger:
0.172 to 0.179 us/row against q8_0's 0.148 to 0.153. Unpacking nibbles costs more ALU than the
halved bytes save, which is also why q4_0 was the SLOWEST of the three types before this.

  kv=11008 n_kv_raw=2304 n_top_k=512 ov=60, batch 2 / 4 / 8

  type    dense              verbatim gather      decoded gather
  f16     2192/2203/2213     -                    640/726/902
  q8_0    3917/3924/3936     1110/1257/1567       628/712/888
  q4_0    4134/4141/4145     1189/1342/1671       626/711/886

All three converge, because after the gather they run the same f16 attention; what is left is
the scattered-read side, where fewer bytes now wins. Against the dense fallback each type took
before any of this work, batch 8 is 2.45x for f16, 4.43x for q8_0 and 4.68x for q4_0.

The interesting part is what the first attempt got wrong. Generalising via dequantize4() from
dequant_funcs.glsl looked obvious and passed on q8_0, then failed q4_0 with NMSE 1.17. That
helper is permutation AGNOSTIC by design: mul_mat_vec only ever feeds it into a dot product,
where any consistent element order gives the same sum, so for q4_0 it returns nibbles in packed
order rather than element order. q8_0 passed only because its layout happens to be contiguous.
Materialising a row into memory is precisely the use where the order matters, so each type's
true mapping is now written out - for q4_0, byte j carries element j in its low nibble and
element j+16 in its high one, exactly as dequant_q4_0.comp materialises it.

That is also why this stops at two types rather than looping over the whole native list: every
type needs its mapping stated and tested, an #error guards anything else reaching the shader,
and q8_0 and q4_0 are the two actually used as a KV cache. q4_1/q5_0/q5_1/iq4_nl keep the
verbatim gather, which is correct for them.

13318 FLASH_ATTN_EXT cases pass, including q4_0 and q8_0 top-k cases at the shapes a DSv4 decode
hits, plus q4_0 perf cells alongside the q8_0 ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Generalising the gather from f16 elements to raw words (6b2cade) replaced

    [[unroll]] for (uint i = 0; i < HEAD_SIZE / LANES; ++i)

with a loop bounded by the row_words push constant, which cannot be unrolled and
pays a bounds check per iteration. Half as many, twice as wide accesses were still
6% slower on the smallest op the gather serves.

Bisected over the 19 beta3 commits with the tip's test file overlaid at every point,
so the instrument stayed fixed while beta3 grew its own perf cases. nb=1 at kv=8192:
54.58 us at the pre-beta3 base, flat through commit 16 (54.84), 58.24 at 6b2cade,
58.23 at the tip.

Step 4*LANES instead. Each access stays contiguous across the lanes, so coalescing
is unchanged, and an f16 row at head size 512 is 256 words == 4*LANES: one iteration,
no loop overhead. The tail loop carries the quantised row sizes, which are not
multiples of 4*LANES (q8_0 is 136 words, q4_0 is 72).

nb=1 against the pre-beta3 base, f16 K/V: -0.07% at kv=8192, -2.83% at kv=35584,
mean -1.29% across the six shapes, so the regression is gone rather than reduced.
Against beta3 as merged: -4.8% to -6.2%. The nb>=2 gains are untouched, worst cell
-0.51%. 13318 FLASH_ATTN_EXT cases pass.

Worth noting the gather itself was never the cost at nb=1: with
GGML_VK_FA_TOPK_GATHER=0 that cell is 249.84 us against 58.14 us with it on.

Assisted-by: Claude Opus 5
The union gather decodes q8_0/q4_0 on the way in, but it exists only for n_batch > 1: a single
token has nothing to deduplicate, so plain autoregressive decode takes the per-token gather,
which relocated rows verbatim and left flash attention paying the same inline decode the union
work measured at ~0.15 us per KV row attended.

flash_attn_gather_dq.comp closes that: same row mapping as flash_attn_gather.comp, same
per-type element mapping as flash_attn_gather_union_dq.comp (q4_0 materialised in element
order, not packed order). The compact scratch becomes f16 and flash attention takes its f16
path, keyed off the compact state exactly as the union path already is.

test-backend-ops perf, kv=11008 n_kv_raw=2304 n_top_k=512 ov=60, nb=1:

  type    verbatim gather   decoded gather
  q8_0    226.9 us          102.9 us
  q4_0    258.4 us          101.8 us
  f16     114.5 us          105.5 us   (f16 never decodes; its two runs bound the harness spread)

Both quantised types land on the f16 op time instead of trailing it 2.0-2.3x: after the gather
the attention work is identical f16, and the gather's scattered read side moves fewer bytes.

q8_0 and q4_0 only, as in the union: each type's element mapping must be stated and tested, an
#error guards anything else reaching the shader, and every other type keeps the verbatim
gather. nb=1 rows are added to the top-k perf grids so this regime stays measured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
ggml_vk_flash_attn_top_k declined any K/V but f16, so a DSv4 run with a quantised cache took
dense FA for every prefill batch - O(kv) against the sparse path's O(n_kv_raw + n_top_k), a
penalty that grows with depth. A 128GB community run measured it end to end: prefill
-5.4/-22.2/-39.4/-52.9% against f16 at source depths 17k/33k/67k/134k with -ctk q8_0.

The sparse shaders stay f16-only. A quantised cache is dequantised once per op into the
prealloc_x f16 scratch by the dense path's own fused dequant+transpose pipeline, and the
shaders read the scratch. K and V are the same tensor in this path, so one pass covers both
at half the dense path's scratch footprint. Types without a dequant-transpose pipeline,
oversized caches, and GGML_VK_FA_DEQUANT=0 keep the dense fallback.

test-backend-ops perf at nb=1024, kv 5504/11008/19200/35584 (the community depths in
compressed-K rows): q8_0 was 60.9 -> 427.6 ms dense (1.30x -> 8.27x of f16, growing with kv)
and lands within +0.8% of f16's flat 46.9-51.7 ms with the scratch; the dequant pass itself
costs under 1%. 36/36 FLASH_ATTN_EXT top-k cases pass, including 6 new quantised prefill
cases (nb=64 ns=1/2, nb=128 split form).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
The K tile is invariant across the head loop but coopMatLoad sits inside it, so
the shader re-read the same 8 MatrixA fragments from shared memory once per head
tile - 4x at N_HEAD=64. Each wave64 fragment load moves 1 KiB of LDS traffic
because the 16x16 f16 fragment is replicated 4x across the subgroup.

Same loads of the same data feeding the same coopMatMulAdd sequence, so the
output is bit-identical; the fragments just live in registers (8 of them, 16 f16
per lane) instead of being re-fetched.

gfx1151, test-backend-ops perf, kv 8704/33280/131584 x batch 1-15: 16-20% faster
at every shape, no spills, no regression. Batch 1 is ordinary DSv4 decode and
gets 12-18% of that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
…ecode CM shader

Builds directly on pepuscz's PR halo-box#6 and issue halo-box#10. Their per-kernel table - 43.8 us
scalar against 27.9 us small CM per 1k scanned rows at batch 5, and the same ratio
at every depth - is what showed the indexer's cost is a per-tile constant rather
than anything to do with tokens or bytes. Once the cost is per tile, the thing to
minimise is the tile count, and that is what this changes. The finding is downstream
of their work; only the choice of shader differs.

The decode CM shader puts 16 HEADS in the coopmat N dimension and dispatches one
workgroup per token, so it issues 4*n_batch tiles per 16 KV rows. That is the
arithmetic minimum - (64 heads x n_batch tokens) / 16 columns - because 64 heads
fill its 16 columns exactly, with no remainder at any batch. The small CM shader
puts 16 TOKENS in N and pays a flat 64 tiles however small the batch is, so at
batch 5 eleven of its sixteen columns are padding.

Measured cost tracks tile count: at kv=131584 batch 1 costs 1.95 us per 1k scanned
rows and batch 5 costs 26.28, i.e. 13.5x for 5x the work, and both shaders sit at
6.6-7.8 ns per tile.

The decode CM shader body has no n_batch == 1 assumption - token is
gl_WorkGroupID.y and it indexes q/w/mask/dst by it - so the old gate was an
artefact of where it was written.

gfx1151, kv=131584 (526k source tokens), us/run, shipped vs this:
  batch  2  2335.6 ->  419.0  (5.6x, was on the scalar path)
  batch  3  3492.3 ->  631.8  (5.5x, was on the scalar path)
  batch  4  3267.9 ->  840.8  (3.9x)
  batch  5  3465.6 -> 1119.4  (3.1x)  <- DSpark n-max 4 verify shape
  batch  8  3704.9 -> 1764.1  (2.1x)
  batch 15  4467.9 -> 3231.6  (1.4x)
Batch 16 and 32 are unchanged, which confirms the arms are isolated.

PR halo-box#6's route is kept as the opt-out arm rather than deleted, and is promoted from
opt-in to default-on so that one variable is enough to reach it:
  default                            decode CM for the whole 2-15 window
  ..._DECODE_CM_BATCH=0              small CM for 4-15, scalar for 2-3 (PR halo-box#6)
  ..._DECODE_CM_BATCH=0 SMALL_CM=0   scalar for 2-15 (pre-PR halo-box#6 baseline)
Verified all three route as documented and pass 29/29. Keeping it is not just
courtesy: the tile-count argument is hardware independent, but decode CM re-reads
the K tile once per token and that part is bandwidth dependent, so the crossover
need not sit in the same place on other devices, and these numbers are from one
gfx1151 box.

A (head, token) packing shader was also tried and is strictly worse - it reaches
4b tiles only when the batch divides 16, and it divides the workgroup count by the
batch. Kept out of tree.

Numerics: decode CM sums the 64 heads in tiles of 16 rather than one at a time, so
f32 accumulation order differs from the small CM path. Cleared by KLD A/B on
trunc10 (-c 8192, -b/-ub small so every indexer dispatch routes through the window,
8 chunks wikitext-2, same binary, only the env var differing):
  -ub 8  mean KLD 0.000000, max 6.0e-5, RMS dp 0.000%, same-top 99.994%,
         PPL 10507558.3156 identical in both arms
  -ub 5  mean KLD 0.000000, max 8.8e-4 but 99.9% 4.9e-5 (one tail event, no argmax
         flip), RMS dp 0.000%, same-top 100.000%
Same class as PR halo-box#6's own measured numbers (max 5.5e-5, same-top 100.000%) and 45x
tighter than the FA_WAVE32 change already shipped.

Adds the eval coverage the 2-15 window never had (batches 4/8/15 at kv=256) and
the perf grid these numbers came from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Same defect as the decode CM shader, larger blast radius. `k_sh[wave]` is written
once above the head loop and never touched again, but `coopMatLoad` sits inside
both the head_base and head_local loops, so each wave re-reads the same 8 MatrixA
fragments N_HEAD times: 512 loads where 8 would do. Each wave64 fragment load
moves 1 KiB of shared-memory traffic because the 16x16 f16 fragment is replicated
4x across the subgroup.

Same loads of the same data feeding the same coopMatMulAdd order, so the output is
bit-identical; the fragments live in registers (8 of them, 16 f16 per lane) instead
of being re-fetched.

This source compiles to both the prefill pipeline and the small-batch one, so both
gain. gfx1151, test-backend-ops perf, us/run:

  prefill (lightning_indexer_cm_f16)
    kv 8704   nb 2048   27492.5 ->  18207.0   1.51x
    kv 33280  nb 2048  104101.9 ->  68947.8   1.51x
    kv 131584 nb 2048  421239.8 -> 281540.5   1.50x
    kv 131584 nb 16      3456.2 ->   2329.2   1.48x
    kv 131584 nb 32      6858.6 ->   4623.6   1.48x
  small batch (lightning_indexer_cm_small_f16)
    kv 131584 nb 5       3433.1 ->   2783.3   1.23x
    kv 131584 nb 8       3682.3 ->   3105.1   1.19x
    kv 131584 nb 15      4474.4 ->   4209.5   1.06x

1.48-1.54x on prefill at every depth and batch measured, flat. The small-batch
variant gains less, which says the 1-wave configuration is bound by its Q staging
and epilogue rather than by fragment traffic.

No regression at any shape, so the 8 held fragments (64 VGPRs per lane) neither
spill nor cost occupancy even in the prefill pipeline, which runs 512 threads at
62.7 KiB of shared memory and was already pinned to one workgroup per CU.
LIGHTNING_INDEXER 29/29.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
…ation constant

The indexer pipelines hardcoded nh=64 (DeepSeek-V4's geometry) in supports_op and
as a shader constant, so any other head count fell to the CPU backend.
Qwen3.8-Flash-Next (qwen4_exp, released today) carries the same indexer at nh=4.

N_HEAD becomes specialization constant 1, one pipeline per supported head count
(LI_NH_VALUES = {4, 32, 64}), selected by q->ne[1] at dispatch. It must be a
specialization constant, not a push constant: the head loop's trip count has to
stay visible to the compiler - as a push constant the loop cannot unroll and
decode costs ~55% (measured, gfx1151, kv=131584, and why this commit is not the
simpler design).

nh=4 does not fill a 16-wide head tile, so the tail tile stages q as zeros
(relu(0)*w = 0, padded heads drop out of the sum) and the weight read clamps its
index. Both guards are gated on N_HEAD % TILE != 0, a specialization-constant
expression, so at nh=64 they fold away at pipeline compile and the codegen is
equivalent to the previous shader - measured, because relying on the compiler to
range-prove head < N_HEAD instead still cost ~45%:

  kv=131584, us/run          before      after
  decode  b1                  226.8      219.6
  decode  b5                 1058.8     1007.4
  decode  b15                3174.0     2966.8
  prefill b16                2329.2     2230.2
  prefill b2048            281540.5   281169.1

No regression at nh=64; the small decode improvement is within a cautious
reading of run-to-run variance and is not claimed.

test-backend-ops LIGHTNING_INDEXER: 44/44, up from 29/29 - the suite's existing
nh=32 cases had been silently reporting "not supported" under the old gate and
now run against the CPU reference.

Head size stays pinned at 128: the coopmat tiling and f16vec4 staging both
assume it, and every known indexer model (DSv4, GLM-DSA, Qwen4-exp) uses 128.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
The scalar64 shader kept its hardcoded 64-head loop when the pipelines went
per-head-count; nh 4 and 32 would have run with the wrong trip count.

Assisted-by: Claude Fable 5
The GEMM2 P load depends only on bc_chunk, but sat inside the hsv_tile
loop, so all four fragments were re-read from shared memory once per
tile (2 tiles at HSV=128, 4 at HSV=256).

Load them once into a coopmat array before the loop. Psh is not written
again until the next KV block, so the fragments stay valid across the
barriers inside it.

Measured on gfx1151 (RADV), Qwen3-Coder-30B-A3B, pp2048 f16 KV:
d8192 +6.9%, d16384 +8.1%, d32768 +9.2%. VGPRs and LDS unchanged,
0 spilled. FLASH_ATTN_EXT suite 5105/5105.

Assisted-by: Claude Fable 5
Psh held P as [kv][query]. The GEMM2 UseA load therefore had to request
ColumnMajor, and RADV only attaches an alignment hint to the internal
column-major case, which for UseA means the RowMajor request. The load
was emitted as 16 separate 16-bit shared reads per fragment.

Store P as [query][kv] instead and request RowMajor. The producer now
writes four scalar components rather than one vec4; those go to disjoint
bytes, so there is no read-modify-write and no partially written vec4.

Also updates the host shared-memory estimator, which mirrored the old
stride and would otherwise disagree with the shader.

Perf-neutral on gfx1151 (within 1% at hd128 and hd256), but it shrinks
Psh: LDS 16384 -> 15360 B and code size 13896 -> 13596 at hd128, VGPRs
unchanged at 96 with 0 spilled. Kept because it is free and removes a
scalar shared-memory access pattern. FLASH_ATTN_EXT suite 5105/5105.

Assisted-by: Claude Fable 5
get_fa_tuning_params_coopmat1 took device->subgroup_size unconditionally,
so on a 64-wide device the coopmat1 FA path always ran wave64. The
sibling scalar path already does AMD-specific wave selection; the
coopmat1 path never got the equivalent.

Narrowing is free exactly when it does not add an iteration to the
O-accumulation loop, which runs ceil((HSV/4) / threads_per_rowgroup) per
row. On a 64-wide device the test reduces to hsv <= 128. Above it the
narrow subgroup issues 1.5x to 1.8x the instructions for the same SIMD
passes and hd256 measures 6 to 18 percent slower, so the rule declines.

Gated behind GGML_VK_FA_WAVE32 (=1 rule, =2 forces the pin regardless of
head size, diagnostic only). Off by default.

Measured on gfx1151 (RADV), model-level pp2048, Qwen3-Coder-30B-A3B:
d0 +2.5%, d8192 +8.4%, d16384 +10.1%, d32768 +11.3%. Op-level across
head sizes, largest where wave64 wastes the most lanes: hsv=64 +12.3%,
hsv=96 +7.4%, hsv=128 +6.5%, hsv=256 correctly declined.

Full test-backend-ops suite 15884/15884 at =0, =1 and =2; the pin was
confirmed to engage in-band via pipeline VGPR statistics rather than
inferred from the env var being set.

Assisted-by: Claude Fable 5
Split from the fork's flag-flip commit (f7d804ee7): GGML_VK_FA_WAVE32
now defaults to 1 (apply the HSV-based narrowing rule); =0 opts out and
=2 keeps its diagnostic force meaning. The subgroup_size_control device
guard is unchanged, so devices without a 32-wide subgroup mode are
unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Select the matmul_id tile by expected per-expert token count
(nei1*nei0/n_expert) instead of aggregate nei1, which always picked the
widest tile and left most N-lanes empty at MoE prefill (~16 rows/expert
vs BN=64). Gated on GGML_VK_MMID_SMALLN=1, default off pending
cross-model validation.

Was null (+0.7%) standalone pre-row-lists: smaller tiles meant more
workgroups each re-paying the per-WG id scan. With the Stage 1 row-list
prepass the scan is gone and the occupancy win materializes.

Strix Halo (RADV gfx1151), Qwen3.6-35B-A3B UD-Q5_K_XL, clean window,
pp512 4-way (rowlists x smalln): 914.7 / 937.9 / 1005.3 / 1063.6 t/s
(combined +16.3% vs baseline). MUL_MAT_ID q5_K 2.33->4.43 TFLOPS (1.91x),
q6_K 1.99->3.57 (1.79x) cumulative. Hot pipelines verified via probe:
matmul_id_*_f32 (y_f32 path, quantize_y does not engage for these quants);
smalln flips tile _m -> _s. 790/790 MUL_MAT_ID both rowlists configs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Two env-gated overrides of the mmid small-tile config (KHR coopmat
branch only, scoped to the mul_mat_id quant pipelines; dense untouched):

- GGML_VK_MMID_TILE16: BN/WN 32->16. NEGATIVE (-3.8% e2e): experts with
  n_e>16 split into two column tiles and re-stream their full weight
  matrix; A-traffic scales with sum(ceil(n_e/BN)), so BN must not drop
  below the mean per-expert n. Kept as a documented dead end.
- GGML_VK_MMID_BM64: BM 32->64, BLOCK_SIZE 64->128 (two warps). +1.3%
  e2e on top of rowlists+smalln: same A-traffic, half the ir-tiles so
  half the per-expert B re-reads, larger WGs hide latency.

Strix Halo, Qwen3.6-35B-A3B UD-Q5_K_XL pp512, clean window, all on top
of RL+SN control 1065.4 +/- 3.3: BM64 1078.9 +/- 3.5, TILE16 1024.6,
BM64+TILE16 1007.5. Cumulative vs pre-Stage-1 baseline: 914.7 -> 1078.9
(+17.9%). 790/790 MUL_MAT_ID for all configs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
…ated)

Extends the BM64 idea to the medium tile: GGML_VK_MMID_M128=1 raises the
mmid m-tile to BM=128 / BLOCK_SIZE=4*subgroup (four warps), same
A-traffic, half the ir-tiles and per-expert B re-reads. The medium tile
is what the per-expert-n heuristic selects at n~64 (e.g. ub2048 on
256-expert/top-8 models, or ub1024 at 128 experts).

Strix Halo, Qwen3.6-35B-A3B UD-Q5_K_XL pp2048, drain-verified window:
ub2048 stack 1039.9 +/- 1.1 -> +M128 1091.6 +/- 2.6 (+5.0%); inert in
the s-tile regime (ub1024 1137.2 vs 1147.9 ref). ub1024 remains the
throughput sweet spot for this model. 790/790 MUL_MAT_ID.

Also measured this session, NOT kept: caching counts+row lists across
the three expert matmuls of a layer (they share one ids tensor) was
correctness-clean but perf-null (1066.3 vs 1070.9 baseline) - the
prepass dispatches and barriers are already free on this queue; reverted
rather than carry the invalidation surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Force required subgroup size 32 on the KHR-coopmat mmid quant pipelines
(pipeline_dequant_mul_mat_mat_id[*] only; dense untouched). Hypothesis:
RDNA3.5 WMMA is wave32-native, so RADV may lower KHR_coopmat better at
wave32 than at the reported default of 64.

Mechanics: the cm1 path in mul_mm.comp derives its warp grid from the
real subgroup (warp_i = gl_SubgroupID, tiw = gl_SubgroupInvocationID)
while NUM_WARPS = BLOCK_SIZE/WARP (spec constants) sizes coopmat_stage[]
and ballots_sh[] and warp_r/warp_c assume NUM_WARPS == (BM/WM)*(BN/WN).
Forcing sg32 with WARP=64 would over-run those shared arrays and leave
warp_c outside the tile, so the gate (a) sets WARP=32 in shadowed copies
of the s/m/l mmid warptiles (composes after the BM64/M128 shadows) and
(b) halves WM (or WN, keeping WM>=TM, WN>=TN) until the doubled subgroup
count exactly tiles BM x BN again, asserting both invariants. BLOCK_SIZE
is kept, so workgroup shape, load loops and shmem match the wave64 stack;
per-lane accumulator footprint is also unchanged (half the lanes per
subgroup, half the (WM/TM)*(WN/TN) fragments). The required size is
passed via a scoped CREATE_MM redefinition adding a trailing
required_subgroup_size arg (fp16-branch pattern), gated on
subgroup_size_control covering 32 since ggml_vk_create_pipeline_func
silently drops the required size otherwise.

Correctness (test-backend-ops -o MUL_MAT_ID -b Vulkan0): 790/790 plain,
790/790 WAVE32=1, 790/790 WAVE32+SMALLN+BM64.

Perf, Qwen3.6-35B-A3B-UD-Q5_K_XL, fa=1 b/ub=512 ctk/ctv=q8_0 pp512 r=3,
atomic window, canary clean:
  stack (SMALLN+BM64), wave64 default: 1076.61 +/- 3.71 t/s
  stack + WAVE32:                      1106.73 +/- 2.52 t/s  (+2.8%)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
Convert contiguous f32 activations (B) to f16 for quantized MUL_MAT_ID on
KHR_coopmat devices and run the matmul_id_subgroup_<quant>_f16 kernels
instead of the f32-B ones. Halves B bytes and buf_b shared memory. The
_f16 SPIR-V already exists for every quant type; this adds a parallel
env-gated pipeline array (pipeline_dequant_mul_mat_mat_id_f16b), extends
the getter to return it for src1=F16 on non-coopmat2 devices, relaxes the
src1-type assert, and forces the existing y_non_contig convert-to-
prealloc_y plumbing (same as coopmat2). Default OFF, zero behavior change
when unset.

Measured on Radeon 8060S (RADV gfx1151), Qwen3.6-35B-A3B-UD-Q5_K_XL,
-fa 1 -b 512 -ub 512 -ctk q8_0 -ctv q8_0 -p 512 -r 3, stacked on
GGML_VK_MMID_SMALLN=1 GGML_VK_MMID_BM64=1:

  stack (f32 B, canary):  pp512 1075.04 +/- 8.66 t/s
  stack + F16B:           pp512 1100.66 +/- 2.37 t/s   (+2.4%)

test-backend-ops test -o MUL_MAT_ID -b Vulkan0: 790/790 with and
without GGML_VK_MMID_F16B=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Assisted-by: Claude (Opus 5)
The MMQ J config is chosen from n, so a wave-partitioning error in one J tile
only shows up when n sits on that tile's boundary. The neighbouring n picks a
different J and passes, which hides it. The existing quantized cases stop at
n=129 and the general MUL_MAT set jumps 64 -> 4096, so nothing lands on 256 or
512 and the whole class went untested.

Sweep n over 255/256/257/511/512/513 for q8_0, q4_0, q4_K, q5_K and q6_K, in
both MUL_MAT and MUL_MAT_ID.

On an RDNA3.5 build that runs the J128 kernel with 16 wave32 waves over a
128-row tile these fail for every n that selects J128 and pass at n=513, which
selects J112. A build predating that config passes the whole sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit b54cd8a)

Assisted-by: Claude (Opus 5)
dzannotti and others added 15 commits October 8, 2026 15:15
vulkan : WMMA selected-key attention for QSA block selection
…cratch

flash_attn_cm1_mr.comp: one workgroup of eight wave32 subgroups owns 64
rows (H heads x 64/H tokens that share a KV head). Q stays in registers
for the whole KV loop and every 16-key K/V tile serves all 64 rows,
where cm1 pins Br at 16 and streams the whole K/V range per 16 rows.
K is read as per-head f16 rows, V from a per-head V^T scratch written by
dequant_f16_transpose_vt.comp ([HSV][KV + 128], 64x64 LDS tiles).
Keys masked in every output row have their V zeroed per tile, so a
WMMA -0.0 product cannot move the output and NaN V in masked cells
cannot leak.

GGML_VK_FA_MR: default on for head size 256 on RADV RDNA3, =1 for every
eligible shape (head size 128 or 256, f16 K after the contiguize pass,
f32 accumulation, KV % 64 == 0, mask shared across heads), =0 off.
GGML_VK_FA_MR_LAZY / _NOEB / _VPOST (default on) are schedule knobs.
GGML_VK_FA_VT: V^T for the cm1 kernel too (default on RADV RDNA3 when
NAN_SAFE_V and shmem staging are off), =0 also disables MR.
New flag bits 64..1024; NAN_SAFE_V keeps 32.

Measured in the fork (v0.7.8) on gfx1151, Qwen3.8-27B UD-Q4_K_XL:
FA per pp2048 ubatch at d32768 2067 -> 954 ms, pp2048 +21.5% at d32768,
+5.7% at d8192, KLD 0.000197 against the cm1 kernel.

Assisted-by: Claude Opus 5.5
…CONV + SILU)

Gated DeltaNet layers build CONCAT(state, transpose(x)) -> SSM_CONV -> SILU,
plus copies of the last 3 concat columns into the recurrent conv state (one
per rollback slot). The concat transposes x into a [T+3, C] tensor that the
conv reads once: 84 MB written and read per layer for Flash-Next at a
2048-token ubatch.

This follows the CUDA backend's gdn_conv match (same graph, no llama change):
- at the CONCAT, when its only readers are one SSM_CONV (+ SILU right after)
  and conv-state tail copies, write only the tail columns those copies read;
- at that SSM_CONV, run a direct conv + SiLU: one thread per channel and 8
  tokens, the 4-tap window sliding in registers, lanes along channels, the same
  dot() as the existing nc == 4 path (identical output).

The decision is made once at the concat and consumed at its conv. The match
checks the allocated addresses (the SiLU output overlaps nothing the conv
reads, and nothing between the concat and the conv writes x or the state), and
graph_optimize asks the allocator to keep x and the state alive through the
SiLU. x and the state are also added to the barrier tracking. Ubatches under
8 tokens per sequence keep the concat (decode/verify unchanged).
GGML_VK_SSMCONV_DIRECT=0 restores the concat + SSM_CONV_SILU pair;
GGML_VK_FUSION_DEBUG=1 prints why a concat did not match.

test-backend-ops: new CONCAT_SSM_CONV_SILU cases (scheduler-allocated, verify
the SiLU output and every state copy): n_t 1..300, ragged channels and token
tiles, two sequences, 1/4/8 rollback slots (8 reaches into the state columns),
10240 channels, and a whole-concat extra reader that must not fuse.
Doubling the direct-conv store fails the fused cases + GDN_CONV_PREFILL;
doubling the tail store fails the fused cases' state copies; both pass with
GGML_VK_SSMCONV_DIRECT=0. Only tested on gfx1151 (RADV).

llama-bench pp2048 -ub 2048, same binary with the gate off/on, ABBA x2, -r 2
(mean of 8): Flash-Next REAP-320 778.4 -> 790.3 t/s (+1.5%), Qwen3.6-35B-A3B
2101.8 -> 2161.0 (+2.8%). Every gate-on sample beat every gate-off sample.
Flash-Next PPL c4096 (2 ubatches per chunk) identical per chunk.

Assisted-by: Claude Opus 5.5
… fusions

rms_norm.comp runs one 512-thread workgroup per row, so on 128-wide rows (the GDN
head norms) 3/4 of the lanes idle and every row pays a shared-memory tree reduction.
rms_norm_small.comp gives each row one subgroup (subgroupAdd), for ne00 <= 256,
f32 in/out, contiguous rows. It handles plain RMS_NORM and RMS_NORM_MUL, plus two
fusions only it has:

- RMS_NORM_MUL_MUL: the gated norm, normalised * gamma * gate (qwen3.5/qwen4exp
  build_norm_gated). The gate chain (sigmoid/silu of z) sits between the two MULs
  in the graph, so graph_optimize now schedules it ahead of the norm and keeps the
  three nodes adjacent. The products are the same f32 multiplies as the separate ops.
- RMS_NORM_SCALE: build_gdn_l2_norm, scale(rms_norm(x, eps/n), 1/sqrt(n)). The
  factor is applied on the store after rounding the normalised value to f32, the
  same two roundings as the SCALE op. graph_optimize keeps the SCALE behind its norm.

The kernel strides over rows, so the grid can be capped at 65535 workgroups and a
subgroup size other than the one the host assumed still covers every row.

GGML_VK_NORM_SMALL=0 restores the old path (and disables both fusions);
GGML_VK_FUSE_NORM_EXTRA=0 keeps the kernel but disables the two fusions.

gfx1151 (RADV), same binary with GGML_VK_NORM_SMALL off/on, ABBA x2, -r 2
(mean of 8): Flash-Next REAP-320 pp2048 -ub 2048 773.3 -> 785.0 t/s (+1.5%),
Qwen3.6-35B-A3B pp2048 -ub 2048 2100.4 -> 2145.5 (+2.1%), Qwen3.8-27B pp512
-ub 256 430.5 -> 431.6 (flat). Norm ops 640/640 in all three gate settings.
PPL moves only by the reduction order (subgroupAdd vs the shared-memory tree).
Only tested on gfx1151.

Assisted-by: Claude Opus 5.5
updated readme.md to remove halo-box fork
vulkan : multi-row coopmat1 flash attention for prefill, with a V^T scratch
The cm1 int8 MMQ epilogue wrote its f32 result one element at a time. On
the RDNA3/3.5 wave32 accumulator layout lane l holds column l % 16 and rows
(l / 16) + 2e, so every store instruction scattered 4-byte writes over 16
columns. Two subgroupShuffleXor(16) per four elements now give each lane four
adjacent rows of its column, and the lane writes them as one vec4. This
covers MUL_MAT and MUL_MAT_ID.

- The packed path needs stride_d (and batch_stride_d or the batch offset) to
  be a multiple of 4. Otherwise the per-element store runs, for example with
  odd M. A vec4 that crosses the row end falls back to scalar stores, so
  nothing is written past M.
- It is enabled by spec constant 12 (PACKSTORE) of mul_mmq_cm1.comp.
  GGML_VK_MMQ_PACKSTORE=0 turns it off. RDNA4 (blocked layout) and other
  tile shapes keep the old store.
- test-backend-ops: new int8 MMQ cases with ragged M/N (M = 20, 36, 132,
  199, 68x2 batch; N = 67, 129) for MUL_MAT and MUL_MAT_ID.

Results on gfx1151 (RADV):
- MUL_MAT + MUL_MAT_ID 14980/14983 with the gate on and off; the 3 failures
  are the same pre-existing CONCAT k=5120 cases in both arms.
- Positive controls: swapping lanes inside the vec4 fails thousands of
  MUL_MAT and MUL_MAT_ID cases with the gate on and none with it off.
  Dropping the row-end check fails the ragged-M cases, while odd M = 199
  passes because it takes the scalar fallback.
- llama-bench, same binary with the gate off/on, ABBA x2, -r 2 (mean of 8):
  Flash-Next REAP-320 pp2048 -ub 2048 769.5 -> 796.3 t/s (+3.5%),
  Qwen3.6-35B-A3B pp2048 -ub 2048 2100.5 -> 2187.1 (+4.1%),
  Qwen3.8-27B pp512 -ub 256 430.9 -> 441.5 (+2.4%). Every gate-on sample
  beat every gate-off sample.
- Flash-Next perplexity is identical per chunk with the gate on and off.

Assisted-by: Claude Opus 5.5
MUL_MAT_ID followed by MUL with the router weights ([1, n_expert_used,
n_tokens]) was fused only on the mat-vec path. At prefill the expert outputs
were written as f32, read back by a separate MUL, scaled and written again.

The int8 MMQ coopmat1 MUL_MAT_ID pipelines get a sixth binding holding the
weights. Bit 1 of the hoist_row_ids push constant tells the epilogue to scale
each output row by its (expert slot, token) weight before the store. The
product is the same f32 multiply the separate MUL does, so results are
identical.

The fusion is admitted only when ggml_vk_mul_mat_id_q_f16 will take the int8
MMQ path (checked against the pipeline's binding count); other MUL_MAT_ID
GEMMs keep the separate MUL. GGML_VK_MMID_EPILOGUE=0 disables it. The packed vec4
store path scales its four rows by the same column weight.

test-backend-ops: 9 new MUL_MAT_ID_FUSION cases (q8_0, iq4_nl, q4_K; n_used
10/4; ragged and odd M). MUL_MAT_ID + MUL_MAT_ID_FUSION + MOE_PREFILL
7561/7561 with the gate on and off. Scaling by twice the weight in the new
epilogue fails every GEMM-path fusion case with the gate on and none with
it off.

gfx1151 (RADV), pp2048 -ub 2048, same binary with the gate off/on, ABBA x2,
-r 2 (mean of 8): Flash-Next REAP-320 775.7 -> 800.2 t/s (+3.2%),
Qwen3.6-35B-A3B 2100.4 -> 2207.5 (+5.1%). Every gate-on sample beat every
gate-off sample. Perplexity is identical in every chunk.

Assisted-by: Claude Opus 5.5
…th chunk

The compute buffer is split into chunks of at most the backend's max size
(1 GiB on Vulkan), but the chunk list was a fixed array of 16, and the 16th
chunk was made unbounded so it could take whatever was left. A compute buffer
past ~16 chunks therefore put everything remaining in one chunk that can be
larger than the device allows: Flash-Next at -c 262144 on a 128 GB Strix Halo
needed a 19.5 GB compute buffer and failed on a 4.4 GiB chunk, over RADV's
4 GiB buffer limit (issue 22).

The chunk lists of the allocator and of the virtual buffer now grow (16, then
doubling), and every chunk stays at the max size unless one tensor alone is
larger. Graphs that fit in 16 chunks are laid out exactly as before.

test_not_enough_chunks now also checks that every chunk stays within max_size; it
fails on the old layout.

Assisted-by: Claude Opus 5.5
vulkan : GDN conv straight from x and the state, and a short-row RMS norm with two fusions
…unks

ggml-alloc : grow the chunk list on demand instead of an unbounded 16th chunk
vulkan : vec4 store for int8 MMQ on RDNA3, and MoE router weights in the MUL_MAT_ID epilogue
server : add --spec-draft-max-slots to stop drafting under concurrency
qsa : enable the incremental QSA cache on Vulkan
@dzannotti

Copy link
Copy Markdown
Collaborator

@iTechMedic you might need a rebase, PR looking great tho

With one image anywhere in the cache, qsa_scalar_visibility_cells rejected every ubatch, so all later decode and
verify steps took the masked top-k path. The mrope mask compares 2-D extents only between a cell and a query at the
same linear position, and with one sequence in the cache the selection already ranks the cells in the mask's own
order (position, then y, then x). Such a cell is therefore harmless unless a query of the ubatch sits at its
position. Several sequences are not ranked, so any 2-D cell still keeps the masked path there, as do 2-D queries.

Text-only caches are unchanged.

Assisted-by: Claude
…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
…image)

An MTP draft context never receives an image's cells, so its positions skip each image's extent. Its prefix was
invalid from the first image on, and every later draft step re-pooled all indexer blocks and rebuilt the metadata
with the per-cell scan, in proportion to the context.

Without an image in the cache the selection pools position blocks and numbers the complete ones in order; a block
with a hole is never pooled, and a query's tail is its own position block with -1 for missing cells. The tracked
prefix now keeps that list of complete blocks (first rank, and the start the selection compares and ropes with),
so the closed-form metadata and the incremental pooling work on such a prefix too. Rank blocks after an image are
the same list. The pooled keys are rebuilt if the numbering changes under existing blocks, and a prefix with holes
only matches while its sequence is all the cache holds. The prefill scoring bound keeps its old rule.

Recovery accepts such a prefix too: test-qsa-recover's hole case now expects position blocks instead of a refusal.

Text-only caches (no holes, no image) are unchanged.

Assisted-by: Claude
test-qsa-recover now rebuilds seq 0 after every random step of a cache it shares with two other conversations, cells in
any order: text, mrope images, position holes (an MTP draft), rollbacks, and a cell shared for a while (refused). The
rebuilt tracker must equal the incremental one in every field the selection metadata reads (cells, ranks, mrope keys,
first duplicate and gap, complete blocks and their starts).

Assisted-by: Claude
@iTechMedic
iTechMedic force-pushed the fix/qsa-image-history branch from c33392f to 0a3e66d Compare October 10, 2026 12:19
@iTechMedic

Copy link
Copy Markdown
Author

@dzannotti thanks, rebased onto master 6aa81b88e (with #116 and #117). New head: 0a3e66dd2 (4 commits; still a draft).

Integration:

Validation on Strix Halo, base vs rebased head (details in the updated description):

  • Text: logits/ids/state byte-identical on ROCm and Vulkan (4 corpora + 32K + 473, ub 4096/256), perplexity identical per chunk, ROCm test-backend-ops 31087/31087 on both, excluding SOLVE_TRI (the same crash on both builds).
  • Tracker vs full scan (diagnostic build), screenshots, MTP holes, rollback, RAM-cache restore and two conversations in one unified cache: 0 mismatches on ROCm, 0 on Vulkan (RADV).
  • Screenshot histories: decode +25.5 % serial / +22.6 % MTP (+32 % / +26 % above 100K), MTP round 94.5 -> 73.9 ms on ROCm; on Vulkan +74 % (about 2x above 100K, smaller sample); text-only unchanged. At ~258K tokens with 14 screenshots the head peaks 10.8 GiB lower in GTT than master in this one run.
  • CodeRabbit on the rebased diff: 0 findings.

Unrelated, pre-existing on master (found while testing, not changed here): with two conversations in a unified cache, one holding an image, the full scan aborts in set_input_qsa_scan (group_members assert) for the other conversation's text ubatch; reproduced on unmodified 6aa81b88e with LLAMA_QSA_INCREMENTAL=0. Happy to open a separate issue.

Remaining validation limitations, including the standard PP2048/llama-benchy matrices not repeated after rebase and Vulkan backend-ops not run, are listed in the description.

Still open: the image-history selection policy (complete blocks + own tail instead of the masked route's 2051 cells) needs a maintainer decision before this leaves draft.

iTechMedic added a commit to iTechMedic/strix-llama.cpp that referenced this pull request Oct 10, 2026
…box#163

Preserve the exact four source commits at 0a3e66d on Strix base 6aa81b8. No conflict resolution or application-code edits. Existing s50 evidence applies to the identical code tree; no fresh GPU tests or deployment claimed. Owner approved experimental integration with the disclosed image-selection change; daily promotion remains separate.

Source-PR: halo-box#163

Assisted-by: Codex
@pwilkin

pwilkin commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

I think we also want to merge mainline proper fixes to MTP image handling.

@iTechMedic

Copy link
Copy Markdown
Author

Thanks, I'll look into the mainline MTP image-handling fixes and see what makes sense to bring into this PR. Are you referring to ggml-org#30257, or are there other commits I should check too? I'll test any additions against the current PR before updating it.

@pwilkin

pwilkin commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

Yeah, that one and the preceding ones that added llama_batch_ext.

iTechMedic added a commit to iTechMedic/strix-llama.cpp that referenced this pull request Oct 11, 2026
Preserve production candidate c2aae46 alongside PR halo-box#163. Production code merges unchanged. Resolve the test-file insertion conflict by retaining all PR halo-box#163 cases and adding the unchanged PR halo-box#165 regression and its main call. Combined CPU validation follows; full-model combination validation and daily promotion remain separate.

Source-PR: halo-box#165

Assisted-by: Codex

This branch has not been deployed

No deployments
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.