# opus r28 track a: mtp multi-row fc_hidden batching, mtp.metal unrolls, gm_cat audit

patch: `/tmp/opus_r28_mtp/opus_r28_mtp.patch` (`git -C vendor/strata apply --check` passes against the current dirty tree).
files: `metal/src/engine/window.cpp` (mtp_encode, the fc_hidden block + CombNormArgs only), `metal/src/engine/msl/mtp.metal`,
`metal/tests/comb_fold_parity.cpp`. `mtp_chain.metal` is untouched: every fixed-trip loop in it already has
`unroll(full)` / `unroll_count(4)`. scratch tree + build: `/tmp/opus_r28_mtp/{strata,build}`. all numbers are from Apple M5
(the 24 GB air) with the live :8080 server up, so expect some noise.

## 1. fc_hidden batching (the change)

**before:** for `n > 1` (catch-up of `a` accepted drafts, and mtp_prefill chunks), mtp_encode ran one fc_hidden GEMV
per stream c (`HC = 4` of them), each with `n` rows, plus 3 `mtp_xprep` dispatches. so the 6.95 MB Q8_0 weight got
read 4 times whatever `n` was. h was laid out `[c][t]`, with `h_stride = MAXT*N`.

**after:** the `n*HC` streams become GEMV rows in `[t][c]` order, up to MAXT = 8 per GEMV.
- `mtp_rms_xprep` always splits now. it prepares the first `plane_tok` (= MAXT) stream rows and skips the rest (new
  guard `t >= plane_tok`), and it keeps the normalized rows in `mhn_` only when `n*HC > MAXT`.
- after that, each further 8-row slice is one `mtp_xprep` (stride N, from `mhn_`) plus one GEMV.
- `mtp_combine_gnorm` reads `h + (t*hc + c)*n`. h, R and xn now share one layout, and `h_stride` is gone from
  CombNormArgs (on both host and kernel).

| n | GEMVs before → after | weight reads | dispatches (rms+xp+gemv+comb) | cold GPU time, hidden branch |
|---|---|---|---|---|
| 1 (draft step) | 1 (4 rows) → 1 (4 rows) | 1 → 1 | 3 → 3 | 70.4 → 70.1 µs (same) |
| 2 (a=2) | 4 × 2 rows → 1 × 8 rows | 4 → 1 | 9 → 3 | **211.6 → 101.4 µs (−52%)** |
| 3 (a=3) | 4 × 3 rows → 8 + 4 rows | 4 → 2 | 9 → 5 | **220.1 → 155.7 µs (−29%)** |
| 4 | 4 × 4 → 2 × 8 | 4 → 2 | 9 → 5 | 228.9 → 190.0 µs (−17%) |
| 8 (prefill chunk) | 4 × 8 → 4 × 8 | 4 → 4 | 9 → 9 | 365.1 → 366.1 µs (same) |

`comb_fold_parity 0 mtp bench` measures this: the weights cycle through 16 copies so they come from DRAM, the
dispatches run in a barrier chain as in the window, and each number is the median of 9 alternated pairs. two runs
agreed to within 1%. you can't get n = 8 below 4 GEMVs without a GEMV that takes more than 8 rows: gemv.metal's NTOK
goes up to 8. so the "zero xprep" n = 8 idea from the brief isn't possible at MAXT = 8. its dispatch count stays the
same (3 xp + 4 gemv as before).

### tiles (gemv_parity --tiles, Q8_0 2560→2560, cold)
| rows | 32,2,4,1 | 16,1,4,1 | 16,2,4,1 | 32,1,4,1 | 8,1,4,1 |
|---|---|---|---|---|---|
| 4 | 59.9 | **57.2** | 62.0 | 70.7 | 59.0 |
| 8 | 113.9 | **86.0** | 109.7 | 124.8 | 86.5 |
| 3 | 56.0 / 66.0 | **54.7 / 58.7** | 58.7 / 71.0 | | |
| 2 | 54.6 / 56.9 | 54.8 / 56.1 | 55.5 / 57.2 | | |

the existing tile() choice (`rows >= 4` → 16,1,4,1) is already the fastest at 4 and 8 rows, so tile() is unchanged.

### bitwise (comb_fold_parity 0 mtp [baseline], ALL PASS)
- in every case n = 1..8, the batched R and xn are **bitwise equal to n separate n = 1 chains**. every fc_hidden GEMV
  now has ≥ 4 rows, which puts it on one tile (16,1,4,1, never `gemv_direct_x`), so a stream's h no longer depends on n.
- at n = 1 and n = 4..8, the output is **bitwise equal to the old per-stream chain**, because it's the same tile.
- at n = 2 and 3 the output **differs from the old chain** by max |Δxn| 9.5e-7. the old chain ran 2–3 rows on
  32,2,4,1, and now these n match the n = 1 draft path. that's the one intended numeric change, and it only affects
  the catch-up K/V of the accepted cells.
