Skip to content

[WIP, Roadmap] Speculative decoding: MTP and DFlash (tracking) #828

Open

Description

@jason-fxz

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-assistant sidecar (which keeps no KV and reads the target's).
    • DFlash 2 drafts. DFlash v1 drafts are refused.
    • DSpark is not started.
  • 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 by SpecRunner (spec/runner.py). The runner holds one Proposer (spec/proposer.py), the draft side of a method. Each method has a subpackage under spec/ (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 in ModelSpec.spec_drafts as method -> class. --spec-algo mtp runs MTPProposer, or SharedKVMTPProposer for a family that declares shared_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 propose while building the batch. The engine runs the target forward and then calls on_prefill or post_verify in the same step, and the scheduler drains the batch before building the next. The table below says what each proposer does in its hooks.
spec decode design
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 (FeatureSink in models/blocks.py): final_hidden for MTP drafts, and layer_output for the layers a DFlash draft reads.
  • Per-request state. A per-request state (SlotStateSpec), such as GatedDeltaNet's, needs a verify handler (SlotVerify). The verify forward only reads the live state and stages scratch; commit rolls 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 a SlotStateSpec, so a prefix hit restores it with the GatedDeltaNet state, and the draft's K/V ring is rebuilt from it.
  • Sliding-window targets. The SWA pool floor budgets decode_rows_per_req rows per step, and each request frees its out-of-window slots once an interval has slid out. A proposer opts in with target_may_be_windowed.
  • Attention backends. The verify needs a backend that captures graphs of k+1 query rows per request: BackendInfo.uniform_extend_graph is device for fa, triton and qsa_sparse, and host_plan for fi. triton runs 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.py compares 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:

  1. 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?
  2. 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.features what the draft reads?
  3. Per-request state. Does every SlotStateSpec of the family (GatedDeltaNet, KDA, ...) have a verify handler?
  4. KV and prefix cache.
    • Does the draft keep KV of its own (declared in budget), and does that KV need radix_lookahead?
    • Is the target sliding-window, or DSV4 window-tier?
    • Must draft state be restored on a prefix hit?
  5. Attention. Does the family's attention backend have a uniform_extend_graph? dsa, dsv4_sparse, dsv41_sparse and m3_sparse do not yet. Which backend runs the draft's own attention?
  6. 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

PRs will be linked here as they open.

Activity

  1. void-mckenzie commented on Nov 10, 2026

    @void-mckenzie
    Contributor

    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-assistant GGUF alongside the target (upstream is google/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.cpp has 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:

    • A draft that owns its logits head, rather than MTPProposer forcing the target's LM head.
    • A read-only view onto the target's KV pool, toggled by a stores_own_kv flag on the draft.
    • Making the sliding-window check in engine/engine.py conditional. 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 past cached_len) and stores nothing.

    Bug found along the way:
    rollback_tail frees 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 on main because 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):

    • Gemma-4 E4B BF16 (--embed-weights host) + assistant: 25/25 prompts byte-identical, ~2.6x mean decode, 3.7 median accept length.
    • 26B-A4B IQ2_XXS + assistant: 25/25 byte-identical, 1.2x mean decode (short 2-5 token replies are slower under spec, as expected).
    • Long context: a 12k-token input, well past the 512 sliding window, byte-identical with the SWA pool saturated throughout.

    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?

  2. jason-fxz commented on Nov 11, 2026

    @jason-fxz
    CollaboratorAuthor

    @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.

    • Please lift the SWA restriction per target rather than per draft type; DSV4 window tiers stay out.
    • Same CLI as MTP: --spec-algo mtp --spec-draft-model <assistant>. Internally, a separate proposer rather than an extension of MTPProposer, since the assistant keeps no KV of its own.
  3. changed the title [-][WIP] Speculative decoding: MTP and DFlash (tracking)[/-] [+][WIP, RoadMap] Speculative decoding: MTP and DFlash (tracking)[/+] on Nov 11, 2026
  4. changed the title [-][WIP, RoadMap] Speculative decoding: MTP and DFlash (tracking)[/-] [+][WIP, Roadmap] Speculative decoding: MTP and DFlash (tracking)[/+] on Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature or requestspec decodeSpeculative Decoding

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions