# 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 MB `F_NOCACHE` preads) = **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):
1. `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.
2. 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.
3. `max_pre` capped the read-ahead at 256 (128 for layers >= 40) of the ~350 non-resident experts a layer needs.
4. 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).
- `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. keep `16_32_32_4` / `16_64_32_2`.
- **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_f16` is < 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_cli` run (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)