- with the original `mtp.metal` as baseline (`/tmp/opus_r28_mtp/orig/metal/src/engine/msl/mtp.metal`), the per-stream
  chain on the old kernels with the old [c][t] layout is bitwise equal to the same chain on the new kernels for every
  n. so the kernel edits (the split guard, the combine index, the unrolls) don't change any value.

not run: a full-model check, which the brief forbade (:8080 is live). `strata_metal_kernels` and `strata_window_cli`
compile and link in the scratch build with no new warnings. the expected end-to-end effect is about 110 µs per verify
round with a = 2 and about 64 µs with a = 3, so a few tenths of a percent of tok/s at ~47 ms/token. someone needs to
confirm it in a window_cli A/B once :8080 is free.

## 2. mtp.metal unrolls
added `#pragma clang loop unroll(full)` to the 8-way `part[]` sums in `mtp_rms_xprep` and `mtp_combine_gnorm`. the
summation order is the same, and the baseline A/B above confirms it's bitwise neutral. this is hygiene only: no
measurable time change, since clang very likely unrolled the 8-trip loop already.

## 3. gm_cat audit (comb_fold_parity 0 mtpcat, Q8_0 2560→N+N, one token, bitwise PASS on every tile)
| shape | tile | cold: 2 gemv → cat | hot: 2 gemv → cat |
|---|---|---|---|
| k+v 512+512 | **32,1,4,4 (window)** | 21.48 → 21.38 (−0.4%) / 22.61 → 22.51 (−0.4%) | +0.2% / +0.4% |
| k+v 512+512 | 32,1,4,1 | +0.9% / +0.8% | +3.4% / +1.5% |
| gate+up 640+640 | **32,1,4,4 (window)** | 27.59 → 27.41 (−0.6%) / −0.2% | −0.1% / +0.1% |
| gate+up 640+640 | 32,1,4,1 | +0.6% / +0.8% | +0.1% / +0.3% |

gemv_parity --cat (K=1) agrees: 512 hot +3.3%, cold −0.0%; 640 hot +0.3%, cold +0.2%.

a correction to the brief: shared gate+up 2560→640 at rows = 1 runs at **32,1,4,4 (gemv_catk)**, not 32,1,4,1. tile()
line 2591 sends every `n_out >= 512` to K=4. the r27 "+2.4% hot" only shows up with the K=1 tile, and only hot. at the
window's actual K=4 tile, cat is neutral or slightly ahead, cold and hot. **verdict: keep gm_cat as it is.** (some
hot numbers drift 18→64 µs between runs, probably GPU clock state with the live server, so trust the cold columns.)

## 4. tile() check on the other MTP Q8_0 shapes (rows = 1, cold, µs)
- 2560→512: **32,1,4,4 11.67** · 32,1,4,1 11.73 · 16,1,4,1 12.07 · 16,1,8,4 12.27 · 32,2,4,2 12.37
- 2560→640: **32,1,4,4 15.06** · 32,1,4,1 15.14 · 16,1,4,1 15.19 · 32,2,4,2 15.81 · 16,1,8,4 15.89
- 2560→2560: **32,1,4,4 54.2** · 16,1,4,1 56.7
- 2560→12288 (q_proj): **32,1,4,4 261.8** · 32,1,8,2 267.4 · 32,2,4,1 271.1 · 16,1,4,1 272.1 · 32,2,4,2 281.6
- 6144→2560 (o_proj): **32,1,8,8 120.5** · 32,1,4,4 124.0 · 32,1,8,2 124.9 · 32,2,4,2 126.0 · 16,1,8,4 127.5
- 640→2560 (shared down): **32,1,4,1 13.9** · 32,2,4,1 14.5 · 16,1,4,1 14.6 · 8,1,4,1 14.9 · 4,1,4,1 15.4

tile() already picks the fastest tile on every shape, so nothing changes. one thing left open: fc_embedding at
rows = 3 (a = 3 catch-up) uses 32,2,4,1, and 16,1,4,1 was 1–7 µs faster in two runs. but that GEMV runs in the same
wave as fc_hidden, so most of the gain is probably hidden. too small and noisy to change without an in-window A/B.

## repro
```
cmake -S /tmp/opus_r28_mtp/strata/metal -B /tmp/opus_r28_mtp/build -G Ninja -DCMAKE_BUILD_TYPE=Release \
  -DSTRATA_GGML_DIR=$PWD/vendor/llama.cpp -DMETAL_CPP_DIR=/tmp/opus_r28_mtp/strata/third_party/metal-cpp
ninja -C /tmp/opus_r28_mtp/build comb_fold_parity gemv_parity strata_metal_kernels
comb_fold_parity 0 mtp /tmp/opus_r28_mtp/orig/metal/src/engine/msl/mtp.metal   # parity incl. baseline kernels
comb_fold_parity 0 mtp bench                                                     # cold paired timings
comb_fold_parity 0 mtpcat                                                        # gm_cat audit
GEMV_AB_COLD=1 gemv_parity --tiles Q8_0 2560 2560 8 16,1,4,1 32,2,4,1 16,2,4,1 32,1,4,1 8,1,4,1
```
