# opus_r6_ple — Round 6 Track A: PLE `gated` elimination, `ple_moe_gate_norm_c`, PromptGemm PLE/bo/yr scratch aliasing

patch: `/tmp/opus_r6_ple/opus_r6_ple.patch` (`git -C vendor/strata apply --check` passes against the current working tree)
scratch tree: `/tmp/opus_r6_ple/strata` (baseline commit = vendor/strata working tree as of task start), build: `/tmp/opus_r6_ple/build`
files: `metal/src/engine/msl/ple.metal`, `metal/tests/ple_moe_parity.cpp`, `metal/src/engine/window.cpp`

## what changed

### ple.metal
- `gate_norm_regs` no longer writes `gated`; it writes `R = rb + gated` (rb = the residual row the kernel already holds in
  registers). The new `ple_conv_res` does `R += conv`. `(rb + gated) + conv` is exactly `ple_conv_add`'s
  `r + gated + conv` (left-assoc; the runtime compiles MSL with safe math, `mtl_rt.mm:80`, so no reassociation).
- `ple_query_gate_norm` (decode): `x` -> `device float* R` (read and written in place), the `gated` arg is gone (buffer
  indices shift: norm_w 5, args 6).
- `ple_moe_gate_norm` (prompt, non-comb_fold / DUMP_MOE path): `gated` gone (norm_w 10, args 11); R is now written once
  (before: written with R' and then RMW'd again by conv_add with gated).
- new `ple_moe_gate_norm_c`: `mm_combine` folded in (same fma chain as `prompt.metal`'s `moe_comb`), grid (1, T): a
  token's 4 streams in turn, so YE (topk gathered rows), ys, value, the slots are read once per token.
- `ple_conv_add` kept unchanged (prompt_chain.inc and the test reference chain still use it); conv math factored into a
  shared `ple_conv()` helper.
- `ple_gate_norm`, `ple_gnorm` untouched.

### window.cpp
- decode (`Window::run`, ~1566): `ple_query_gate_norm` without gated, `ple_conv_add` -> `ple_conv_res`. `gated_` member +
  its `A(4*MAXT*10240)` allocation removed (lines 374/719 — outside the listed line ranges but only a one-token removal
  each; flagging it).
- `PromptGemm` struct: `gated` member removed (line 2928, same note).
- `prompt_gemm_init` make(): with `prompt_bo_alias()`: `yr = F32(16)` (never written now), `bo = p.ys` (ys lives shared
  expert -> next layer's HC read / PLE kernel / final gr_apply_moec; bo lives attention GEMM -> the FFN's HC read, which
  is after the previous ys read and before the next ys write; all with barriers between). PLE: `embh = ygh`,
  `pkey = qkv`, `pval = z`, `Wpk = Wqkv`, `Wpv = Wz`; `emb` and `normW` keep their own allocations.
- `prompt_gemm_need_bytes()` updated to match (yr 16 floats, `if (!alias) bo`, PLE only emb + normW). The order of
  allocations is the same as make(); sum with per-buffer 16 KiB rounding matches by construction. The runtime
  `need != prompt_gemm_need_bytes()` check in prompt_gemm_init is still there.
- `ple_proj()` now always runs at `l == 0` right before the PLE kernel (both ple_late and !ple_late), i.e. after layer 0's
  attention + MoE + shared expert, so the aliases are dead/idle there. The `if (ple_on_ && !ple_late) ple_proj();` before
  the layer loop is gone.
- `mm_combine` condition: `if (!comb_fold)` -> zero `mm_combine` dispatches across all 48 layers when comb_fold.
- PLE dispatch at l == 0: `ple_moe_gate_norm_c` (1, T) when comb_fold, else `ple_moe_gate_norm` (HC, T) with yr; then
  `ple_conv_res`.

## REJECTED from the brief: `normW = p.XS`
`p.normW` is read **on the CPU after the graph** (window.cpp ~3648, the PLE history advance:
`const float* nw = p.normW.as<float>()`), and `mm_gather_h` writes `p.XS` in every one of the 48 layers. Aliasing it
would corrupt the PLE conv history for every following chunk. (Follow-up if wanted: do the history advance on the GPU
right after `ple_conv_res` — then normW could alias layer-0-idle scratch; that touches lines 3646-3660, outside my range.)
Also `emb` kept separate, as the brief said (CPU gather writes it during layer 0 in the streaming case).

## RAM saved (prompt buffers, 16 KiB rounding)
yr 4MTN + embh 2MTN + pkey 4MT*10240 + pval 4MTN + gated 4MT*10240 + Wpk 52.4 MB + Wpv 13.1 MB:
- C = 1024 (stream default on <96 GB, i.e. the 24 GB laptop): **-165 MB**
- C = 2048 (non-stream default): **-286 MB**
- C = 4096: **-506 MB**
(the brief's -390 MB at 2048 assumed normW->XS, which is unsafe; see above.) Decode: -4*MAXT*10240 B (gated_).

## verification (all short, no :8080, no full-model runs)
- `ple_moe_parity` (rewritten): BITWISE vs the unfused primitive chains (moe_out + gr_norm + ple_gnorm x2 + ple_gate_norm +
  ple_conv_add; mm_combine prepended for _c), comparing final R **and** normalized rows:
  - ple_moe_gate_norm + ple_conv_res, ple_moe_gate_norm_c + ple_conv_res, ple_query_gate_norm + ple_conv_res: PASS at T =
    1, 7, 64 (default run, 0.23 s) and T = 2048 (`ple_moe_parity 2048 bench`, 1.6 s). gr_norm_h_moe / gr_apply_moe still PASS.
  - mutation checks: `R = rb` (gated dropped) -> all three FAIL; perturbing the folded combine -> _c FAIL. So the test bites.
  - pos has dropped (-1) slots and scattered rows, like comb_fold_parity.
- `comb_fold_parity`: ALL PASS. `arena_budget_test`: all passed.
- builds clean: `ple_moe_parity`, `arena_budget_test`, `comb_fold_parity`, `strata_window_cli` (ninja, scratch tree).
- NOT run: full model / `strata_window_cli` (forbidden while :8080 is live). The need==need_bytes check and the alias
  lifetimes are verified by reading + the runtime check; **parent should do one prompt-chunk run (stream and non-stream,
  PLE on) before merging**, comparing logits/tokens to pre-patch (expected bitwise-identical).

## GPU timing (M5, scratch bench `/tmp/opus_r6_ple/zz_ple_bench.cpp`, best of 7 x 8 reps, live server may add noise)
T = 2048, the PLE stage once per chunk:
| path | ms |
|---|---|
| old: mm_combine + ple_moe_gate_norm (writes gated) + ple_conv_add | 9.40 |
| new, non-fold: mm_combine + ple_moe_gate_norm + ple_conv_res | 8.19 |
| **new, comb_fold: ple_moe_gate_norm_c (1, T) + ple_conv_res** | **6.74 (-28%)** |
| new, ple_moe_gate_norm_c variant with grid (HC, T) + ple_conv_res | 12.70 (YE gathered 4x: rejected) |
parts: conv_add 2.56 -> conv_res 1.90; old fused 5.11 -> new fused 4.50; mm_combine alone 1.75.
T = 1024: 4.71 -> 3.41 ms. So **(1, T) wins clearly** on M5 and is what's wired.
Decode-shape (query_gate_norm + conv) at T=2048: 5.70 -> 5.08 ms; real decode T is tiny so this is noise-level there.
Honest scale: ~2.7 ms per 2048-token chunk (~1.3 ms per 1024) — it runs once per chunk (layer 0 only), so prefill
throughput gain is small; the RAM drop is the main win.
