# opus_r17_mtp — Round 17 Track A: MTP encode / prefill (gemv_cat Q8_0, router, wave zipping, mtp.metal vectorization)

patch: `/tmp/opus_r17_mtp/opus_r17_mtp.patch` (`git -C vendor/strata apply --check`: clean; 4 files, +~170/−31)
test:  `/tmp/opus_r17_mtp/r17_mtp_test.cpp` (parity + paired A/B, 4.9 s), `/tmp/opus_r17_mtp/r17_gemv_cold.cpp`
       (cold-cache GEMV A/B, 3.5 s); full output `/tmp/opus_r17_mtp/r17_mtp_test.out`.  Built in
       `/tmp/opus_r17_mtp/strata/build-metal` (strata_window_cli, gemv_parity, route_parity, fuse_parity all build).
device: Apple M5 (m5_layout).  No :8080, no full-model runs.

## TL;DR
- **All 151 bitwise checks PASS** (memcmp == 0): gemv_cat/gemv_catk Q8_0 at every tile × ntok 1..8 × {512, 640},
  mtp_xprep ×8, mtp_rms_xprep (embed ×8, hidden-split ×1, hidden-keep ×8), mtp_combine_gnorm ×8, mtp_pick ×6.
  Existing gemv_parity / route_parity / fuse_parity: ALL PASS.
- **mtp.metal vectorization is the real win**: mtp_xprep −41/−45 %, mtp_rms_xprep −21…−32 %, mtp_combine_gnorm −9 %,
  mtp_pick −2 %.  ≈ 5 µs GPU per draft step (rms embed −1.2, rms hidden-split −3.7, combine −0.3, pick −0.1) —
  small next to a draft step, but free and bitwise.
- **Q8_0 gemv_cat is a wash on GPU time**, so it's wired for the draft step only (bitwise, −2 dispatches/step), not prefill.
- **router_route: not applicable** — the brief's premise (BF16 router) doesn't hold for the default MTP path.
- **Wave zipping: nothing left to zip** without changing bits.

## 1. Q8_0 gemv_cat (k+v, shared gate+up)
### a correction to the brief
"gemv_cat runs KS = 1, so bitwise" is false for the draft step on M5: mtp_encode's `gm` tile for n_out ≤ 640,
Q8_0, rows == 1 is **32,1,4,K=4** (K-split).  A KS = 1 cat there would change the reduction order.  So I added
`gemv_catk<F, LPR, NR, SG, KS>` (gemv's exact body + gemv_cat's row → W/W2 select, same `red[]` fixed-order sum),
instances `gemvck_8_{32_1_4_4, 32_2_4_2, 32_1_8_8}`, next to the GVC block.  This is *new kernel code inserted at the
GVC region* (gemv.metal 215+), slightly past the "instantiations only" boundary; the existing `gemv_cat` body is
untouched.  Drop the GVCK block if that's out of bounds; then the draft can't use cat (prefill could, see below).

Also added `GVC(T_Q8_0, 32,1,4 / 32,1,8 / 16,1,4 / 32,2,4 / 8,1,8)`, and `Window::gemv_cat` now accepts Q8_0
(+ the 8,1,8 instance in its `inst` check).  Note: that one also affects the main window's GDN alpha+beta **if** a
model stores them as Q8_0 (bitwise; the shipped models use BF16 there).

### window.cpp (mtp_encode)
- `gm`'s tile choice pulled into a `tile(ty, n_in, n_out, rows)` lambda (unchanged logic), so `gm_cat` uses exactly
  gm's tile → bitwise by construction (KS = 1 → `gemvc_`, KS > 1 → `gemvck_`).
- `gm_cat(a, b, x, ya, yb, stride, rows)`: same type + shape, Q8_0/BF16/F32, instance exists, `rows == 1`,
  STRATA_METAL_GEMV_CAT ≠ 0; else falls back to the old `gm(a); nb_ = true; gm(b)` (same wave, same barrier state).
- used for k_proj+v_proj and shared gate_proj+up_proj.

