# Round 5 Track A — fold `mm_combine`, per-token `gr_norm_h`, skip dead `mixed` (opus-fold-mm-combine-and-hc-ffn)

Patch: `/tmp/opus_r5_comb/opus_r5_comb.patch` (`git -C vendor/strata apply` — `--check` passes against the current working tree).
Scratch tree + build: `/tmp/opus_r5_comb/strata`, `/tmp/opus_r5_comb/strata/build-metal`.
Touches `metal/kernels/prompt.metal`, `metal/src/engine/window.cpp` (only `prompt_chunk` + one namespace-scope constant; **not**
`prompt_gemm_init` / `prompt_gemm_need_bytes` / QSA lines — `p.mixed`, `p.yr` stay allocated), new `metal/tests/comb_fold_parity.cpp`
(picked up by the tests/*.cpp glob).

## What changed

| change | kernel | wired in `prompt_chunk` |
|---|---|---|
| per-token 4-stream HC read with apply (bo read once/token, not 4x) | `gr_norm_h4`, grid (1,T) | `hc_read(..., apply, !moe)` = every `hc_ffn` (48/chunk). `apply==0` reads (layer 0 hc_attn, layer 1 with PLE) keep `gr_norm_h` (HC,T): the 4-stream grid is **+13% slower** there (no shared read, ¼ the threadgroups) |
| skip dead fp32 `mixed` store | `gemm_hc_up`: `if (mixed) mixed[...] = s;` | `mixed` bound to a namespace-scope nil `kUnbound` when `prompt_bo_alias()` (= no `STRATA_METAL_DUMP_MOE`); 96 calls/chunk. (A persistent empty Buffer is needed — `mtl::Arg::B` stores a pointer, so `mtl::Buffer{}` would dangle.) |
| `mm_combine` folded into the next read | `gr_norm_h_moec` (1,T), `gr_apply_moec` (ceil(n/256),T) | when `prompt_bo_alias()`: `mm_combine` dispatched only at `l==0 && ple_on_` (ple_moe_gate_norm still reads yr; it is (HC,T) so folding there would read YE 4x — 1/48 layers, not worth it). Layers' `hc_attn` moe read → `gr_norm_h_moec`; final apply → `gr_apply_moec`. DUMP_MOE keeps the old path (yr written, dumped). |

Fold details: threads `tid < topk` load `pos[t*topk+k]` and `srt_w[pos]` into threadgroup memory once per token, then each
`d` forms `yr` exactly as `mm_combine` (same fma order k=0..topk-1, -1 slots skipped) and feeds it into `fma(gs, ys, yr)`.
`gr_apply_moec` was reshaped from (hc·n/256, T) to (n/256, T) with each thread looping its 4 streams, so YE is gathered once per (t,d).
`gr_norm_h_moe`'s tail was factored into `gr_norm_h_out` (shared by `gr_norm_h4`, `gr_norm_h_moe`, `gr_norm_h_moec`; bitwise unchanged — ple_moe_parity still passes).
Hazards checked: YE/pos/sw are only written by the next layer's mm_place/mm_down, which come after barrier-ordered hc_attn/hc_ffn;
the folded read's dispatch group starts after the barrier before `dq(hc_*_down)`, so all of the previous layer's MoE is complete;
nothing besides DUMP_MOE reads `p.mixed` or `p.yr` (the MTP source is `p.R`).

## Parity (M5, all bitwise `memcmp`)
`comb_fold_parity` (T = 1, 7, 64, 1024; YE rows a shuffled permutation, every 37th slot -1):
- `gr_norm_h_moec` == `mm_combine` + `gr_norm_h_moe` (R', xnh): PASS ×4
- `gr_apply_moec` == `mm_combine` + `gr_apply_moe` (R'): PASS ×4
- `gr_norm_h4` == `gr_norm_h` (R', xnh), apply 0 and 1: PASS ×8
- `gemm_hc_up` with `mixed` nil: mixh identical to bound run: PASS ×4
- Existing: `ple_moe_parity` ALL PASS, `prompt_parity` ALL PASS (gemm_hc_up with mixed bound still writes it).
  `moe_mm_parity` exits 3 (`mm_gu_42_16_64_16_2` not found) — **pre-existing**, identical with the untouched `vendor/strata/build-metal` binary.
- Parity run is 4.1 s wall. `strata_window_cli` / `libstrata_metal_kernels` build clean (no new warnings). No full-model run (per rules).

## M5 microbench, T = 1024, ms per 48 reps in one command buffer (best of 3; `comb_fold_parity 1024 bench`)
| | before | after | Δ |
|---|---|---|---|
| `mm_combine` + `gr_norm_h_moe` → `gr_norm_h_moec` | 89.54 | 83.65 | **-6.6%** |
| `mm_combine` + `gr_apply_moe` → `gr_apply_moec` | 102.03 | 73.40 | -28.1% (once/chunk) |
| `gr_norm_h` (HC,T) apply=1 → `gr_norm_h4` (1,T) | 53.96 | 44.08 | **-18.3%** |
| `gr_norm_h` apply=0 → `gr_norm_h4` | 22.20 | 25.13 | +13.2% (**not wired**) |
| `gemm_hc_up` mixed bound → nil | 35.44 | 31.44 | **-11.3%** |

(alone: `mm_combine` 40.70, `gr_norm_h_moe` 48.13.) The fold only wins ~6.6% because both kernels were already near M5 DRAM
bandwidth (~126–141 GB/s) and the fold removes only the yr write+read (21 MB of ~262 MB per layer at T=1024); the YE gather
itself is not reduced. Tried keeping R in registers for the norm tail (`gr_norm_h4r`/`gr_norm_h_moecr`): no gain (the R re-read
hits cache), dropped.

Estimated per 1024-token chunk (isolated kernels, sum): fold ≈ 5.7 ms (46–47 reads) + apply 0.6 ms + `gr_norm_h4` ≈ 9.9 ms +
mixed skip ≈ 8.0 ms (96 calls) ≈ **~24 ms/chunk**; roughly 2x at T=2048. Realized gain in the full graph may differ (those
reads overlap concurrent `dq` dispatches) — needs the parent's full-model prompt benchmark to confirm.

## Not done / notes for others
- `prompt_chain.inc` (mac-kern path) still uses `gr_norm_h`/`mm_combine`/fp32 `mixed` unchanged.
- With this patch `p.mixed` and (except PLE layer 0 / DUMP_MOE) `p.yr`'s MoE role are dead in the default path; Track C could drop
  `p.mixed` from `prompt_gemm_init`/`need_bytes` when `prompt_bo_alias()` (−MT·N·4 B) — left untouched as instructed.
