strata metal opts v1 (opus partner) — hit rate, read-ahead, handshake, GPU floor #
all numbers below come from probes in /tmp/opus_metal_probes/ over the real cal_iq3s_1024 dump (1,022 tokens × 48 layers) and the
real K256-q2_0-*metal.gguf routers / Q2_0 experts, plus one tiny Metal microbench (≈2 s of GPU, run while no strata process was up).
the cache replays are closed-loop: routing re-runs MoCE against the simulated residency every layer, like router_route does.
the patch is /tmp/opus_metal_probes/opus_v1.diff (208 changed lines, git apply --check clean on the current vendor tree,
all three files pass clang++ -fsyntax-only; not built or run on the GPU: that's yours).
0. tl;dr (ranked by expected ms/token at the 7.5 GB ops_1024 point, 59.1 ms/tok = 16.9 tok/s) #
| # | change | how | expected | risk |
|---|---|---|---|---|
| 1 | wire dense + arena | STRATA_METAL_MLOCK_DENSE=1 STRATA_METAL_WIRE_ARENA=1, arena 6.0–7.0 GB |
−5…−8 ms (compute = gpu−wait grows 35.9 → 41.4 → 43.8 → 53.7 ms as the arena grows 3.4 → 6.0 → 7.5 → 8.2 GB) | needs ~11.5 GB wired; vm.user_wire_limit = 19.6 GB |
| 2 | prefetch K=16, no LRU touch of resident guesses | STRATA_METAL_PREFETCH=16 + patch (STRATA_METAL_PREFETCH_TOUCH=0 default) |
urgent misses −29% (9.82 → 6.99/tok in replay) → wait 7.1 → ~5 ms: −2 ms | +15 reads/tok = +21 MB/tok (0.35 GB/s), SSD has 6.8 |
| 3 | shared expert between signal and wait | patch (STRATA_METAL_SHEXP_OVERLAP=1 default) |
−1.5…−2.2 ms (measured: a handshake costs 57–73 µs of GPU idle, and work placed between signal and wait is hidden) | none (same math, same order) |
| 4 | host turn split: read-ahead + pre-slot retirement after the GPU is released | patch (STRATA_METAL_SPLIT_TURN=1 default) |
removes ahead() and the MoCE "wait for unused in-flight read-aheads" stall from all 48 handshakes: −0.5…−2 ms (exact = host − waiting in the stream report, which the new report line splits out) |
deterministic by construction (limbo list, see §2.4) |
| 5 | read-ahead GEMV on a Q8_0 copy of ffn_gate_inp |
patch (STRATA_METAL_PRED_Q8=1 default) |
−92 MB/tok of F32 reads: −0.7 ms; +33 MB memory | zero quality risk (guess only picks what is read ahead) |
| 6 | MoCE protect top-2 instead of top-3 | router.metal rank == 3 → 2 and ahead() k_unpert 3 → 2 |
misses −14% (−1.4/tok at 7.5 GB) | routed-output relMSE +14% (0.0343 → 0.0392) — measure KLD first |
| 7 | speculative T=2 windows (suffix drafter already in the CLI) | --suffix-draft 1..3 |
T=2 moves 16.3 experts/layer instead of 10 → +9.5% bytes for 2 positions; any acceptance > ~10% pays | MoCE + more misses per window; measure |
1+2+3+4+5 ≈ −11…−15 ms → ~45 ms/tok ≈ 22 tok/s, before speculation. 99% hit rate is not reachable by policy at 7.5 GB (needs ~13 GB, §1.2), and it's the wrong target anyway: stall time is.
1. MoE cache hit rate #
1.1 where the misses are (replay, stock = J3, λ=0.85, prefetch K=10 MRU, cap = slots, tokens 128..1021) #
| cap (GB) | policy | urgent miss/tok | read-ahead hits/tok | SSD reads/tok | miss by selected rank 0..9 /tok |
|---|---|---|---|---|---|
| 2441 (3.4) | stock | 24.89 | 26.01 | 72.6 | 2.1 4.0 6.1 4.2 3.5 2.5 1.5 0.7 0.2 0.1 |
| 4308 (6.0) | stock | 13.16 | 13.53 | 40.1 | 1.3 2.5 4.1 2.1 1.5 1.0 0.5 0.2 0.1 0.0 |
| 5385 (7.5) | stock | 9.82 | 9.92 | 30.2 | 1.0 2.0 3.3 1.4 1.0 0.6 0.3 0.1 0.0 0.0 |
| 5385 (7.5) | no prefetch | 19.65 | 0 | 19.7 | 3.5 4.7 5.8 2.5 1.6 0.9 0.4 0.1 0.0 0.0 |
| 5385 (7.5) | λ=0, no prefetch | 55.97 | 0 | 56.0 | 3.0 3.5 4.1 4.6 5.1 5.6 6.5 7.1 7.9 8.6 |
your measured 7.5 GB ops_1024 run had misses 16.3/tok + ra 3.24% (15.6/tok); the replay's 9.82 + 9.92 is the same shape on a different (prefill-text) workload, so use it for relative deltas.
- yes, ranks 0–2 carry most of the misses once MoCE is on: 6.3 of 9.8 (64%) with prefetch, 14.0 of 19.65 (71%) without.
- but un-protecting them does not buy much: the bonus (0.85 logits) is big next to the router's spread (median z₁−z₃ = 0.35, z₂−z₃ = 0.12; only 31% / 6% of rank-1/2 picks lead rank 3 by > 0.5), so most protected misses are experts whose lead is bigger than the bonus. replay at 7.5 GB, prefetch on, quality = routed-output relative MSE vs λ=0 over 256 sampled tokens × 48 layers, exact Q2_0 SwiGLU experts (healed
.experts):
| policy | urgent miss/tok | Δ vs stock | routed relMSE | rel-L2 |
|---|---|---|---|---|
| J3 λ0.5 | 13.41 | +37% | 0.0223 | 0.149 |
| J3 λ0.85 (stock) | 9.82 | — | 0.0343 | 0.185 |
| J2 λ0.85 | 8.45 | −14% | 0.0392 | 0.198 |
| J1 λ0.85 (= Qualcomm top-1) | 7.82 | −20% | 0.0418 | 0.204 |
| J3 λ1.5 | 8.15 | −17% | 0.0440 | 0.210 |
| J2 λ1.5 | 5.16 | −47% | 0.0580 | 0.241 |
| J3 λ0.85 + drop tail misses w<0.05 (rank ≥ 6) | 9.76 | −0.6% | 0.0343 | 0.185 |
- anchor: Q2_0 (healed) vs IQ3_S experts on the same routing is rel-L2 0.469 (relMSE 0.22), so stock MoCE adds ~+15% routed MSE on top of the quant error, J2 ~+18%, J1 ~+19%.
- J1/J2 at λ0.85 dominate raising λ: J1 gets fewer misses than J3 λ1.5 at lower error. if anything moves, move J, not λ.
- margin gating = J1 in practice (J3 with margin 0.5 or 1.0 reproduces J1 to 0.01 misses/tok): the margin almost never binds because the bonus already exceeds most gaps. skip it.
- adaptive top-k (drop rank 8–9 misses with small weight) is pointless under MoCE: rank 8–9 misses are ~0.0/tok because the bonus already pulls residents into the tail. 0.06–0.09 drops/tok.
- capacity is the real lever. stock policy vs slots: 5385 → 9.82, 6500 → 7.18, 7500 → 5.13, 8500 → 3.52, 9500 → 2.28 (99.05% hit), 10500 → 1.23. 99% needs ~13.2 GB of arena. ds4's 99% is a much larger resident fraction; not comparable.
1.2 eviction — tested, negative (don't spend time here) #
- Belady MIN on the stock routing trace at 5385: 10.71/tok vs LRU 19.65 (no prefetch): 45% headroom exists, but online policies don't reach it (same as ds4's −4%).
- popularity EMA + near-pick credit (top-16 unbiased candidates at 0.5 credit): 19.65 → 19.65.
- probationary insertion (misses enter below the MRU end, SLRU-style) by 10k/25k/50k/100k accesses: 19.66 / 20.03 / 25.49 / 48.47 — worse.
1.3 the read-ahead (predL_) is not broken #
- the guess
W(l+1)·XF(l)is good: recall of layer l+1's real top-10 = 0.678 @10, 0.821 @16, 0.932 @32; the real top-1 is in the guessed top-10 93.2% of the time (weight-recall 0.762 @10). two layers ahead (W(l+2)·XF(l),PREFETCH2) is worse: 0.588 @10, top-1 86%. - "3.2% read-ahead hits" is the expected number: 96% of lookups are hits already, and the read-ahead converts ~half of the would-be misses (replay at 7.5 GB: 19.65 → 9.82 urgent misses, 9.92 read-ahead hits). it is doing its job.
- what is wrong is around it:
ahead()callslru_front()on every resident guessed expert, so guesses that are never used push real recency down. no-touch: 9.82 → 9.40 (−4%).- K=10 is too shallow for the budget: K=16 no-touch → 6.99 urgent misses/tok (−29% vs stock) for 45 reads/tok (vs 30); only ~0.8 new reads per layer, ~1.1 MB/layer, well inside one layer's GPU time at 3.9–6.8 GB/s. confidence gating (only guesses with p ≥ 0.02/0.04) loses recall faster than it saves reads; don't.
- with MoCE on,
at_layerwaits for every unused in-flight read-ahead of the layer to finish before it even looks up the layer's experts (done_cv_.waitin themoce_bonus_ > 0block), inside the handshake, and that wait is counted inhostbut not inwaiting. then it throws the slot away. the patch moves all of that to after the GPU is let go (§2.4). - keeping unused read-aheads resident at MRU instead of dropping them (pf5 in the replay): 10.00 vs 9.82, slightly worse, and the dropped-slot design is fine once it's off the critical path.
2. the decode floor #
2.1 what "gpu" is #
win_gpu_ is GPUEndTime − GPUStartTime of the one segmented command buffer, so it includes every host turn and SSD wait ("GPU incl. the host's turns"). wall 59.1 = CB 50.87 + ~8.2 ms outside the CB (embeddings, PLE gather_batch, encode patching, finish). the CB = SSD wait 7.09 + 48 handshakes (~2.8–3.3 ms, §2.3) + host non-wait turn time + compute (~38–40 ms). run once with STRATA_METAL_PROFILE=1 to get the pre / rg / fin split of the 8.2 ms; the PLE row gather could move into layer 0's host turn (its GEMVs sit before layer 0 but are only needed after it).
2.2 bytes per token (why ~40 ms is close to the floor) #
dense reads per decoded token on this GGUF: attn_qkv 711 MB (Q6_K) + ssm_out 444 + attn_gate 379 + HC 4×315 BF16 → Q8_0 copies ≈ 670 + attn_q 262 + attn_output 151 + ffn_gate_inp F32 126 + the read-ahead GEMV re-reading the next layer's F32 router 123 + shexp 146 + indexer/k/v/ssm ≈ 70 + output.weight Q6_K 521 ≈ 3.6 GB, + experts 10 × 48 × 1.38 MB = 0.66 GB. 4.3 GB / ~40 ms ≈ 108 GB/s on a 153 GB/s part. kernel tuning can find maybe 10%; bytes are the lever: the Q8_0 read-ahead router (−92 MB) is free; the head (521 MB = ~4.5 ms) is the next biggest single item if you ever want a two-stage head.
2.3 handshakes (measured, hs.mm, 48 segments, M5) #
| mode | 4 MB × 2/seg | 4 MB × 4/seg | 16 MB × 4/seg |
|---|---|---|---|
| one encoder | 2.638 ms | 5.265 | 21.48 |
| 48 encoders, no events | 2.630 | 5.255 | 21.49 |
| 48 encoders + signal/wait (host spins, answers at once) | 5.506–5.735 | 8.750 | 24.20 |
| + 1 more dispatch per segment between signal and wait | 5.175 | 8.061 | — |
- encoder breaks are free; the cost is the event round trip, 57–73 µs of GPU idle each, ~2.8–3.5 ms/token over 48 layers.
- work encoded between
encodeSignalEventandencodeWaitForEventruns while the host takes its turn: one extra 27 µs dispatch per segment made the whole thing faster (5.735 → 5.175). so the patch puts the shared expert (gate/up/gate_inp GEMVs, swiglu_prep, down; ~2.7 MB ≈ 25–45 µs a layer) there. - "skip the wait when the guess says everything is resident" — tested in the replay and rejected: at 7.5 GB, 29 of 48 layers/token would qualify (MoCE guess top-16 all resident), but 3.8 of those still miss, dropping 4.4 experts/token holding 14% of their layer's routed weight. too costly for ~1.7 ms.
2.4 the patch (/tmp/opus_metal_probes/opus_v1.diff) #
mtl_rt.{h,mm}:run_graph_seg(ds, cuts, sigs, at_cut, after_cut, user): signal atsigs[s], encode[sigs[s], cuts[s])as its own encoder, wait atcuts[s]; after settingsignaledValue, callafter_cut(s). the old overload forwards withsigs = cuts,after_cut = nullptr(the prompt chain uses it unchanged).window.cpp:layer(): when streaming, the shared expert's dispatches go to a side list and are spliced in right afterrouter_route/moe_route;sigs_records the position. barriers: the side list starts its own encoder;moe_route(non-fused path) keeps its barrier on the router GEMV;moe_downkeeps its barrier onmoe_gu(the oldnb_ = truebefore the down-shexp GEMV is skipped).after_cut(s)→st_->after_layer(s, …, predL_ + s); the prompt chain callsafter_layerinline inat_cut.STRATA_METAL_PRED_Q8(default on): Q8_0 copy of the 48 routers (33 MB) for the read-ahead GEMV only (gemv_8_32_1_4_1exists; Q8_0 reads the float x plane).am.dense_file = st_->dense_unwired_bytes()for the arena rule.
stream.{h,cpp}:at_layer(decode,split_turn_): only the layer's own lookups, misses and the wait. the queued-read-ahead cancel, the MoCE wait/evict andahead()move toafter_layer.after_layer(l):release_limbo()(last turn's retired read-aheads: wait for any still reading — they had a whole layer — then free in slot order,lru_back),retire_unused_pre(l, l, true)(this layer's unclaimed read-aheads: queued pieces dropped, slots parked inlimbo_), thenahead(l+1).- determinism:
victim()never picks alimboslot, nor (in decode) apreslot, so which slots are free at every decision point depends only on the order of turns, never on SSD timing. smap is untouched for read-aheads until claimed, as before.ahead()still pins with the current stamp, so it can't evict what the GPU is reading. pre_touch_(STRATA_METAL_PREFETCH_TOUCH, default 0):ahead()no longerlru_fronts resident guesses.hold_region()releases limbo on both edges (prompt start/end), so prompt stream-ins can't leak.- report:
(+ X ms after the GPU went on).
arena_budget.h:ArenaMem::dense_filesubtracted inarena_budget()'s RAM term and inarena_grow(); 0 keeps every old number (thearena_budget_teststill passes).- A/B switches:
STRATA_METAL_SHEXP_OVERLAP=0,STRATA_METAL_SPLIT_TURN=0,STRATA_METAL_PREFETCH_TOUCH=1,STRATA_METAL_PRED_Q8=0each restore the old behavior.STRATA_METAL_PREFETCH=16is not a default in the patch; set it in the bench.
2.5 why 8.2 GB got slower, and the arena rule #
- compute (gpu − wait) climbs with the arena on both prompts: ops_1024 35.9 / 41.4 / 43.8 / 53.7 ms and wal_43 42.2 / 52.4 / 50.7 / 90.0 ms at 3.4 / 6.0 / 7.5 / 8.2 GB, while min_avail falls 5.4 → 3.8 → 3.4 → 2.9 GiB with 0 swap. that is paging, not the GPU page table: TLB reach depends on bytes touched per token (same at every arena size), not on arena size.
- mechanism: with
MLOCK_DENSE=0the 4.53 GB dense map is clean file cache, and GPU reads never set the CPU's referenced bits, so the pager sees those pages as idle and reclaims them first. on small Macs the arena is unwired too (WIRE_ARENAdefaults on only at ≥ 64 GB), so it can be compressed between command buffers. every command buffer then pays to make it all resident again.arena_avail()makes it worse: it counts inactive file pages, the model's own dense pages included, as free. - confirm in one run:
vm_statbefore/after a 64-token decode, Pageins and Decompressions deltas per token. tens of MB per token = this. - what to run:
MLOCK_DENSE=1 WIRE_ARENA=1, arena 6.0 / 6.5 / 7.0 GB; expect compute back near 36 ms. 24 GB: 4.5 dense + 7 arena = 11.5 GB wired, under the 19.6 GB wire limit, leaving ~10 GB for macOS + buffers. - default rule on a 24 GB Mac:
arena = available − keep − ple_reserve − dense_unwired, keep = 7 GB as now. your data says min_avail ≥ ~3.3 GiB is safe and ≤ 2.9 GiB is not. with dense wired,dense_unwired= 0 and the old rule applies.
3. what i'd like back from the M5 runs #
- the full stream report line for 7.5 GB stock, especially
host X ms per window, of it waiting Y(X−Y = what the split turn saves) and thelatecount. STRATA_METAL_PROFILE=1pre / rg / fin for one decode run.vm_statPageins/Decompressions deltas across a decode at 7.5 GB, MLOCK_DENSE=0 vs 1.- A/B order: (a) MLOCK+WIRE alone at 6.5/7.0; (b) + patch defaults; (c) +
PREFETCH=16; (d) + J2 with a KLD check (STRATA_METAL_LOGPOS); (e)--suffix-draft 1/2/3.
4. reproduce #
cd /tmp/opus_metal_probes
./logits models/K256_reap_l2_sum/K256-q2_0-metal.gguf <cal_iq3s_1024 prefix> cal # router logits + guesses, checks 99.99% set agreement with .ids
./sim cal <K256-expert-profile.bin> main|caps|pf|ins|opt|dump # closed-loop replays
./recall cal # read-ahead recall
./belady sel_ref.bin 5385 ; ./belady sel_j3b085.bin 5385
NTH=4 ./eval <healed .experts> <cal prefix> 4 sel_ref.bin sel_*.bin # routed-output error (validated: 0.086 rel-L2 vs the heal tool's own Q2 teacher)
./hs 4 2 ; ./hs 4 4 # handshake microbench (GPU, ~2 s)