Repository navigation
Description
Tracking issue for speculative decoding in FreeToken. Related: #79 (roadmap).
WIP: the first version has landed. The issue stays open for the open items in Support by model and PRs, and is updated as they move.
Note
A draft PoC with the whole stack on one branch is in #832. It is a reference only and will not be merged; the PRs below may structure the code differently.
Update (2026-11-09): the draft PoC is abandoned; the MTP stack it prototyped has merged as #844.
Scope
- Draft methods:
- MTP, from the checkpoint's own MTP layers, or for Gemma 4 from the
gemma4-assistantsidecar (which keeps no KV and reads the target's). - DFlash 2 drafts. DFlash v1 drafts are refused.
- DSpark is not started.
- MTP, from the checkpoint's own MTP layers, or for Gemma 4 from the
- Targets: see Support by model. DFlash is not tied to one model family: a target only has to export the hidden states of the layers its draft reads.
- On the Qwen families this covers GatedDeltaNet hybrids, MoE offload and hybrid modes, and multimodal prompts.
Design
- Layers. The engine and the scheduler see one runtime interface,
SpecRuntime(engine/spec_protocol.py), implemented bySpecRunner(spec/runner.py). The runner holds oneProposer(spec/proposer.py), the draft side of a method. Each method has a subpackage underspec/(spec/mtp/,spec/dflash/). - Drafts. Draft networks live with their model family (
models/qwen3_5_moe/mtp.py,models/gemma4/mtp.py), and a family registers them inModelSpec.spec_draftsas method -> class.--spec-algo mtprunsMTPProposer, orSharedKVMTPProposerfor a family that declaresshared_kv_mtp.spec/imports no model family. - Flow. Each scheduler iteration runs one batch: a prompt chunk if one is waiting, otherwise a verify batch for the running requests, for which it calls
proposewhile building the batch. The engine runs the target forward and then callson_prefillorpost_verifyin the same step, and the scheduler drains the batch before building the next. The table below says what each proposer does in its hooks.
| Proposer | on_prefill |
propose |
post_verify |
|---|---|---|---|
MTPProposer (checkpoint MTP layer) |
draft extend over the prompt, writing its own draft KV | k-1 chain steps from the first draft (CUDA graph) | draft extend over the k+1 verify rows; keeps the row at the last accepted position |
SharedKVMTPProposer (Gemma 4 assistant) |
stashes the last prompt row's target hidden; no draft forward | k steps at the pending position, reading the target's KV | stashes the last accepted row's target hidden |
DFlashProposer (DFlash 2) |
context rows from the target's tapped layers into the window checkpoint, and their K/V into the ring; no draft forward | one forward over a block of the pending token plus k masks | every verify row's context rows into the ring and window |
- Target -> draft. The target forward hands the draft its hidden states through
batch.features(FeatureSinkinmodels/blocks.py):final_hiddenfor MTP drafts, andlayer_outputfor the layers a DFlash draft reads. - Per-request state. A per-request state (
SlotStateSpec), such as GatedDeltaNet's, needs averifyhandler (SlotVerify). The verify forward only reads the live state and stages scratch;commitrolls the state to the accepted count. The engine refuses a model with a state that has no handler. - Prefix cache.
- A draft whose KV slot i depends on token i+1 (the MTP drafts) sets
radix_lookahead, so radix keys pair (t_i, t_{i+1}). The SWA radix cache keeps plain token keys. - Draft state has to survive a prefix hit. DFlash does not keep its sliding-window K/V for that. It checkpoints the window's context rows (
DFlashWindow, one hidden-width row per position), which is 1.5-6x smaller than the draft's K/V for the released drafts. The checkpoint rides the GatedDeltaNet slots as aSlotStateSpec, so a prefix hit restores it with the GatedDeltaNet state, and the draft's K/V ring is rebuilt from it.
- A draft whose KV slot i depends on token i+1 (the MTP drafts) sets
- Sliding-window targets. The SWA pool floor budgets
decode_rows_per_reqrows per step, and each request frees its out-of-window slots once an interval has slid out. A proposer opts in withtarget_may_be_windowed. - Attention backends. The verify needs a backend that captures graphs of k+1 query rows per request:
BackendInfo.uniform_extend_graphisdeviceforfa,tritonandqsa_sparse, andhost_planforfi.tritonruns verify rows through its prefill kernel today (perf(attention): run up to 8 query rows per request in the triton decode kernel #900). - Limits.
--tp 1, and the normal loop (no overlap scheduling). - Outputs. Speculative decoding must not change outputs.
tests/spec/test_verify_contract.pycompares greedy runs token by token on tiny models, and the real checkpoints are compared the same way.
Adding speculative decoding to a model
Questions to answer before writing code:
- Draft. Which method does the release use: the checkpoint's own MTP layers, a sidecar that reads the target's KV (Gemma 4 assistant), DFlash 2, or DSpark? Where do its weights come from, and does an existing proposer fit?
- Target forward. Does the model run a verify batch (k+1 rows per request) correctly, including per-token side inputs such as positions, mrope and PLE? Does it hand
batch.featureswhat the draft reads? - Per-request state. Does every
SlotStateSpecof the family (GatedDeltaNet, KDA, ...) have averifyhandler? - KV and prefix cache.
- Does the draft keep KV of its own (declared in
budget), and does that KV needradix_lookahead? - Is the target sliding-window, or DSV4 window-tier?
- Must draft state be restored on a prefix hit?
- Does the draft keep KV of its own (declared in
- Attention. Does the family's attention backend have a
uniform_extend_graph?dsa,dsv4_sparse,dsv41_sparseandm3_sparsedo not yet. Which backend runs the draft's own attention? - Tests. Add a tiny-config case to
test_verify_contract.py: once with the oracle proposer for the target side, and once with the real draft class, asserting the draft is used. Compare greedy outputs on the real checkpoint.
Support by model
✅ supported, ❌ not yet, — none published. DFlash 2 drafts are incoai's releases. Prefix cache is the cache the engine runs for --cache-type radix: hybrid_radix for GatedDeltaNet / KDA hybrids, swa_radix for sliding-window and DeepSeek-V4 window-tier models, plain radix otherwise.
| Model | Prefix cache | MTP / DSpark | DFlash 2 |
|---|---|---|---|
| Qwen3.5 / 3.6 / 3.8 | hybrid_radix |
MTP ✅ #844, #856 | ✅ #857: Qwen3.6-35B-A3B-DFlash2, Qwen3.8-27B-DFlash2; GGUF ❌: Qwen3.8-27B-DFlash2-GGUF |
| Qwen3.8-Flash-Next | hybrid_radix |
MTP ✅ #844 | ✅ #857: Qwen3.8-Flash-Next-DFlash2; GGUF ❌: Qwen3.8-Flash-Next-DFlash2-GGUF |
| Gemma 4 | swa_radix |
gemma4-assistant ✅ #895 |
— |
| DeepSeek-V4 Flash / Pro | swa_radix |
MTP ❌; DSpark ❌: DeepSeek-V4-Flash-DSpark, DeepSeek-V4-Pro-DSpark | — |
| DeepSeek-V4.1 Flash | swa_radix |
DSpark ❌ | — |
| GLM-5.3-Flash | hybrid_radix |
MTP ❌ | ❌ GLM-5.3-Flash-DFlash2 |
| GLM-5.3 | radix |
MTP ❌ | ❌ GLM-5.3-DFlash2 |
| GLM-5.2 | radix |
MTP ❌ | — |
| GLM-4.5 / 4.6 / 4.7 | radix |
MTP ❌ | — |
| MiniMax-M2 / M2.5 / M2.7 | radix |
MTP ❌ | — |
| MiniMax-M3 | radix |
MTP ❌ | — |
| Muse-Glimmer | swa_radix |
— | ❌ Muse-Glimmer-30B-DFlash2 |
| Flint | swa_radix |
— | ❌ Flint-DFlash2 |
| gpt-oss | swa_radix |
— | — |
A draft with a sliding window (every DFlash 2 release uses 2048 tokens) on a hybrid_radix target follows the Qwen families. The draft's window is not kept as sliding-window KV under a window radix: cached prefixes would each hold draft K/V, which costs too much VRAM on edge hardware. Instead, the rows its K/V is computed from are stored as a slot state in hybrid_radix, restored with the target's linear-attention state on a prefix hit, and the K/V is rebuilt from them. For a radix or swa_radix target, there is no design yet.
Not in the first version
- Overlap scheduling together with speculative decoding.
- Tree drafts (not planned).
PRs
- Engine and scheduler seams for multi-row decode steps (no behavior change): refactor: add engine and scheduler seams for multi-row decode steps #833
-
radix_lookaheadkeys, so that a prefix hit never reuses an MTP draft slot written for a different next token: feat(scheduler): add radix_lookahead keys to the prefix cache #834 - Linear-attention metadata in a
LinearAttnBackend(no behavior change): refactor(attention): move linear-attention metadata into a LinearAttnBackend #837 - One shared GatedDeltaNet layer for the
qwen3_5_moeandqwen4_expfamilies (no behavior change): refactor(layers): share one GatedDeltaNet op in layers/linear_attn #841 - Target-side verify: GatedDeltaNet verify and replay, rejection sampling, per-row presence and frequency penalties: feat(spec): verify draft tokens on the target side #836
- MTP for the Qwen3.5 family (HF checkpoints) and Qwen3.8-Flash-Next (HF checkpoint, or GGUF with a Strata MTP pack): feat(spec): add MTP drafts for the Qwen3.5 family and Qwen3.8-Flash-Next #844
- DFlash 2 drafts, with exact draft-context restore on prefix-cache hits: feat(spec): add DFlash 2 speculative decoding #857
- GGUF MTP weight loading for the Qwen3.5 family (side branch): feat(gguf): load mtp drafts for the qwen3.5 family from nextn blocks and mtp-*.gguf sidecars #856
-
--speculative-token-mapfor a smaller MTP draft head: feat(spec): add --speculative-token-map for a smaller mtp draft head #892 - Gemma 4 MTP from the
gemma4-assistantGGUF sidecar (dense, E2B / E4B, 26B-A4B), and with it sliding-window targets: feat(gemma4): speculative decoding with the assistant draft #895 - [Perf] Up to 8 query rows per request in the triton decode kernel, so verify steps stop falling back to the prefill kernel at long context: perf(attention): run up to 8 query rows per request in the triton decode kernel #900
- [Perf] CUDA graphs for the Gemma 4 assistant draft, which runs eager
- DFlash with prefix reuse on
radixandswa_radixtargets (no design yet)
PRs will be linked here as they open.
Activity
void-mckenzie commentedon Nov 10, 2026
I'd like to take on Gemma 4 assistant MTP support (dense releases, E2B / E4B, and 26B-A4B). I know #828 left sliding-window targets out of v1, so I wanted to outline what lifting that restriction for Gemma 4 actually takes, I already have this running locally.
How the assistant works in engine terms:
It ships as a separate
gemma4-assistantGGUF alongside the target (upstream isgoogle/gemma-4-*-it-assistant, mirrored by unsloth). Unlike the Qwen MTP layer, it has no K or V tensors at all. It takes the target's last-token embedding and post-final-norm hidden state, projects them to a smaller width, runs four layers of read-only attention over the target's KV cache (its SWA layers read the target's last SWA layer, and its full-attention layer reads the target's last layer, with all draft tokens at one position), and samples from its own tied vocab table.llama.cpphas done this since mid-June. Because it reads the target's cache, it adds no KV to the budget at all - just its own weights, about 149 MiB for the E4B assistant and 425-428 MiB for the 12B and 26B-A4B Q8_0 ones.What the engine needed:
MTPProposerforcing the target's LM head.stores_own_kvflag on the draft.engine/engine.pyconditional. That check exists because window frees can release slots a verify step still needs - which applies to the target's widened rows, not the draft. The draft only reads committed positions (never pastcached_len) and stores nothing.Bug found along the way:
rollback_tailfrees full-attention slots for rejected verify rows, but not their sliding-window slots - leaking ~k+1 SWA slots per verify step until the pool exhausts. It's only latent onmainbecause speculative decoding currently blocks SWA targets. I have this as the first commit with a regression test. Happy to split it into a separate PR if you'd prefer to keep cache-layer fixes isolated.Benchmarks (5060 Ti 16 GB, greedy, vs. baseline without spec flags):
--embed-weights host) + assistant: 25/25 prompts byte-identical, ~2.6x mean decode, 3.7 median accept length.Out of scope for now:
Chain-graph capture for this draft (eager-only today) and E-series slab aliasing (left for #882). One prompt diverged from the baseline on the Q2_K_XL 12B target and did not reproduce on BF16 or under repeat runs; it looked like a format choice between two near-equal tokens, and since FreeToken doesn't expose logprobs I couldn't confirm the margin. Not related to the draft path itself.
Is a PR against this fine, and would you rather the SWA slot fix land with it or before it?
jason-fxz commentedon Nov 11, 2026
@void-mckenzie Thanks! A PR is welcome, and please keep the SWA slot fix in the same PR, since it only matters once SWA targets are allowed.
--spec-algo mtp --spec-draft-model <assistant>. Internally, a separate proposer rather than an extension ofMTPProposer, since the assistant keeps no KV of its own.[-][WIP] Speculative decoding: MTP and DFlash (tracking)[/-][+][WIP, RoadMap] Speculative decoding: MTP and DFlash (tracking)[/+][-][WIP, RoadMap] Speculative decoding: MTP and DFlash (tracking)[/-][+][WIP, Roadmap] Speculative decoding: MTP and DFlash (tracking)[/+]