opus R19 Track A: fused ple_comb_query_gate_norm, register-cached ple_gnorm, load-time PSO pre-warm #
patch: /tmp/opus_r19_prewarm/opus_r19_prewarm.patch (git -C vendor/strata apply --check passes against the current working tree)
scratch tree: /tmp/opus_r19_prewarm/strata (build: build-metal/), A/B bench: /tmp/opus_r19_prewarm/ab_bench.cpp (+ binary), baseline kernel copy: /tmp/opus_r19_prewarm/ple_base.metal
files touched: metal/src/engine/msl/ple.metal, metal/src/engine/window.cpp (prewarm region ~475-570, Window::run PLE block, window_init one line)
what changed #
ple.metal #
ple_comb_query_gate_norm(grid (hc, T), 256 thr): reads R once, forms layer 0's deferred MoE combine on the fly (fma(fma(gs, ysh, sum_k ye[plan[P_TOKENT + t*10 + j]]), g, R), gr_norm_comb's exact expression and k order), thengnorm_regs+gate_norm_regs. R written once; gr_norm's xn (dead: layer 1'sgr_readruns with apply=false and re-norms R after the conv) never written. Unrolled fast path for n=2560 && topk=10, generic loop otherwise.P_TOKENTdefaults to 532 under#ifndef(same as gr2.metal).ple_gnorm: n=2560 path keeps the row inxv[NPT]and reusesgnorm_regs, so x is read once (was twice, un-unrolled loops). Generic path unchanged. Moved belowgnorm_regs(needs NPT / the helper).
window.cpp #
- new members in the prewarm region:
ple_key_norm(ds, Tn)andple_after_layer0(ds, Tn)carry the PLE dispatches, shared byWindow::runand the prewarm (so prewarm now builds exactly run's graph, incl. the PLE kernels; the old prewarm skipped the PLE block and so built amoe_combine_shflush that run never uses). ple_after_layer0: whencomb_bo_ == &bo_ && comb_ntok_ == Tnit takes the deferred combine and dispatchesple_comb_query_gate_norm; else (orSTRATA_METAL_PLE_COMB=0, orSTRATA_METAL_GR_COMB=0) the oldgr_norm+ple_query_gate_norm. Window graph: -1 dispatch, -1 barrier, -120 KB/token DRAM (xn write + R write + R re-read).prewarm_pipelines:bool prewarmed_->int prewarmed_t_(sizes 1..prewarmed_t_ done; a later largermax_twarms only the new sizes) +mtp_prewarmed_. Loop now t = 1..max_t_ (was 2..), withfold(ds, t, 0)(gdn_step#t), the PLE block, argmax_part/final, and once the MTP layer:mtp_encode(k = 1..MAXT, full=false)(mtp_prefill chunks / draft catch-up),mtp_encode(1, full=true)(draft step),mtp_pick(0, true)whenchain_. Resetscuts_/sigs_/rec_/cur_inj_/nb_/comb_bo_/comb_ntok_.prewarm(): env gate (STRATA_METAL_PREWARM=0off), skips while a streaming arena is not made yet (stream_ && !st_->ready(): the MoE dispatches bindst_->chunk(), which indexes an empty vector before the arena), then warms what is missing. Called fromwindow_initright afterset_max_t(default path:prompt_gemm_inithas made the arena by then), and fromWindow::runas the lazy fallback (STRATA_METAL_LAZY_ARENA=1, or MTP loaded later).
deliberate deviations from the brief #
- no extra
layer(ds, l, 1, 0)(pos0=0, sel=false) pass: inlayer()pos0 only feedssel, andsel=falseonly skips pushing qsa_score/qsa_topk + the indexer q gemv;P()for qsa_score/qsa_topk is called either way and the indexer gemv's PSO is the same 512-out gemv as attn_k/attn_v. pos0=4096 is a strict PSO superset; the pass would only cost build time. - no
membp_ = &memb_swap:membp_is nullptr outsidemtp_prefill(which resets it), and withtokens == nullptrmtp_encode only binds(membp_ ? *membp_ : memb_)as an argument, so the swap is a no-op. - the
max_t_ > 1condition is gone: at max_t=1 the first T=1 window's compile now also moves to load time.
verification (M5 Air; NOTE the live :8080 server shares the GPU, so absolute us are noisy; ratios are paired medians) #
bitwise, ab_bench BASE NEW 128 T (memcmp on R, norm_w; on y for ple_gnorm; R checked to have moved):
| T | gr_norm_comb + ple_query_gate_norm -> ple_comb_query_gate_norm | ple_gnorm base -> regs |
|---|---|---|
| 1 | R PASS, norm_w PASS; 7.87 -> 6.65 us, ratio 0.84-0.85 | PASS; 2.95 -> 1.43 us, ratio 0.48 |
| 2 | PASS/PASS; 8.5 -> 7.45 us, ratio 0.875 | PASS; 2.9 -> 1.45 us, ratio 0.49-0.50 |
| 3 | PASS/PASS; 9.96 -> 8.59 us, ratio 0.86 | PASS; 2.95 -> 1.69 us, ratio 0.57 |
| 4 | PASS/PASS; 10.7 -> 8.97 us, ratio 0.84 | PASS; 3.0 -> 1.77 us, ratio 0.59 |
| 8 | PASS/PASS; 17.0 -> 13.3 us, ratio 0.78 | PASS; 3.33 -> 2.38 us, ratio 0.71 |
(3 sweeps, every run ALL PASS; the quiet-sweep numbers above, contended sweeps had the same ratios +-0.05.)
existing tests in the scratch build: ple_moe_parity ALL PASS (its chain uses the new ple_gnorm against the fused register
kernels, so it cross-checks the new ple_gnorm too), ple_moe_parity --ab ple_base.metal 7 ALL PASS, fuse_parity ALL PASS,
arena_budget_test all passed. strata_window_cli builds with no new warnings in window.cpp.
honest scale / not verified #
- per decode window the two kernel changes save roughly 1.2 + 1.5 us of GPU time (~3 us vs ~47 ms/token): real but ~0.006%; the 120 KB/token DRAM saving is the same order. Not a tok/s lever on its own.
- the pre-warm is the bigger user-visible piece (moves ~280 ms + MTP first-use compiles out of the first request), but it
was NOT measured end-to-end: per the brief no full-model
strata_window_clirun and nothing on :8080. Needs one load + first-request run to confirm (a) first-request prefill/decode/mtp ms drop, (b) the load-time cost of warming (window_ensure passes MAXT=8, so all 8 sizes are compiled at load even if the server runs a smaller max_t), and (c) output unchanged (greedy tokens identical withSTRATA_METAL_PLE_COMB=0vs default). - static review points for the auditor: prewarm at init mutates only host
mqids_slot 0 (via mtp_encode full=true at cell0=0), which every real full mtp_encode rewrites before use; all other state is restored at the end ofprewarm_pipelines.