# Gemma 4 MTP — Multi-Token Prediction speculative decoding > Scope: this document covers the MTP (Multi-Token Prediction) speculative > decoding path added to this fork on top of `llama.cpp`, currently specialised > to **Gemma 4** targets (`gemma4`) paired with the official **Gemma 4 > assistant** drafter (`gemma4_assistant`). It is a self-contained reference: how the feature is built (model, graph, KV, context, scheduler), what server-loop integration looks like, what knobs the operator has, what the recent design decisions were, and where the throughput numbers came from. For the public/user-facing section about CLI flags and `--spec-type mtp`, see also `docs/speculative.md`. --- ## 1. What MTP is here Gemma 4 ships an "assistant" model — a small transformer head that consumes the **target's last hidden state** (backbone output `h_prev`) plus the last sampled **token id** and predicts the next token in one forward step. Chained across `B - 1` steps inside one MTP graph, it produces a draft block of length `B - 1` that is then verified by the target in a single batched decode. Conceptually it is a draft-model speculation, but with three crucial twists: 1. **Single context.** The assistant is **not** a second `llama_context`. Its weights live next to the target in `llama_model::mtp_assistant`. There is no second tokenizer, no second KV cache, no second sampler. 2. **Cross-attention into the target's KV.** Each MTP layer reads `K/V` from the **last layer of the matching attention type** (full / sliding) of the target's KV cache (`llama_kv_cache_iswa::init_mtp` → `mtp_slot_info`). No draft-side KV is allocated. 3. **Shared `h_prev` from the target.** The assistant ingests the target's per-token backbone hidden state (`embeddings_ith`) for the **last accepted** position. Embeddings must therefore stay enabled on the target context (`llama_set_embeddings(ctx_tgt, true)`). This makes MTP much cheaper than a normal "small draft model" approach: there is essentially no draft KV, no second model orchestration, and the per-step graph is tiny (4 transformer blocks for 26B/31B; centroid LM head for E2B/E4B). --- ## 2. Components and where they live | Concern | File(s) | |---|---| | MTP graph (per-step build) | `src/models/gemma4-assistant.cpp` | | Model arch + tensor types | `src/llama-arch.cpp/.h`, `src/llama-model.cpp` | | GGUF tensor mapping | `gguf-py/gguf/tensor_mapping.py`, `gguf-py/gguf/constants.py`, `convert_hf_to_gguf.py` | | Loading assistant into target | `src/llama.cpp::llama_model_load_mtp_from_file` | | MTP scheduler + worker + APIs | `src/llama-context.cpp/.h` (`sched_mtp`, `mtp_worker_loop`, `decode_mtp_*`) | | KV cross-attention helpers | `src/llama-kv-cache.cpp`, `src/llama-kv-cache-iswa.cpp` (`init_mtp`) | | Speculative driver / overlap | `common/speculative.cpp` (`common_speculative_state_mtp`) | | Server integration | `tools/server/server-context.cpp` | | Public C API | `include/llama.h` (`llama_decode_mtp_async/_wait`, `llama_model_load_mtp_from_file`, `llama_model_mtp_n_embd_backbone`, …) | | Verification helper | `scripts/verify-gemma4-assistant-gguf.py` | | Run scripts | `scripts/run-gemma4-{,e2b-,e4b-,31b-}mtp-server.sh`, `scripts/quantize-gemma4-edge-assistant-mtp.sh` | | Tests | `tests/test-speculative-mtp.cpp` | | Tracing | `LLAMA_MTP_ACC_TRACE` (NDJSON in `common/speculative.cpp`) | --- ## 3. Model side: assistant, centroid LM head, GGUF layout The assistant is loaded from its own GGUF and **attached** to a target model: ```c int32_t llama_model_load_mtp_from_file( struct llama_model * model, const char * path_assistant, struct llama_model_params mparams); ``` After load, the target carries: - `model.mtp_assistant` — a fully-loaded `llama_model` with arch `gemma4_assistant` (4 transformer blocks, the pre/post backbone projections, optional centroid head). - `hparams.n_embd_backbone` — must equal target's backbone hidden size; this is asserted at load and re-checked at draft time (`n_bb` in `decode_mtp_run`). CLI surface (`common/arg.cpp`, `common/common.cpp`): - `--mtp-head ` (preferred) and `--model-draft / -md` (back-compat alias) — feed the same `mparams_dft.path` field. - `--spec-draft-n-max ` — head proposes `N` tokens per round (replaces the pre-b10018 `--draft-block-size `, `N = B - 1`). - `--gpu-layers-draft / -ngld`, `-ctkd / -ctvd` — placement and KV typing for the **assistant** weights when offloaded. ### Centroid / ordered-embeddings LM head (E2B / E4B) For Edge models (`use_ordered_embeddings = true` in HF config), the LM head is not the dense tied embedding but a **MaskedEmbedder**: 1. `centroid_logits = mul_mat(mtp.centroids, h)` → `[n_centroids]`. 2. `top_k(centroid_logits, centroid_intermediate_top_k)` → `top_k` centroid ids (I32, on-device). 3. `mtp.token_ordering` is viewed as `[vsc, n_centroids]` (`vsc = n_vocab / n_centroids`); each centroid column lists `vsc` candidate token ids. `get_rows` gathers the candidate ids for the chosen centroids. 4. `get_rows(token_embd, ids)` then `mul_mat(·, h)` produces sparse logits over only those candidates. 5. We **scatter** them into a full `[n_vocab]` row pre-filled with `-1e30` (`ggml_fill_inplace + ggml_set_rows`). The full-vocab row is what the verifier expects — sparse-only argmax broke server accept (rare-token edge cases) and so was reverted. GGUF layout for the centroid head (see `convert_hf_to_gguf.py` and `docs/development/gemma4-assistant-tensor-inventory.md`): | Tensor | Stored type | On-disk shape | Notes | |---|---|---|---| | `mtp.centroids.weight` | F16/F32 (or quant) | `[n_embd, n_centroids]` after GGUF dim packing | numpy is `[n_centroids, n_embd]`; written as-is so loader sees `mul_mat`-compatible shape | | `mtp.token_ordering.weight` | **I32** (kept integer end-to-end) | `[n_vocab]` | must not be quantized — the converter explicitly preserves I32 | | `mtp.pre_projection.weight` / `mtp.post_projection.weight` | as model | `[2*n_embd, n_embd]` / `[n_embd, n_embd_backbone]` | concatenates `[token_embd, h_prev]` then projects back | The verifier `scripts/verify-gemma4-assistant-gguf.py` enforces these shape / dtype invariants and is run automatically by every `run-gemma4-*-mtp-server.sh` script (skip with `VERIFY_ASSISTANT_GGUF=0`). --- ## 4. Per-step MTP graph (`gemma4-assistant.cpp`) `llm_build_gemma4_mtp` builds a single-token, single-sequence graph (`n_tokens = 1`, `n_seqs = 1`, `n_outputs = 1`): 1. Inputs (registered as `llm_graph_input_mtp`): - `inp_last_token : I32 [1]` - `inp_h_prev : F32 [n_embd_backbone, 1]` - `inp_pos` (standard `build_inp_pos`) - `inp_attn` (`build_attn_inp_kv_iswa`) 2. Token embedding from the **target's** `tok_embd`, then scaled by `sqrt(n_embd)` to mirror Gemma 4's input scaling. 3. `concat([tok_e, h_prev], axis=0) → mtp.pre_projection` (collapses the `2 * n_embd` channel back to `n_embd`). 4. 4 transformer blocks (`mtp.layers[il]`): - RMSNorm → Q proj → Q-norm → RoPE. - **Cross-attention** via `build_attn_mtp`: queries from MTP, K/V fetched from the target's KV cache at `il_kv = last layer in target with the same attention type` (SWA / full). - Per HF Gemma 4 quirk (`attention_k_eq_v: true`): even when V was derived from K, the V slot is **written** with rms-norm-without-scale and un-rotated, so cross-attn must always read V from cache (not reuse the post-RoPE K). This is encoded as `use_k_as_v = false`. - Standard residual + post-norm + GELU FFN + post-FFN norm + per-layer `out_scale` (if present) + `build_cvec`. 5. Final RMSNorm → `mtp.post_projection` produces the **next-step `h_prev`** (this is what the host stitches between steps). 6. LM head — dense (tied) **or** centroid-routed for ordered embeddings. 7. Optional `f_final_logit_softcapping`. 8. **In-graph greedy argmax**: `ggml_argmax(cur)` → `I32 [1]`. The final result exposes three tensors via `llm_graph_result`: - `t_embd` = `h_post` (next `h_prev`) - `t_logits` = full-vocab row (kept for diagnostic / `out_logits` API) - `t_argmax` = greedy token id The host reads `t_argmax` (4 bytes) per step instead of pulling the full F32 `[n_vocab]` row across a backend boundary and running CPU argmax. On Gemma 4 + Q4_K_XL this alone delivered ~+2-3% throughput (109.5 → 112.5 tps at n=128; 95.8 → 97.8 tps at n=512), with bit-identical greedy drafts. The full row is still computed in-graph and is fetched on demand by passing `out_logits != NULL` to the synchronous `llama_decode_mtp(...)` API (legacy / diagnostic path), which transparently falls back to `decode_mtp_sync`. --- ## 5. KV sharing — what is read, what is appended The MTP step does **not** allocate or write its own KV. It reads the target's KV by: - `kv_iswa->init_mtp(seq_id, ub)` — produces a memory context whose attention inputs (`build_attn_inp_kv_iswa`) wire the cross-attn to the target slot for `seq_id`, with a mask that admits all positions `≤ attn_pos`. - `attn_pos` is taken from `llama_memory_seq_pos_max(mem, seq_id)` immediately before submission (post-`seq_rm`). All `n_steps` step positions chosen for RoPE are strictly **`> attn_pos`**, so the causal/SWA mask uniformly admits every target cell — that is why a single mask suffices for the whole chained draft. KV-safety contract for asynchronous draft work (see Section 7): - `decode_mtp_async` snapshots `h_prev` and `attn_pos` at submit time. - The target may **append** at positions `> attn_pos` between submit and `_wait` (this is what the verify decode does), but it must not evict, rewrite or `seq_rm` cells at positions `≤ attn_pos` until `_wait` returns. - The current append-only KV cache satisfies this. `common_speculative_cancel` is invoked at the few server-loop points that *do* mutate KV destructively (request stop / release; new request `seq_rm`; spec-disabled iterations). --- ## 6. `llama_context` plumbing — `sched_mtp`, worker, and APIs The async pipeline lives in `src/llama-context.cpp/.h`. New members on the context (Phase C of the original plan): - `sched_mtp` — a **dedicated** `ggml_backend_sched` for MTP. Created lazily by `ensure_sched_mtp()` and reserved with a single-token MTP graph (the MTP graph is invariant in size: `n_tokens = 1`, `n_seqs = 1`, `n_outputs = 1`, so one reserve covers all subsequent calls). - `gf_res_prev_mtp` — a **separate** `llm_graph_result` cache. This is the key reason MTP graph reuse survives target-decode resets, and is the single largest win of the async refactor. - A worker thread (`mtp_worker`), `std::mutex` + 2 condition variables (`mtp_cv_request`, `mtp_cv_response`), and request/response slots (`std::optional`, `bool mtp_in_flight`, `std::optional`). - `backend_cfg_mu` — guards shared backend reconfiguration (`set_threadpool_fn`, `set_n_threads_fns`) so the worker's `graph_compute_mtp` cannot race the main thread's `graph_compute`. The lock is held only across cheap setters; the actual `graph_compute_async` calls run unlocked so target verify and MTP encode can interleave on each scheduler. Public C APIs (`include/llama.h`): ```c LLAMA_API int32_t llama_decode_mtp_async( struct llama_context * ctx, llama_seq_id seq_id, llama_pos attn_pos, llama_token last_token, const float * h_prev, int32_t n_steps); LLAMA_API int32_t llama_decode_mtp_wait( struct llama_context * ctx, llama_token * out_drafts, float * out_h_prev_last); // Backward-compatible synchronous facade. If out_logits != NULL falls back to // decode_mtp_sync (per-step logits captured in-thread). LLAMA_API int32_t llama_decode_mtp( struct llama_context * ctx, llama_seq_id seq_id, llama_pos attn_pos, llama_token last_token, float * h_prev, int32_t n_steps, llama_token * out_drafts, float * out_logits, float * out_h_prev_last); ``` Contract: - At most **one in-flight request per context**. `_async` while a previous request has not been `_wait`ed returns `-7`. - `h_prev` is *copied* into the request → caller may free / reuse immediately. - Drafts are written into `out_drafts[0..n_steps-1]`; the last `h_prev` is optionally copied into `out_h_prev_last`. Worker loop (`mtp_worker_loop`): waits on `mtp_pending`, runs `decode_mtp_run` (the per-step chain on `sched_mtp`), publishes `mtp_completed`, and notifies. `decode_mtp_run` per step: 1. Build `llama_ubatch` `{token=last_token, embd=h, pos=attn_pos+1+k, output=0}`. 2. `kv_iswa->init_mtp(seq_id, ub)` → memory context. 3. `process_ubatch_mtp` → reuse cached graph if `can_reuse(gparams)` else rebuild + alloc. 4. `graph_compute_mtp` → `sched_mtp.graph_compute_async` → synchronize. 5. Read `t_argmax` (4 bytes) → `last_token = drafts[k]`; read `t_embd` → `h` (next `h_prev`). On context destruction the worker is signalled via `mtp_worker_stop`, woken, and joined before tearing down `sched_mtp`. --- ## 7. Speculative driver — pipeline depth-2 The host driver lives in `common/speculative.cpp :: common_speculative_state_mtp`. Its job is to translate the server's "draft / accept" loop into the right `_async / _wait` calls and to enforce the KV-safety contract. ### Depth-2 overlap The server normally goes: ``` loop: drafts = common_speculative_draft(...) # produce drafts target_decode(...) # verify drafts n_acc, sampled = sample_and_accept_n(...) common_speculative_accept(spec, n_acc) seq_rm(...); update slot.sampled / batch.dft index ``` Depth-2 inserts a `prepare_next` at the **end** of the iteration, after `accept` and `seq_rm`: ``` common_speculative_prepare_next(spec, slot.sampled) # async submit ``` …which calls `llama_decode_mtp_async(...)` for the *next* round using: - `attn_pos = seq_pos_max(seq_id)` (post `seq_rm`), - the real sampled token `slot.sampled` (no optimistic guess), - `h_prev = embeddings_ith(h_idx)` snapshotted right after sample/accept (see Section 8 for `h_idx`). Then on the next iteration, `common_speculative_draft` checks `has_pending`. If pending and `pending_n_steps == n_steps`, it goes "lazy": ``` llama_decode_mtp_wait(...) # blocks only on whatever is left of MTP ``` This **overlaps MTP draft compute with everything that happens between `accept` and the next `draft`**: token I/O, OAI streaming, slot bookkeeping, batching, the next prefill if any. The benefit is real because the MTP graph, while small, is not free — it is `B - 1` sequential single-token decodes through 4 layers + cross-attn + LM head. When `n_steps` changes between iterations (e.g. on the last iteration of a request), or when the target is about to mutate KV destructively, the driver **drains** the in-flight request (`mtp_drain_pending_discard`) to keep the `_async`/`_wait` invariant intact. The depth-2 path can be A/B-tested at runtime by exporting `LLAMA_PIPELINE_DEPTH2=0`, which turns `prepare_next` into a no-op and restores depth-1 (sync `_async + _wait` inside `draft`). ### Drain points (server-side) `tools/server/server-context.cpp` invokes `common_speculative_cancel` in three places: 1. When the iteration **skips** speculative decoding (`n_remaining == 1`, `n_min` not satisfied, etc.) — otherwise the worker would compute against KV that is about to change in the upcoming target_decode (we observed Metal command-buffer status 3 on turbo3 KV before this guard). 2. After `send_final_response` / `slot.release` — the next request will `seq_rm` and overwrite cells the worker is still reading. 3. In `common_speculative_begin` (new prompt) — the previous generation's in-flight MTP must not bleed into the next prompt. --- ## 8. The `h_idx` correction A subtle correctness issue: `embeddings_ith(-1)` returns the **last batch output**, which after partial draft acceptance is the hidden state of a **rejected** draft (computed for the wrong input). Feeding that as `h_prev` collapses acceptance. Fix: after `sample_and_accept_n` the server sets ```cpp common_speculative_set_h_idx(slot.spec, slot.i_batch_dft[ids.size() - 1]); ``` i.e. it points the next MTP draft at the batch index of the **last accepted token**. This is honored both in the sync `draft` path and in `prepare_next` (Section 7). --- ## 9. Adaptive skip-streak There are workloads (numbers, code, rare tokens deep in long generations) where the MTP head is consistently wrong. Drafting still costs ~10 ms with no accepted tokens. The driver detects this: - `prev_n_acc_drafts` snapshot at the end of each `draft`. - Increment `zero_accept_streak` when `n_acc_drafts` did not move since the previous call; reset on any non-empty accept. - After `LLAMA_MTP_SKIP_STREAK_THRESHOLD` consecutive zero-accepts (1..32), return an empty draft for one batch (server falls back to a single-token verify), reset the streak, and let the next batch re-arm. - `skip_streak_last_draft` prevents threshold=1 from oscillating into a permanent skip. Off by default (env unset / `0`). The `MTP_PRESET=throughput` Edge presets do **not** enable it either — turn it on per-deployment when the workload warrants. --- ## 10. Diagnostic NDJSON tracer (`LLAMA_MTP_ACC_TRACE`) Set `LLAMA_MTP_ACC_TRACE=1` (stderr) or `LLAMA_MTP_ACC_TRACE=/path/to.ndjson` (append) to enable the `mtp_acc_tracer` in `common/speculative.cpp`. Off by default at zero overhead (enabled-only L2 reduction over `n_bb` per draft). Two events per iteration, paired by `iter`: - `mtp_draft` — `iter`, `path` (`sync` / `lazy` / `skip-streak` / `skip-nsteps`), `seq_id`, `id_last`, `h_idx`, `attn_pos`, `n_steps`, `h_l2` (L2 norm of `h_prev`), and `drafts[]`. - `mtp_accept` — `iter`, `n_accepted`, `n_drafted_prev`. This is the recommended tool for any acceptance-rate debugging: per-position acceptance, h_prev stability, `h_idx` selection bias, and depth-2 lazy/sync distribution all fall out by joining on `iter`. --- ## 11. Operating it — scripts and presets ### Pre-built assistant GGUFs Official Gemma 4 assistant heads, converted with this fork's `convert_hf_to_gguf.py` (preserves I32 `mtp.token_ordering` for the centroid-head Edge variants), are published as a Hugging Face collection: > [AtomicChat / Gemma 4 Assistant GGUF](https://huggingface.co/collections/AtomicChat/gemma-4-assistant-gguf) > — F16 / Q8_0 / Q5_K_M / **Q4_K_M** / Q4_K_S quantizations. | Target | Assistant repo | Recommended quant | |---|---|---| | Gemma 4 E2B | [`AtomicChat/gemma-4-E2B-it-assistant-GGUF`](https://huggingface.co/AtomicChat/gemma-4-E2B-it-assistant-GGUF) | **Q4_K_M** | | Gemma 4 E4B | [`AtomicChat/gemma-4-E4B-it-assistant-GGUF`](https://huggingface.co/AtomicChat/gemma-4-E4B-it-assistant-GGUF) | **Q4_K_M** | | Gemma 4 26B-A4B | [`AtomicChat/gemma-4-26B-A4B-it-assistant-GGUF`](https://huggingface.co/AtomicChat/gemma-4-26B-A4B-it-assistant-GGUF) | **Q4_K_M** / Q4_K_S | | Gemma 4 31B | [`AtomicChat/gemma-4-31B-it-assistant-GGUF`](https://huggingface.co/AtomicChat/gemma-4-31B-it-assistant-GGUF) | **Q4_K_M** / Q4_K_S | Q4_K_M is the recommended default: throughput is identical to F16 in the matrix bench (the head is small enough that bandwidth, not weight precision, dominates), while VRAM/RAM footprint is ~4× lower. Drop to F16 only if you are debugging an acceptance regression that you suspect is quant-related; the verifier `scripts/verify-gemma4-assistant-gguf.py` will refuse to load a malformed assistant GGUF in either case. The repo helpers prefer a quantized assistant under `.scratch/` when one exists (`gemma-{e2b,e4b,…}-assistant-mtp-Q4_K_M.gguf`) and fall back to F16 otherwise. Override with `DRAFT_GGUF=…` or pass `--mtp-head` directly. ### Run scripts Helper scripts live under `scripts/`: | Script | Target | Notes | |---|---|---| | `run-gemma4-mtp-server.sh` | gemma 4 26B | dense LM head; `MTP_PRESET` not used | | `run-gemma4-31b-mtp-server.sh` | gemma 4 31B | dense LM head | | `run-gemma4-e2b-mtp-server.sh` | gemma 4 E2B | centroid head; `MTP_PRESET` aware | | `run-gemma4-e4b-mtp-server.sh` | gemma 4 E4B | centroid head; `MTP_PRESET` aware | | `run-gemma4-server-turbo.sh` | dense baselines, no MTP | TurboQuant KV demo | | `quantize-gemma4-edge-assistant-mtp.sh` | quantizer for E2B/E4B assistant | preserves I32 ordering | Edge presets (`MTP_PRESET`): | Preset | `DRAFT_BLOCK_SIZE` (B, passed as `--spec-draft-n-max B-1`) | `DRAFT_MAX` (legacy cap, no longer passed) | |---|---:|---:| | `throughput` | 2 | 6 | | `lift` | 3 | 8 | | `balanced` | 3 | 8 | | `quality` | 4 | 16 | Override directly with `DRAFT_BLOCK_SIZE`, `DRAFT_MAX`, `LLAMA_MTP_SKIP_STREAK_THRESHOLD`. KV typing is taken from `CTK / CTV / CTKD / CTVD` — both target and assistant inherit the same default (`turbo3`). The bench harness `.scratch/bench-matrix.sh` runs the matrix `{model} × {f16-base, turbo3-base, f16-mtp, turbo3-mtp} × {short=128, long=512} × 3 runs` against `/v1/chat/completions` with `temperature=0`, `cache_prompt=false` and `stream=false`, and reports median tps + mean draft-accept rate. --- ## 12. Latest matrix benchmark (`.scratch/bench-logs/gemma-matrix-fullrun-20260512-224705.md`) Run on 2026-05-12 on a **MacBook Pro M4 Max (40-core GPU, 48 GB)**. Q4_K_M assistant heads, draft-block defaults from each script (`B = 3` for the dense scripts, `B = 2` for E4B `MTP_PRESET=throughput`). `accept` is `draft_n_accepted / draft_n` averaged over 3 runs; `tps` is the median. Cells now include the **Edge E4B** target as well (centroid head). ### Bench host | Component | Value | |---|---| | Machine | MacBook Pro (`Mac16,5`, MX313LL/A) | | SoC | Apple **M4 Max** — 16 CPU cores (12P + 4E), **40-core GPU** | | Unified memory | **48 GB** LPDDR5 | | OS | macOS 26.3.1 (build 25D2128), Darwin 25.3.0 | | llama.cpp backend | Metal (full GPU offload: `-ngl 99 -ngld 99`, `-fa on`) | | Server | local `llama-server` over `127.0.0.1:8080` | | Client | `python3 urllib` → `/v1/chat/completions`, `temperature=0`, `cache_prompt=false`, `stream=false` | | Driver | `.scratch/bench-matrix.sh` (3 runs/cell, median tps, mean accept) | Single-slot configuration (`--parallel 1 -np 1 --cont-batching`); no other heavy GPU/CPU workloads were running on the host during the matrix sweep. | model | mode | short tps (n=128) | long tps (n=512) | short accept | long accept | Δ short | Δ long | |---|---|---:|---:|---:|---:|---:|---:| | gemma-E4B | f16-base | 90.29 | 88.99 | — | — | — | — | | gemma-E4B | f16-mtp | **94.27** | 86.00 | 80.0% | 64.5% | **+4.4%** | −3.4% | | gemma-E4B | turbo3-base | 53.41 | 53.45 | — | — | — | — | | gemma-E4B | turbo3-mtp | **67.83** | **64.47** | 82.6% | 72.3% | **+27.0%** | **+20.6%** | | gemma-26B | f16-base | 83.56 | 82.65 | — | — | — | — | | gemma-26B | f16-mtp | **110.81** | 75.66 | 84.0% | 67.9% | **+32.6%** | −8.5% | | gemma-26B | turbo3-base | 51.75 | 49.45 | — | — | — | — | | gemma-26B | turbo3-mtp | **80.50** | **69.21** | 84.9% | 66.1% | **+55.6%** | **+40.0%** | | gemma-31B | f16-base | 19.41 | 17.49 | — | — | — | — | | gemma-31B | f16-mtp | **21.15** | **18.46** | 88.0% | 74.4% | **+9.0%** | **+5.5%** | | gemma-31B | turbo3-base | 15.73 | 15.44 | — | — | — | — | | gemma-31B | turbo3-mtp | **19.36** | **16.31** | 88.0% | 70.7% | **+23.1%** | **+5.6%** | Key observations: - **turbo3 MTP is the sweet spot across all three targets.** The asymmetric jump on 26B (+55.6% short, +40.0% long over `turbo3-base`) reflects that 26B is bandwidth-bound at this rig: TurboQuant3 KV already lifts the baseline, and MTP then converts the spare compute headroom into accepted drafts. - **f16 MTP wins on short, can lose on long.** 26B f16 long regresses to −8.5 % vs `f16-base` because the dense head is paid every iteration; once acceptance drops to ~68% (boilerplate runs out), the per-step cost outweighs the saved verifications. The right combo for 26B is `f16` target weights + `turbo3` KV + MTP — this matrix only covers the homogeneous KV cells, but the practical lift on heterogeneous KV is in line with the `turbo3-mtp` column. - **Acceptance stays high on all targets** (≥80% short, ≥64% long). E4B acceptance is now competitive with the dense heads thanks to the `MTP_PRESET=throughput` (`B = 2`, `max = 6`) defaults and the I32 ordering fix in the converter. - **31B is bandwidth-bound** (`turbo3-base 15.73 > f16-base` on long was observed in earlier matrices and reappears within run-to-run noise here), so turbo3 KV + MTP is the clear pick. ### How we got here (history within this branch) The matrix logs in `.scratch/bench-logs/` show the optimisation journey for the gemma-26B `f16-mtp` short-prompt cell: | Log (mtime, `ls -lt`) | Short tps | Long tps | Short accept | What changed | |---|---:|---:|---:|---| | `matrix-run2.log` (May 7 01:26) | 70.89 | 76.79 | 55.5% | early async pipeline, sync wrapper | | `matrix-old.log` (May 7 01:41) | 61.88 | 63.98 | 50.0% | depth-1 sync MTP, `h_idx=-1` regression | | `matrix-q4chat.log` (May 7 02:02) | 109.49 | 95.75 | 85.9% | depth-2 + in-graph argmax + correct `h_idx` (Q4_K_S) | | `gemma-matrix-fullrun-20260512-224705.md` | **110.81** | 75.66 | **84.0%** | this matrix (Q4_K_M, includes E4B; long is noisier on this run) | The big jump (~62 → ~109 tps short) came from three independent fixes landing together back in May 7: 1. **`h_idx` correction** so MTP feeds the *accepted* hidden state instead of a rejected draft's output (acceptance jumps from ~50% to ~86%). 2. **Pipeline depth-2 overlap** so MTP work overlaps post-accept bookkeeping (steady ~+8% throughput at fixed accept). 3. **In-graph argmax** so the host transfers 4 bytes instead of `n_vocab × 4 B` per step (~+2-3% on top). The current matrix (May 12) is on **Q4_K_M assistants** (rather than Q4_K_S in May 7) and adds the Edge **E4B** row. Short-prompt tps is within noise; the 26B `f16-mtp` long cell dropped because that bench host had heavier ambient load that day (the `turbo3-mtp` long cell, the harder case, was unaffected). --- ## 13. Trade-offs and gotchas These are the non-obvious failure / regression modes you should keep in mind when changing or extending this code. **Embeddings on the target context.** MTP is meaningless without `llama_set_embeddings(ctx_tgt, true)`. The server wires this conditionally per batch (`need_embeddings = need_embd() || mtp_active`). If a future code path flips embeddings off mid-generation, MTP will silently degrade to drafting against zero `h_prev`. **`h_idx` after partial accept.** Forgetting to call `common_speculative_set_h_idx` after `sample_and_accept_n` regresses accept rate to ~50 % on the same workload (matrix-old vs matrix-q4chat). Any new code path that produces drafts must restore the correct batch index of the *last accepted* token, not `-1`. **KV append-only invariant.** Async MTP correctness depends on `attn_pos` cells remaining stable until `_wait`. Any new operation that rewrites KV in place (eviction, sliding-window compaction, retroactive `seq_rm` past `attn_pos`) must call `common_speculative_cancel` first. The server has three explicit drain points (Section 7); reuse them rather than inventing a fourth contract. **Single in-flight request per context.** This is intentional — multiplexing MTP across slots requires a sched-per-slot or a request queue with its own graph-cache. Today a second `_async` returns `-7` and `prepare_next` is a no-op when one is in flight. With `--parallel > 1` slots run on the same context: the MTP overlap currently benefits only the slot whose `prepare_next` won the race; the others fall back to sync. Lifting this is non-trivial (graph-cache, scheduler, KV snapshot all need per-slot identity). **`draft_block_size` vs. `draft_max`.** `draft_block_size` is the **MTP head's block** (head emits `B - 1` tokens). `draft_max` is the standard llama.cpp upper bound on draft length the server will accept. For Edge centroid heads (heavier per-step), small `B` (2-3) usually wins; for the dense 26B/31B, `B = 3` is the current sweet spot in the matrix bench. **Centroid-head `top_k` cost.** Edge MTP runs `top_k` over `n_centroids` and a routed `get_rows` per draft step. Greedy still materialises the full-vocab row (masked-fill + scatter) so verify-side argmax stays consistent. `use_ordered_embeddings` has measurably higher per-step cost than the dense head; budget `B = 2` (`MTP_PRESET=throughput`) by default on Edge. The Edge matrix cell is not yet in `matrix-q4chat.log` (the script lists `gemma-E4B` in `MODELS`, but the row is not present — the GGUF was missing on the bench host that day). **Skip-streak hysteresis.** With `LLAMA_MTP_SKIP_STREAK_THRESHOLD=1` and without `skip_streak_last_draft`, the driver would skip every other batch forever as soon as one zero-accept happened. Keep that guard. **Backend reconfiguration races.** `set_n_threads` / `set_threadpool` are process-global on a backend. The `backend_cfg_mu` window in `graph_compute` / `graph_compute_mtp` is intentionally tiny (only the setters, never the `graph_compute_async` itself). Lengthening that critical section will block the worker on every target step and erase the depth-2 win. **Vocab compatibility for MTP is laxer than for `--spec-type draft`.** Target chat templates own stop / EOS tokens; the MTP head only predicts next-token ids. `common_speculative_are_compatible_mtp` therefore checks `vocab_type`, size (within `SPEC_VOCAB_MAX_SIZE_DIFFERENCE`) and per-token text equality from id ≥ 5, but **skips** bos/eos/add_bos/add_eos checks. Don't reuse this loosened check for non-MTP draft pairings. **Optimistic last token (future work).** Submitting `prepare_next` with a guess of the next sampled token before sample/accept could hide one extra `llama_decode` on hits. On misses we'd waste the entire MTP block. Not landed — would need a clear measurement that hit-rate is high enough to justify the miss cost on this workload. --- ## 14. Quick reference ```sh # 26B + Q4 assistant, MTP on TurboQuant3 KV (matches matrix-q4chat row). scripts/run-gemma4-mtp-server.sh # E4B, throughput preset (B=2, max=6), centroid head, optional skip-streak. LLAMA_MTP_SKIP_STREAK_THRESHOLD=4 \ MTP_PRESET=throughput \ scripts/run-gemma4-e4b-mtp-server.sh # A/B-test depth-2 overlap vs sync at the same model/config: LLAMA_PIPELINE_DEPTH2=0 scripts/run-gemma4-mtp-server.sh # NDJSON acceptance trace to a file. LLAMA_MTP_ACC_TRACE=/tmp/mtp.ndjson scripts/run-gemma4-mtp-server.sh # Re-run the matrix bench (median over 3 runs per cell). bash .scratch/bench-matrix.sh | tee .scratch/bench-logs/matrix-$(date +%H%M).log ``` Environment knobs: | Var | Default | Effect | |---|---|---| | `LLAMA_PIPELINE_DEPTH2` | unset (on) | `=0` disables depth-2 overlap; falls back to sync `_async + _wait` inside `draft`. | | `LLAMA_MTP_SKIP_STREAK_THRESHOLD` | unset / `0` (off) | `1..32` enables zero-accept skip streak. | | `LLAMA_MTP_ACC_TRACE` | unset (off) | `1` → stderr; any other value → file path (append). | | `LLAMA_GRAPH_REUSE_DISABLE` | unset (off) | Disables `llm_graph_result::can_reuse`. Useful when changing the MTP graph; disastrous for throughput. | Public API entry points: ```c llama_model_load_mtp_from_file(model, path, mparams); llama_model_has_mtp_assistant(model); llama_model_get_mtp_assistant(model); llama_model_mtp_n_embd_backbone(model); llama_decode_mtp_async(ctx, seq_id, attn_pos, last_token, h_prev, n_steps); llama_decode_mtp_wait (ctx, out_drafts, out_h_prev_last); llama_decode_mtp (ctx, ..., out_logits, ...); // sync facade ``` Driver entry points (`common/speculative.h`): ```c common_speculative_init / _free common_speculative_set_seq_id // server slot -> target seq id common_speculative_set_h_idx // last accepted batch idx after accept_n common_speculative_begin // per-prompt; drains stale MTP common_speculative_draft // emits drafts (lazy-waits depth-2) common_speculative_accept // updates stats; emits trace common_speculative_prepare_next // depth-2: async submit for next round common_speculative_cancel // drain in-flight MTP common_speculative_print_stats ```