opus_ple_moe_out — Round 4 Track A: fold moe_out into the next HC read + fuse the PLE norms #

Patch: /tmp/opus_ple_moe_out/opus_ple_moe_out.patch (git -C vendor/strata apply --check passes against the current dirty tree). Scratch tree: /tmp/opus_ple_moe_out/strata (git-tracked; base commit = rsync of vendor/strata at start). Test build: /tmp/opus_ple_moe_out/build.

What changed #

file change
metal/kernels/prompt.metal gr_norm_h_moe (moe_out + gr_norm_h in one, one threadgroup per token covering all 4 streams so bo is formed once per token), gr_apply_moe (final layer: R' only, elementwise)
metal/src/engine/msl/ple.metal ple_moe_gate_norm (prompt: layer 0's moe_out + gr_norm R-update + key norm + query norm + gate + gated/normalized, 10 floats/thread in registers), ple_query_gate_norm (decode: query norm + gate, keyn precomputed), shared gnorm_regs / gate_norm_regs helpers
metal/src/engine/window.cpp prompt_chunk: no moe_out dispatch; hc_attn reads use gr_norm_h_moe (grid (1, T)); l==0 PLE = ple_moe_gate_norm + ple_conv_add (was gr_norm + 2x ple_gnorm + ple_gate_norm + conv); final gr_norm -> gr_apply_moe. prompt_gemm_init / prompt_gemm_need_bytes: drop p.keyn, p.qn; p.bo aliases p.yr (own rows only when STRATA_METAL_DUMP_MOE is set, see below). Window::run: ple_gnorm(R)+ple_gate_norm -> ple_query_gate_norm, qn_ gone
metal/tests/ple_moe_parity.cpp new standalone bitwise test

moe_out, ple_gnorm, ple_gate_norm kernels are kept (prompt_chain.inc, decode keyn, prompt_parity still use them).

Results (measured, M5 Air, standalone; no :8080, no full-model CLI) #

ple_moe_parity (0.8 s): ALL PASS, bitwise (memcmp) for T = 1, 7, 64:

  • gr_norm_h_moe == moe_out + gr_norm_h (R', xnh)
  • gr_apply_moe == moe_out + gr_norm (R')
  • ple_moe_gate_norm == moe_out + gr_norm + ple_gnorm(key) + ple_gnorm(query) + ple_gate_norm (R', gated, normW)
  • ple_query_gate_norm == ple_gnorm(query) + ple_gate_norm (gated, normW)

Falsifier check: perturbing the gnorm eps and the gr_norm_h_moe residual by 1 ulp-ish made the matching lines FAIL (exit 1); reverted -> ALL PASS. Regression: fuse_parity ALL PASS (5.5 s), prompt_parity ALL PASS (1.1 s). Scratch window.cpp passes -fsyntax-only with the real build's flags.

GPU time at T = 1024 (the laptop's streaming chunk; 3 runs, stable to ±0.5%; throwaway bench /tmp/opus_ple_moe_out/bench_ple_moe.cpp):

chain old new delta
moe_out + gr_norm_h (x48 layers/chunk) 1338 µs 1002 µs -25%, ≈ -16 ms/chunk
l==0: moe_out + gr_norm + 2x ple_gnorm + ple_gate_norm (once/chunk) 4454 µs 2561 µs -42%, ≈ -1.9 ms/chunk

Note: a first gr_norm_h_moe with gr_norm_h's (stream, token) grid was slower (1470 µs vs 1347) since each of the 4 stream threadgroups re-read yr+ys; the per-token layout (all 4 streams in one threadgroup, same per-thread d order and same simd/part reduction order -> still bitwise) is what wins. The same layout would likely speed up the plain gr_norm_h for hc_ffn too (bo read once per token instead of 4x) — not done here, suggested follow-up.

Memory #

Prompt scratch (prompt_gemm_need_bytes), with ple on:

  • p.keyn + p.qn: -2·MT·10240·4 B = -83.9 MB at MT=1024 (laptop default stream chunk), -167.8 MB at MT=2048
  • p.bo aliased onto p.yr: -MT·2560·4 B = -10.5 MB / -21.0 MB
  • decode: qn_ gone (-328 KB, MAXT=8)

DRAM traffic per chunk (estimate, not measured with counters): moe_out's bo write + gr_norm_h's bo read = 2·T·N·4 per layer -> ≈1.0 GB/chunk at T=1024 (2.0 GB at 2048); PLE stage drops keyn/qn write+read, the unused fp32 xn write into YE and gated re-read (≈0.3 GB at 1024); final gr_norm's unused xn write (42 MB at 1024) also gone.

Why bo can alias yr (and the exception) #

After the fold, p.bo only carries the attention GEMM's output to the same layer's hc_ffn read. p.yr lives from mm_combine (layer l) to the next layer's hc_attn read (or the l==0 PLE kernel / final apply). Every writer here is pushed with barrier = true (no nb_ before the attention gemm(..., p.bo, ...) nor before mm_combine), so the lifetimes are disjoint and serialized. Exception: STRATA_METAL_DUMP_MOE's at_cut dumps layer s-1's yr at layer s's handshake, i.e. after layer s's attention has written bo — so prompt_bo_alias() returns false when that env var is set and bo gets its own rows (both in make() and prompt_gemm_need_bytes(), so the need check stays consistent). Free path unaffected: the alias is not in all_bufs.

Not verified / residual risk #

  • No end-to-end run of Window::prompt_chunk / Window::run (rules: no :8080, no full-model CLI). Kernel math is bitwise-proven in isolation; the window.cpp wiring (arg order, grids, inj[ci] parity, the alias lifetime) is checked by reading and compiling only. Before merging, run one greedy prefill+decode token-equality check vs. the unpatched binary (e.g. strata_window_cli on a fixed prompt, compare tokens/logits) when the server is free.
  • gr_norm_h_moe assumes hc <= 4 and ple_* fused kernels assume n_embd <= 2560 (10 floats/thread register arrays); both are model constants (N = 2560, HC = 4 in window.cpp). No runtime guard added.
  • STRATA_METAL_SKIP=moe_out timing hook no longer does anything for the prompt path (dispatch gone).
  • Decode path still does gr_norm (possibly gr_norm_comb) before ple_query_gate_norm; folding gr_norm_comb's R update into the PLE kernel too was left out (comb deferral state makes it messier, and Tn <= 8 so the win is tiny).