# opus-mtp-turbo: Track 1 report (MTP drafter: indexer, sink window, low-bit weights, sampling at temp > 0)

Patch: `/tmp/opus_mtp_turbo/opus_mtp_turbo.patch` (617 lines, paths relative to `vendor/strata`; apply with
`git -C vendor/strata apply /tmp/opus_mtp_turbo/opus_mtp_turbo.patch`; `--check` passes against the live tree as of
this writing, which other sessions are editing).  Touches `metal/kernels/qsa.metal`, `metal/src/engine/window.cpp`,
`metal/src/strata_window_cli.cpp`.  Nothing here ran on the GPU: every speed number is a byte-count floor, not a
measurement.

## What was verified (CPU only)

| check | result |
|---|---|
| `clang++ -fsyntax-only -std=gnu++20 -arch arm64 -DSTRATA_GPU_METAL=1 -Wall` on patched `window.cpp` / `strata_window_cli.cpp` | clean; same 4 pre-existing warnings as the unpatched file |
| patched `qsa.metal` compiled through `MTLDevice newLibraryWithSource` + `qsa_prep` pipelines with QSA_ROW0 unset and = 24 (`/tmp/opus_mtp_turbo/mcheck.mm`; no GPU work dispatched) | both pipelines build |
| exactness of the new temp > 0 verification, Monte Carlo, 400k sequences of 3 tokens, toy 6-token model with history-dependent logits, temp 0.6 / top-p 0.95 / min-p 0.05 / rep 1.08 / pres 0.4 (`spec_test.cpp`) | TV(ref, ref2) = 0.0027 (noise floor), **TV(ref, exact) = 0.0029**, TV(ref, current greedy-match) = 0.0598 |
| same, drafter = the model's own greedy chain (`spec_test2.cpp`) | exact 1.83 tok/round, TV 0.0028; greedy-match 3.00 tok/round, **TV 0.79** |
| sink / window cell bookkeeping, randomized sim of streaming prompts + rollbacks + decode rounds, checks that every cell the draft attention reads holds committed K/V | new logic: 0 invalid reads in 3 x 36k rounds; original logic: 407k invalid cell reads in 36k rounds |
| Q8_0 / Q5_0 / Q4_0 (Lloyd) on the real MTP BF16 tensors, 256 rows each, GEMV error on Gaussian x (`quant_test.cpp`) | table below |
| CPU cost of one sampling-distribution build (copy + top-k + penalties, V = 248320) | 0.23 ms per row |

## Findings, item by item

### 1. Dead indexer compute in `mtp_encode`: real, removed (small win)

Confirmed: `mtp_encode` never calls `qsa_score` / `qsa_topk`; `mqids_` is filled on the CPU. The indexer state that
`qsa_prep` writes for the drafter (`qidx_`, `mtail_[0]`, `mdead_[0]`, `mpooled_[0]`) is read by nobody: the only
other references are checkpoint/session save/load and SCR relocate, which copy bytes around without using them.
Correction to the brief: `qsa_prep`'s grid rows are 0..23 q heads, 24..27 K/V, 28..31 indexer q heads, and x = 32 the
indexer-key threadgroup (not "x = 32 is kcur/vcur").

Patch:
- `index_qk_proj` GEMV dropped (and not quantized at load).
- `qsa_prep` dispatched with grid x = 28 on draft steps (q + K/V), and on catch-up / prefill rows (no queries read)
  with a new function constant `QSA_ROW0` (index 28, unused elsewhere) = 24 and grid x = 4: K/V rows only. The main
  window's `qsa_prep` is unchanged (constant undefined -> 0).
- Saves 1.74 MB (640 x 2560 Q8_0) per encoded row group, 29 of 33 `qsa_prep` threadgroups per catch-up row, and the
  serial 1-threadgroup indexer-key loop. That's ~1% of a draft step's bytes, not "an entire step".

### 2. Sink + window: implemented, and it uncovered a worse pre-existing bug

