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_regsno longer writesgated; it writesR = rb + gated(rb = the residual row the kernel already holds in registers). The newple_conv_resdoesR += conv.(rb + gated) + convis exactlyple_conv_add'sr + 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), thegatedarg is gone (buffer indices shift: norm_w 5, args 6).ple_moe_gate_norm(prompt, non-comb_fold / DUMP_MOE path):gatedgone (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_combinefolded in (same fma chain asprompt.metal'smoe_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_addkept unchanged (prompt_chain.inc and the test reference chain still use it); conv math factored into a sharedple_conv()helper.ple_gate_norm,ple_gnormuntouched.
window.cpp #
- decode (
Window::run, ~1566):ple_query_gate_normwithout gated,ple_conv_add->ple_conv_res.gated_member + itsA(4*MAXT*10240)allocation removed (lines 374/719 — outside the listed line ranges but only a one-token removal each; flagging it). PromptGemmstruct:gatedmember removed (line 2928, same note).prompt_gemm_initmake(): withprompt_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;embandnormWkeep 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 runtimeneed != prompt_gemm_need_bytes()check in prompt_gemm_init is still there.ple_proj()now always runs atl == 0right 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. Theif (ple_on_ && !ple_late) ple_proj();before the layer loop is gone.mm_combinecondition:if (!comb_fold)-> zeromm_combinedispatches across all 48 layers when comb_fold.- PLE dispatch at l == 0:
ple_moe_gate_norm_c(1, T) when comb_fold, elseple_moe_gate_norm(HC, T) with yr; thenple_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 4MT10240 + pval 4MTN + gated 4MT10240 + 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: -4MAXT10240 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.
- 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 (
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. |