opus prefill-opt: K=512 IQ2_XS prompt_chunk on the 24 GB M5 Air #
author: prefill-opt-opus (claude-opus-5-5). patch: /tmp/opus_prefill_opt/prefill_opt.patch (applies cleanly with
git apply on the current vendor/strata working tree, verified on a scratch copy /tmp/opus_prefill_opt/applytest,
built in /tmp/opus_prefill_opt/build_final). nothing tracked in vendor/strata was edited.
tl;dr #
| prompt | metric | base | patched | delta |
|---|---|---|---|---|
| 1024 tok (1 chunk, 1023 rows) | chunk ms (4 interleaved pairs) | 4016 / 4029 / 4066 / 4521 | 3440 / 3459 / 3487 / 3497 | -14..-24 % |
| 1024 tok | prefill_ms e2e | 4298 / 4309 / 4358 / 4810 | 3586 / 3604 / 3633 / 3645 | |
| 1024 tok | prefill tok/s e2e | 213 - 238 | 281 - 286 | +20 % (median) |
| 244 tok | chunk ms (4 pairs) | 2504 / 2528 / 2634 / 2638 | 2207 / 2320 / 2328 / 2338 | -12 % |
| 244 tok | prefill tok/s e2e | 85.0 - 89.5 | 97.6 - 103.2 | +13 % |
| 2048 tok, chunk 1024 | prefill_ms (cool run) | 8196 | 7309 | -11 % |
2048 tok, STRATA_METAL_PROMPT_CHUNK=2048 (env only) |
prefill_ms (cool run) | 8196 | 5980 (342 tok/s) | -27 % |
| final shipped patch, re-check (1 pair, warm) | 1024 chunk / e2e ms | 4210 / 4565 | 3699 / 3840 | -12 % / -16 % |
| decode after a 1024 prefill, 64 tok | tok/s (2 pairs, cooled) | 22.06 / 22.52 | 22.73 / 21.45 | noise (+-0.6), no regression |
prefill output is bit-identical: STRATA_METAL_DUMP_MOE dumps of every layer's .mixed .ids .wts .yr .hh .sgv plus
.R_post47 .inj47 are byte-equal for base vs patched, patched vs the parent's build-metal binary, run vs run, and
(bonus) chunk 1024 vs chunk 2048 on the 2048-token prompt. route_parity: PASS (0 mismatches both phases).
all numbers: strata_window_cli, the parent's env_k512 (ARENA_GB 6.5, wired, MOCE_BONUS 0.85, IO_THREADS 8, ...),
STRATA_METAL_PROMPT_MOCE unset (= 0, exact routing), base = same binary with STRATA_METAL_PROMPT_PRED=0 STRATA_METAL_PLE_ASYNC=0 (exactly the old code path; its dumps are byte-equal to the parent's binary). runs gated on no
other strata_window_cli running; base/patched interleaved because the fanless Air throttles hard (see caveats).
1. the diagnosis: cold prefill is an SSD problem, not a kernel problem #
- per expert (gate+up+down, contiguous copy) = 36.23 GB / (48 x 512) = 1.474 MB.
- 1024-token chunk: 410 distinct experts / layer, 14.8 % in the arena -> ~16.8 K experts must come off the SSD = 24.7 GB per chunk no matter what.
- raw SSD:
/tmp/opus_prefill_opt/ssdbw(random 1.47 MBF_NOCACHEpreads) = 7.97-8.05 GB/s at 4/8/16 threads. - so the floor for a cold 1024 chunk is ~3.09 s of pure NVMe time; the GPU work alone is ~2.0-2.4 s (KPROF). base sat at 4.0-4.5 s because the SSD was idle part of the time and read experts nobody used.
where the SSD went idle / wasted in base (stream.cpp at_layer, prompt branch):
layer_rank_[l+1]is the static profile: only 50.9 % of lookups were streamed ahead, 34.3 % were synchronous misses (GPU stalled, 1.2-1.5 s exposed wait), and ~1.2 K of the 11.2 K streamed-in experts were never used.- the next layer's read-ahead was queued only after the wait for this layer's misses returned, so the queue ran dry at every handshake.
max_precapped the read-ahead at 256 (128 for layers >= 40) of the ~350 non-resident experts a layer needs.- the PLE row gather (
gather_batch, ~150 ms of SSD reads per 1K chunk) ran serially on the host before the GPU got its first dispatch.
2. what the patch does #
A. exact-ish lookahead of layer l+1's experts (the big one) #
in prompt_chunk, right after layer l's route_topk: dq(blk.{l+1}.ffn_gate_inp) -> p.Wr2, gemm(p.mixh, p.Wr2, p.logits2), route_topk(logits2 -> ids2) with MoCE forced to 0. these dispatches sit before the layer's cut, so at
at_layer(l) the host has T x 10 predicted ids for layer l+1 (same trick decode already uses via predL_, but for a
whole chunk and computed on the GPU in ~10 ms total for 47 layers).
ExpertStream::at_layer (prompt branch): after pinning/queuing layer l's misses (urgent queue) and before waiting,
it counts the predicted ids per expert, orders them most-tokens-first, and submits every non-resident one as a
read-ahead of layer l+1 (no 256 cap). urgent misses still go first (io threads drain urgent_q_ before pre_q_), and
the existing drop logic cancels any unstarted read-ahead layer l+1 turns out not to use. with a prediction set, the old
static-rank queue is skipped. layer 0 (no previous layer) gets the whole profile rank at hold_region instead of 256.
effect on the 1024 chunk: streamed ahead 50.9 % -> 76.4 %, misses 34.3 % -> 9.6 %, exposed wait 1.2-1.5 s -> 0.56-0.61 s; experts read 18.0 K -> 18.4 K (the ~1.4 K wrong guesses are the only extra bytes). total SSD traffic is now ~27 GB in ~3.4 s = ~7.9 GB/s, i.e. the drive is saturated.
knobs: STRATA_METAL_PROMPT_PRED=0 = old behaviour. like the static read-ahead it stands down for a MoCE prompt
(0 < STRATA_METAL_PROMPT_MOCE < 100), whose routing reads residency. tested and rejected STRATA_METAL_PROMPT_PRED_MIN=2 (only experts
predicted by >= 2 tokens): fewer wasted reads (14.3 K streamed) but misses 17 %, chunk +40..110 ms slower; misses cost
more than waste because they idle the GPU. (the PRED_MIN knob is in the patch, default 1.)
B. PLE gather beside the GPU #
gather_batch runs on a std::thread started before the graph is submitted; Window::at_cut(0) joins it (the GPU is
parked at layer 0's handshake anyway), and the PLE projections (act16(emb), Wpk/Wpv gemms) are emitted after layer
0's moe_out, which is still before their only consumer (the l == 0 PLE block). ~80 ms off e2e prefill (3586-3645 vs
3622-3658 ms, sync vs async on the same build). STRATA_METAL_PLE_ASYNC=0 = old order; non-streaming path unchanged.
the trace line now splits host time: prompt chunk ... (embd 3 ms, ple 0 ms, graph build 2 ms).
3. bit-exactness / KLD #
- prefill state: byte-equal dumps as listed in the tl;dr (all 48 layers, all rows). the lookahead only changes which slot an expert sits in and when it is read; routing in the prompt path never reads residency when PROMPT_MOCE = 0.
- teacher-forced KLD (
--logits-out/--logits-ref, prompt_2048 file, 1024 prompt + 64 tf tokens):- with decode MoCE off (
MOCE_BONUS=0): base vs base (identical config, run twice) mean KLD 0.0012, top-1 95.3 %; patched vs base 0.0011, top-1 96.9 %; the parent's binary vs base 0.0079 (single run). i.e. decode itself is not run-to-run deterministic even with MoCE off (residency/timing-dependent paths: EXPERT_SPLIT / SPLIT_TURN read- ahead timing). that is pre-existing and independent of this patch, but worth knowing before anyone calls a decode-side KLD "bit-exact". - with decode MoCE 0.85 vs the MoCE-off reference: base 0.074 / 0.067, patched 0.062 / 0.064 mean KLD (noise-level; if anything the patched arena state is a hair better because the streamed-in experts are the ones actually used).
- with decode MoCE off (
route_parity(built from the patched tree): K=512 mismatches 0, K=256 + MoCE 0.85 mismatches 0, PASS.
4. avenues that did not pay (measured, STRATA_METAL_STREAM_FAKE=1 STRATA_METAL_PKPROF=1) #
KPROF of the patched 1024 chunk, FAKE stream (cool-ish machine): GPU 1967 ms over 2492 dispatches:
gemm_f16_128_64_4 x517 534 ms, mm_gu3_22 (IQ2_XS) x34 442 ms, mm_down3_42 (Q2_0) x48 165 ms, gdn_scanr_4 138 ms,
mm_gu3_16 (IQ3_S) 130 ms, qsa_flash 113 ms, gr_norm_h 106 ms, gemm_hc_up 73 ms, mm_combine 42 ms.
(the extra 47 router gemms + 47 route_topk of the lookahead cost ~10 ms of that.)
- B (expert tiles), probe builds with the plan's TM switched:
- TM 32 +
mm_gu4_*_2_32_32_4+mm_down3_*_32_64_32_2: gu 447 ms (vs 442), down 212 ms (vs 165) -> worse. - TM 32 +
mm_gu3_*_32_64_32_2+ down 32_64_32_2: gu 804 ms, down 235 ms -> much worse. the IQ2_XS gate+up is at ~5 TFLOPS effective, bound by the codebook decode, and the 16-row tile keeps more threadgroups in flight; bigger M tiles lose occupancy faster than they save decode. keep16_32_32_4/16_64_32_2.
- TM 32 +
- C (dense): the dense/shexp/router GEMMs are ~3.4 GMAC/token -> 6.9 TFLOP per 1K chunk in 534 ms = ~13 TFLOPS fp16
on the 10-core M5; there is no 30 % left there.
dq_f16is < 50 ms total. not worth a kernel rewrite. - why GPU wins barely matter cold: after A the chunk is ~3.45 s vs an SSD floor of ~3.1-3.4 s (27 GB at 8 GB/s, incl. wasted guesses) and GPU ~2.0-2.4 s; the remaining 0.56 s exposed wait is the drive. GPU work only becomes the bottleneck when the Air is thermally throttled (see caveats) or on warm/repeated prompts.
5. the next lever: chunk 2048 (env, not in the patch defaults) #
bytes per token is the only thing left, and chunk size is how you cut it: a 2048 chunk touches ~470 distinct experts a layer once instead of ~410 twice. on the 2048-token prompt: experts read 32.6 K -> 18.5 K (-43 %), prefill 7309 -> 5980 ms (342 tok/s) cool (7747 vs 8506 hot). bit-identical to chunk 1024 (dumps byte-equal). cost: transient prompt scratch 1.36 -> 2.29 GB (virtual prefill peak 9.7 -> 10.6 GB against an 18.8 GB cap; scratch is unmapped after prefill, phys_footprint identical afterwards).
recommendation: set STRATA_METAL_PROMPT_CHUNK=2048 in the K512 serve env (scripts/serve_k512_local.sh). i did not
flip the default (stream_chunk = phys >= 96 GB ? 4096 : 1024) because with an automatic arena size (am.arena_gb < 0)
prompt_gemm_need_bytes() is subtracted from the arena budget, so a bigger default chunk would silently shrink the decode
arena on 24 GB Macs; with STRATA_METAL_ARENA_GB=6.5 pinned (as in the K512 config) it costs decode nothing.
6. caveats #
- thermal: the fanless Air throttles after a few minutes of back-to-back prefills; the same patched 1024 chunk measured 3.44 s cool and up to 3.8-4.3 s hot, decode 22.7 cool vs 16-17 tok/s hot. all A/B numbers above are interleaved pairs; absolute numbers depend on how warm the machine was. the KPROF tables are from the middle of a session (GPU 1.97 s here vs the 2.3-2.4 s the parent recorded).
- the parent's numbers (3.8-4.1 s base chunk) and mine (4.0-4.5 s base) agree within that thermal spread.
- a concurrent
build-metal/strata_window_clirun (long-ctx sweep) contaminated two early measurements; those are not used anywhere above.
7. files #
/tmp/opus_prefill_opt/prefill_opt.patch- the drop-in (window.cpp, stream.cpp, stream.h; ~230 lines of diff)/tmp/opus_prefill_opt/ssdbw.cpp- the NVMe bandwidth probe/tmp/opus_prefill_opt/kld/- teacher-forced logits + prefill dumps used for the exactness checks/tmp/opus_prefill_opt/strata- the working copy with the probe hooks (STRATA_PG_TM,STRATA_PG_GU_KERNEL, ... were removed again before the patch was cut)