# opus_r16_kvik — round 16 track A: QSA `Wk | Wv | Wik (| Wiq)` as one GEMM

**verdict: bitwise-clean, -24/-36 dispatches and -5.6 MB of prompt scratch per chunk, but speed-neutral at C = 2048.**
the premise that the 512-column GEMMs are bandwidth-bound turned out wrong: the separate GEMMs already run concurrently
(`nb_`) and reach ~13.7 TFLOPS fp16 together (2·2048·1664·2560 flop in 1.27 ms), so they're compute-bound. dropping the
extra `p.mixh` reads and halving the weight re-loads saves nothing you can measure. the patch is safe to merge for the
memory and dispatch savings; don't expect tok/s from it.

patch: `/tmp/opus_r16_kvik/opus_r16_kvik.patch` (`git -C vendor/strata apply --check` passes)
test:  `/tmp/opus_r16_kvik/kvik_test.cpp` (standalone, built in the scratch tree as `metal/tests/kvik_test.cpp`; NOT part of the patch)
raw:   `/tmp/opus_r16_kvik/{gemm_runs,tile_sweep,prep_run}.txt`

## the change (window.cpp 3070–3530, qsa.metal 90–200 only)
- `prompt_chunk` QSA branch: dequantize attn_k → `Wk` rows 0..511, attn_v → 512..1023, indexer.k_proj → 1024..1151,
  indexer.q_proj (only when `pos0 + T > 2051`) → 1152..1663, all into `p.Wk` (= `p.Wz`, 6144 rows). one
  `gemm(p.mixh, p.Wk, p.kcur, T, 1152|1664, N)` into `p.kcur` (= `p.gqn`, MT·2048 floats). `qsa_prep` gets
  `kcur`, `kcur+512`, `kcur+1152` (iqraw), `kcur+1024` (ikraw).
- `prompt_gemm_init` / `prompt_gemm_need_bytes`: `p.iqr` (MT·512 f32), `p.ikr` (MT·128 f32), `p.Wik` (128·N f16) are
  no longer allocated (4 MiB + 1 MiB + 640 KiB at C = 2048, N = 2560); `p.Wv` / `p.Wiq` / `p.vcur` aliases dropped. grep:
  nothing else in window.cpp reads them (prompt_chain.inc has its own `b.*` struct, untouched).
- `gemm()` tile: new bucket `Nn <= 1664` (only the fused shape lives in 641..1664): `M < 768 ? 32_128_2 : 64_128_4`.
  the default it would otherwise fall into (`M < 1536 ? 128_64_4`) is **10% slower at M = 1024** (0.89x, +0.64/+0.95 ms/chunk).
- `qsa_prep`: **i deviated from the brief's pointer-difference detection** (`vcur == kcur + 512`) and used a function
  constant `QSA_KV_STRIDE` (26, unused in qsa.metal + qsa_prompt.metal) instead. reasons: the pointer test can
  false-positive whenever a caller passes K/V as slices of one buffer (or adjacent 1-token allocations), and it would add a
  runtime branch to every decode/MTP/test caller. with the constant undefined the strides fold to the old `512 / 128 / 512`
  at compile time, so every other caller's pipeline is unchanged. prompt_chunk keys the pipeline `...#kv1152` / `#kv1664`
  (two extra pipelines, compiled on first use).

## evidence (M5 Air; the :8080 server was live, so expect some GPU contention noise)
**GEMM parity**: fused vs the 3/4 separate GEMMs (each with prompt_chunk's tile), every K / V / ik / iq sub-slice of every
row `memcmp == 0` at M = 256, 512, 768, 1024, 1536, 1983, 2048 × Nn = 1152, 1664, and for every fused tile in the sweep
(128_64_4, 64_128_4, 64_64_4, 128_128_4, 64_128_2, 32_128_2).

**GEMM speed** (paired, 15 alternating rounds × 16 reps, run 1; run 2 agreed within ±0.06 ms; a third run had contention
spikes and was dropped):

| M | 1152: sep → fused | ms/chunk (12 layers) | 1664: sep → fused | ms/chunk |
|---|---|---|---|---|
| 256  | 122.9 → 114.8 us (1.07x) | -0.10 | 179.5 → 174.0 (1.03x) | -0.07 |
| 512  | 236.9 → 221.3 (1.07x) | -0.19 | 333.5 → 318.9 (1.05x) | -0.17 |
| 768  | 333.4 → 341.8 (0.98x) | +0.10 | 488.8 → 504.8 (0.97x) | +0.19 |
| 1024 | 438.5 → 457.6 (0.96x) | +0.23 | 643.7 → 659.3 (0.98x) | +0.19 |
| 1536 | 667.4 → 663.0 (1.01x) | -0.05 | 961.1 → 948.7 (1.01x) | -0.15 |
| 1983 | 859.3 → 825.8 (1.04x) | -0.40 | 1230.9 → 1227.2 (1.00x) | -0.04 |
| 2048 | 874.2 → 882.3 (0.99x) | +0.10 | 1275.0 → 1273.9 (1.00x) | -0.01 |

no fused tile beats this at M ≥ 768 (tile_sweep.txt): 128_128_4 runs at 0.5x, 64_128_2 at 0.8x, the rest at 0.9–1.04x.
so the fused GEMM gets small wins only for short tail chunks (≤ 512 rows); full 2048-row chunks are ±0.1 ms.

**qsa_prep parity**: at stride 1152 and 1664, with fp32 and `qf16` (fc 25), 12 cases
(T = 2, 3, 5, 517, 1029, 1983, 2048; pos0 = 0, 1, 1022, 2047, 2048, 2050, 4093, 6143, which covers the t < 3 boundary, the
tail, pos0 % 4 ≠ 0 and pooling past the budget), all 9 outputs (qcur, qidx, kq, vq, ksc, vsc, tail, dead, pooled)
`memcmp == 0` against the separate-buffer reference. the reference is compiled from the **unpatched** vendor qsa.metal
(`KVIK_BASE`), so the default path is also checked bitwise against the old kernel. negative control (`KVIK_NEG=1`: fused
rows read with the default stride): every case DIFFs, so the test does catch a wrong stride. qsa_prep timing is in the noise
(single cold dispatches, contention).

**regression**: scratch `qsa_parity` ALL PASS, `qsa_prompt_parity` ALL PASS (incl. qsa_prep qf16 memcmp), and
`strata_metal_kernels` (window.cpp) builds clean.

## not done (per the rules)
- no full-model run: the `need != prompt_gemm_need_bytes()` runtime check and end-to-end prompt token parity weren't
  exercised. by inspection the two removals match (`F32(MT*512)`, `F32(MT*128)`, `F16(128*N)` dropped on both sides).
  run a prompt-parity / `strata_window_cli` check on a > 2051-token prompt once :8080 is free.
- tests/dense_mm_parity.cpp was left alone (outside my lines); the bench lives in kvik_test.cpp. if the fused tile rule
  lands, `new_tile()` there should mirror it.