### measured (paired A/B, medians, 11 rounds)
| case | 2× gemv | cat | Δ |
|---|---|---|---|
| cold 32,1,4,4 512+512 nt 1 (draft k/v) | 20.67 µs | 20.68 | +0.0 % |
| cold 32,1,4,4 640+640 nt 1 (draft gate/up) | 25.53 | 25.43 | −0.4 % |
| cold 16,1,4,1 512+512 nt 2 (prefill k/v) | 20.81 | 21.10 | +1.4 % |
| cold 16,1,4,1 512+512 nt 8 (prefill k/v) | 43.44 | 45.32 | +4.3 % |
| cold 32,1,4,1 512+512 / 640+640 nt 1 (M4 tile) | 20.06 / 25.34 | 20.22 / 25.54 | +0.8 % |

The two GEMVs already share a wave (`nb_ = true`), run concurrently and saturate bandwidth (~135–139 GB/s cold),
so merging saves nothing on the GPU.  Prefill got slower → `gm_cat` is gated to `rows == 1`.  Honestly you could drop
1.x entirely and lose nothing measurable; I kept the draft path for the 2 fewer dispatches encoded per step.
(The hot-cache 640+640 case read ~142 µs in both arms; it's a cache artifact of looping the same 3.5 MB, gone in the
cold run.  Equal across arms either way.)

## 2. router_route + wave zipping
- `models/K512_iq2_xs/mtp-q2_0.gguf`: `mtp.layers.0.mlp.gate.weight` is BF16 [2560, 512] on disk, but `mtp_load`
  quantizes **every** 2-D BF16 tensor with n_in % 32 == 0 into `mq8_` (no router exception), so `TQ(gate)` is
  **Q8_0** by default.  router_route only has BF16/F32 instances (router.metal, not mine), and `routef_` is opt-in
  because it measured no faster in the main window.  → kept `gm + moe_route`, as the brief says to for Q8_0.
- MLP wave structure today: [router GEMV ‖ shared gate/up ‖ shared_expert_gate] → [moe_route ‖ swiglu_prep] →
  [moe_gu ‖ shared down] → [moe_down] → comb_defer.  Each barrier is a true dependency (route needs logits;
  gu needs plan + XH_; down needs h_); no bitwise-safe zip is left.

## 3. mtp.metal / mtp_chain.metal
- `mtp_prepare`: unrolled; 16 float4 stores (4 planes × 4) instead of 64 scalar.  The plane-3 even lanes stay
  unmultiplied (`float4(x.x, x.y*2^-8, x.z, x.w*2^-8)`), so a denormal can't get flushed by a `*1.0f` the original never did.
  Alignment: raw = xp + t·n + 16i, plane = plane_tok·n (multiples of 16 floats), buffer offsets are 16-B multiples.
- `mtp_xprep`: 4 float4 loads.
- `mtp_rms_xprep`: output loop float4 loads of x/w + float4 store of y (`x*rs*w`, same op order).  The
  sum-of-squares loop is untouched on purpose: vectorizing it changes which elements each thread sums → not bitwise.
- `mtp_combine_gnorm`: `n == 2560` specialization, fully unrolled 10-step loops with hoisted pointers; same fma
  order; generic path kept.
- `mtp_pick`: `#pragma clang loop unroll(full)` on the sequential 32-term sum (order kept).

| kernel | base | new | Δ |
|---|---|---|---|
| mtp_xprep nt 1 / 8 | 3.10 / 4.27 µs | 1.84 / 2.34 | −41 % / −45 % |
| mtp_rms_xprep embed nt 1 / 8 | 4.34 / 4.38 | 3.14 / 3.13 | −28 % / −29 % |
| mtp_rms_xprep hidden split (draft) | 11.80 | 8.06 | −32 % |
| mtp_rms_xprep hidden keep nt 1 / 8 | 8.72 / 8.84 | 6.68 / 6.95 | −23 % / −21 % |
| mtp_combine_gnorm nt 1 / 8 | 3.48 / 4.61 | 3.17 / 4.23 | −9 % / −8 % |
| mtp_pick nv 32768 | 4.49 | 4.40 | −2 % |

(400 serialized copies per graph, barrier each, 9 interleaved rounds; medians.)

## Caveats / not done
- No end-to-end draft/acceptance run (rules).  Host-side wiring of `gm_cat` (MB(name)+offset, y_off 0, nb_ state)
  mirrors `gm` line for line; kernel arithmetic is covered by the parity test at the exact tiles gm picks.
- `gemv_parity` took 12 s (I ran it in parallel with route/fuse parity; it's the existing all-formats test).
- Suggested next check once the server is free: `STRATA_METAL_GEMV_CAT=0` vs default draft tokens identical.