**Pre-existing bug (fixed):** with SSD streaming, `prompt_run_rows` calls `on_chunk` (the drafter's prefill) only
for the *last* prompt chunk. If that chunk has fewer than 256 rows, `mtp_prefill` skips nothing, so `mtp_cell0_` is
never moved, and the draft window `[cell0 - 256, cell0]` covers up to 255 cells of the *previous* chunk that the drafter
never encoded: zeros after a fresh start (zero keys still take softmax mass, with zero values), or the previous
conversation's K/V after `window_reset` (reset does not clear `mkq_`). For a 12000-token prompt with 512-row chunks the
last chunk is 224 rows, so 32 garbage cells sit in every early draft. This alone could explain part of an acceptance
drop on long Albedo sessions, independent of the sink.

Fix: the drafter now tracks its valid run `[mtp_cell0_, mtp_cell1_)` plus the sink `[0, mtp_sink_)` through one
helper, `mtp_wrote(c0, n)` (called by `mtp_prefill` for the parts it writes and by `mtp_draft` for the catch-up rows +
draft 0, which hold committed tokens). A write at c0 marks everything past c0 stale (rollbacks / checkpoint restores
to shorter prefixes stay correct); a gap restarts the run. `reset()` and `session_load()` clear it.

**Sink:** `STRATA_METAL_MTP_SINK` (default 64, 0 = off, max 1024). `mtp_prefill` also encodes the skipped rows that fall
below the sink cap; the streaming `prompt_run_rows` path additionally calls `on_chunk` on the first chunk with just its
sink rows (64 rows, cell0 = 0), since its residuals are gone by the last chunk. The draft attention's `mqids_` is
`[0 .. min(sink, first) - 1] ++ [first .. cell0]`, at most 64 + 257 = 321 cells = 6 chunks of 64 (was 5). `qsa_attn`
handles arbitrary cell ids fine (`cid[]` gathers by id, RoPE was applied at write time at the absolute position, so
relative positions stay right); `qsa_merge` derives the chunk count from `pos0` = count - 1.

The "78% -> 61%" figure in the brief is unverified by me (needs the GPU). Measure with SINK=0 vs 64 to separate the
two effects (the stale-cell fix is on in both).

### 3. Q4_0 / Q5_0 draft weights + tiles: partly a wrong premise

- **`mhead_` is not Q8_0.** In `K512-iq2_xs-metal.gguf`, `output.weight` is IQ4_XS (type 23), and `mhead_` is a row
  gather in the file's type: 40525 x 1360 B = **55.1 MB**, not 110 MB. Q4_0 (4.5 bpw) would be *larger* than IQ4_XS
  (4.25 bpw). It already runs through `Window::gemv` on the M5 `32,2,4,1` tile (`m5 && IQ4_XS && n_out >= 6144`).
  No head change.
- **A bigger, free byte cut was hiding in `fc_hidden`:** the 2560 x 2560 matrix was read once per hyper-connection
  stream (4x = 27.9 MB per draft step). For n = 1 (every draft step) the 4 streams are now the 4 rows of one GEMV
  (`xprep` with stride N, `y_stride = MAXT * N` lands each row at its stream's `mh2_` slot). Each token's sum in
  `gemv.metal` is independent of the token count, so the result should be bitwise the same; worth a parity check.
  Saves 20.9 MB per draft step (~11%).
- `mq8_` low-bit, opt-in: `STRATA_METAL_MTP_Q4=1` / `STRATA_METAL_MTP_Q5=1` puts q_proj, o_proj, fc_embedding,
  fc_hidden and the shared expert in Q4_0 (Lloyd, the HC loader's quantizer) / Q5_0 (ggml rule). K/V, router,
  shared_expert_gate and the hyper-connections stay Q8_0 (`gr_read_m` only reads all-Q8_0 or BF16; a Q4 HC matrix
  there would be read at the wrong offset). Both quantizers are lifted out of `Window::init`'s lambdas into shared
  functions (no behaviour change for the HC path).
- Tiles: in `mtp_encode`'s `gm`, Q4_0/Q5_0 wide matrices (n_out > 640, n_in < 6144) on the M5 layout take `32,2,4,1`
  (the precedent `Window::gemv` uses for Q8_0 512-row and IQ4_XS on the M5); **unmeasured**. Q8_0 defaults are
  unchanged; `STRATA_METAL_MTP_TILE=L,R,S,K` overrides the wide class for A/B (must be one of `GV_SET`'s combos).

Quantization error on the real MTP tensors (256 rows each, GEMV output rel-RMSE on Gaussian x):

| tensor | Q8_0 | Q5_0 | Q4_0 (Lloyd) |
|---|---|---|---|
| fc_embedding | 0.58% | 4.4% | 8.7% |
| fc_hidden | 1.18% | 4.4% | 5.8% |
| shared_expert.down | 0.68% | 5.6% | 10.7% |
| o_proj | 0.62% | 4.9% | 9.1% |
| q_proj | 0.69% | 5.6% | 10.3% |

Byte floor per full draft step (n = 1), from tensor sizes: before **~186 MB** (fc_emb 7.0, fc_hidden 4 x 7.0, 3 HC
blocks 21, K/V 2.8, indexer 1.7, q 33.4, o 16.7, router 1.4, shared 5.2, 10 Q2_0 experts 13.8, head 55.1). Patch
default: **~163 MB (-12%)**. `MTP_Q5=1`: ~139 MB (-25%). `MTP_Q4=1`: ~131 MB (-30%). At ~120 GB/s that's
1.55 -> 1.36 / 1.16 / 1.09 ms per step: pure bandwidth floors, dispatch overhead not included. Whether Q5/Q4 pay off
depends on the acceptance they cost, which only the GPU can tell; try Q5 first.

### 4. temp > 0 verification: the current scheme is not a sampler; replaced with exact speculative sampling

Current `serve_loop` at temp > 0: drafts are kept while they equal `output[i]`, the *unpenalized greedy argmax*, and
only `output[keep-1]` is sampled. So every accepted draft token is a greedy, penalty-free decision: with a good drafter,
most of the text is greedy decoding, and rep/presence penalties apply only to the bonus token. In the toy test with a
perfect greedy drafter this gives TV 0.79 from true sampling (3.0 tok/round). The "high acceptance at temp 0.6" is
partly this artifact.

Second bug: the bonus token was sampled before `cached_ids` got the window's tokens, so the penalties missed
`input[0..keep-1]`, including the token right before (the one-token path pushes `cur_tok` first). Fixed on both paths.

New default: `spec_sample` (Leviathan et al. 2023 sec. 2.3, deterministic drafts so q is one-hot): row i's
distribution `p_i` is exactly `sample_logits`' (`sample_dist` factored out: top-k, penalties over history +
`input[0..i]`, temp, min-p, top-p); draft x is accepted with probability `p_i(x)`; on rejection, sample `p_i` with x
removed (= norm(max(p - q, 0))); a fully accepted window samples its last row. Tokens are distributed exactly as
one-at-a-time `sample_logits` (TV at noise floor above). `sample_logits` itself is unchanged in behaviour.
`STRATA_SPEC_SAMPLE=greedy` keeps the old greedy-match rule (with the history fix). The greedy stop-token shortcut is
gone on the exact path (EOS is sampled like any token, as the one-token path does). Cost: ~0.23 ms CPU per verified
row (<= 4 rows).

Expect tokens/round at temp 0.6 to **drop** vs. greedy-match (accepted ~= E[p(draft)] instead of P(draft == argmax));
that's the price of the output being the distribution you asked for. Typical-acceptance (Medusa) or margin rules
would get the speed back but are lossy again, so I didn't ship one. If the speed hit is too much, the honest options
are temp 0 for drafted spans or a lower temperature, not a relaxed verifier.

## Suggested GPU checks (for the parent; I did not run any)

1. Parity: temp 0, `STRATA_METAL_MTP_SINK=0`, a prompt shorter than one chunk: draft tokens/acceptance should match
   the old binary exactly (fc_hidden batching, indexer removal and QSA_ROW0 should not change bits).
2. Long persona prompt (~12k tokens, streaming), temp 0: acceptance old vs new SINK=0 (stale-cell fix only) vs
   SINK=64.
3. MTP ms per round: default vs `MTP_Q5=1` vs `MTP_Q4=1` (+ acceptance), and `STRATA_METAL_MTP_TILE` A/B.
4. temp 0.6: tokens/round and tok/s, default (exact) vs `STRATA_SPEC_SAMPLE=greedy`.

## Not done / left out

- Q4/Q5 for the drafter's hyper-connection matrices (21 MB/step): needs `gr_read_m` to pick `gr_down_q4/q5` the way
  the main window does; the HC mixing is the most precision-sensitive part, so it should be measured separately.
- Batching `fc_hidden` for n > 1 (prefill/catch-up): the row layout doesn't match `mh2_`'s; small win.
- Nothing measured on the GPU, by instruction.
