Skip to content

Research: FR-Spec-style draft-vocab trimming for native MTP speculative decoding #25187

Description

@avifenesh

Research Stage

  • Background Research
  • Hypothesis Formed
  • Strategy / Implementation Forming
  • Analysis of results
  • Debrief / Documentation

Previous existing literature and research

The bellow is a result of trying to reduce my decode bottleneck and profiling it, noticing that a large piece of it is coming from the draft itself.

For large-vocabulary models, the draft model's LM-head projection (hidden -> vocab_size) is a significant fraction of per-step draft cost in speculative decoding, independent of the draft transformer's own size. FR-Spec (Zhao, Pan, Han et al., ACL 2025, arXiv:2502.14856) addresses this directly: restrict the draft's candidate search to a frequency-ranked subset of the vocabulary (e.g. top 32,768 of a 100k+ vocab), cutting LM-head compute by ~75% while the target model still verifies over the full vocabulary, so the output distribution is provably unchanged (lossless, same guarantee as standard speculative sampling).

SGLang already ships this for EAGLE-style drafters via --speculative-token-map: a hot_token_id tensor gathers the draft's lm_head rows down to the hot set at load time, and after the draft's topk sampling, predicted indices are remapped back to real vocab IDs (topk_index = hot_token_id[topk_index]) before being used downstream (eagle_worker_v2.py, lines ~336-338 and ~594-595 as of this writing).

llama.cpp's own EAGLE-3 implementation (src/models/eagle3.cpp) already has the equivalent machinery for a different reason — handling a draft model whose native vocabulary is smaller than the target's. It carries a d2t ("draft-to-target") tensor, auto-sized from the GGUF (d2t_meta->ne[0]), and after the draft head's matmul it scatters the compressed logits into a full-vocab tensor (filled -inf elsewhere) via ggml_set_rows, so every downstream consumer (sampler, verify path) only ever sees full-vocab-shaped, correctly-indexed logits.

The native MTP path (--spec-type draft-mtp, landed in #22673) has no equivalent — every MTP-capable architecture I checked (qwen35.cpp, qwen35moe.cpp, step35.cpp, glm4-moe.cpp, bailingmoe2.cpp) hardcodes the MTP head's output tensor (nextn.shared_head_head, or model.output when that's absent) to the trunk's full n_vocab.

Hypothesis

d2t is architecture-agnostic infrastructure, not EAGLE-3-specific. If a standalone MTP-only draft GGUF ships a d2t tensor, the same trim-at-load + scatter-after-matmul pattern EAGLE-3 already uses should let native MTP drafters use a frequency-ranked vocab subset too — with the same lossless guarantee (target still verifies full vocab) and the same kind of LM-head-cost reduction FR-Spec reports.

Strategy / Implementation

Implemented and tested for qwen35.cpp (covers Qwen3.5/Qwen3.6 dense MTP). Branch: https://github.com/avifenesh/llama.cpp/tree/frspec-mtp-vocab-trim (commit 047bfa508).

The change is ~30 lines, gated so it's a no-op for every existing GGUF:

  • In load_arch_tensors: if the draft is MTP-only (mtp_only) and a d2t tensor is present in the GGUF, size output.weight from d2t->ne[0] instead of n_vocab, and load d2t via the same LLM_TENSOR_D2T tensor name EAGLE-3 already uses.
  • In the MTP head's graph build (after build_lora_mm(head_w, ...)): if model.d2t is set, scatter the compressed logits back to full-vocab shape with ggml_set_rows, identical to the existing code in eagle3.cpp.
  • No changes needed in common/speculative.cpp — the scatter happens inside the compute graph, so the draft sampling loop never has to be aware the head was trimmed (verified empirically: the EAGLE-3 draft loop already has zero d2t-aware code, for the same reason).

A draft GGUF with a trimmed output.weight + a d2t tensor was produced offline with gguf-py (gather the output.weight rows by a frequency-ranked token-ID list, store that list as d2t). No convert_hf_to_gguf.py producer path exists yet — this issue is about the consumer/loader side and whether the approach is worth pursuing before building that out.

The frequency map itself was built from two sources for reproducibility:

  1. A 1GB, 50/50 code-prose corpus from public HF datasets (codeparrot/codeparrot-clean-valid, codeparrot/github-jupyter-code-to-text, Salesforce/wikitext wikitext-103, allenai/c4 en, HuggingFaceFW/fineweb-edu), tokenized with the target model's own tokenizer, top-32768 by raw frequency.
  2. A code-only corpus from my own agent/coding-session logs (not reproducible by others, included only as a private-workload data point, not part of the proposal).

Analysis

Hardware: Qwen3.6-27B-NVFP4 trunk + Q6_K MTP draft GGUF (--spec-type draft-mtp --spec-draft-n-max 3), single RTX 5090 Laptop, 128k ctx, K=q8_0/V=q5_1 cache.

Kernel-level (Nsight Systems, isolated decode window, same prompt before/after):

baseline (full 248,320-vocab head) trimmed (32,768 hot vocab) delta
mul_mat_vec_q (Q6_K, draft LM-head, batch=1) 407.1 ms / 655 calls 61.6 ms / 675 calls -84.9%
avg latency per call 621 µs 91 µs -85.3%

That matches FR-Spec's reported ~75% LM-head-compute reduction (32768/248320 ≈ 7.6x fewer rows; measured ≈6.6x time reduction — close given other fixed per-call overhead).

End-to-end decode throughput, wide bench (6 code + 4 prose prompts, temp=0, n-max=3):

draft / vocab map code mean tok/s prose mean tok/s overall mean
baseline (no trim) 88.4 77.2 83.9
trimmed, public 50/50 corpus 89.6 78.3 85.1
trimmed, private code-heavy corpus 93.4 76.1 86.5

The public, reproducible 50/50 map improves both code and prose over baseline. A code-weighted map trades some prose acceptance for a bigger code win — expected, since the trim only ever costs speed (a miss falls through to a lower-confidence/rejected draft token), never correctness.

Correctness: at temperature=0, generated text is byte-for-byte identical between the baseline and trimmed drafts across every prompt tested. This is the expected result, not a surprise — the target always verifies over the full vocabulary regardless of what the draft proposed, so the lossless guarantee holds independent of the draft's hit rate. I'm noting it because I checked it, not because it was in doubt.

Architectural generality (claimed, not yet run): step35.cpp, glm4-moe.cpp, and bailingmoe2.cpp declare the identical nextn.shared_head_head / {n_embd, n_vocab} tensor pattern this patch targets in qwen35.cpp, so the same ~30-line change should apply to those architectures unchanged. I have not verified this on real hardware: I could not find a current, non-Qwen model that is (a) small enough to run on a single 24GB GPU, (b) ships an MTP/nextn head in its public GGUF, and (c) is loadable by llama.cpp's existing MTP path — every candidate I checked failed at least one of those three (e.g. a popular GLM-4.7-Flash GGUF reports num_nextn_predict_layers: 1 in its source config.json, but converts to llama.cpp's deepseek2 architecture, whose loader has no nextn/MTP code at all, and the actual GGUF bytes confirm no MTP tensors were written). I'm flagging this gap rather than papering over it.

Before building a convert_hf_to_gguf.py producer path and turning this into a PR, I'd like to check this is a direction the project wants, and get input on a few open questions: reusing the existing d2t tensor/convention for this vs. an MTP-specific key (is there a reason EAGLE-3 and MTP shouldn't share it?); where the frequency map should come from in a real workflow (baked into the converter from a bundled/standard corpus vs. a separate user-supplied map file, closer to SGLang's --speculative-token-map); and whether the missing cross-architecture validation above is a blocker before this is worth a PR, or fine to defer to whoever has hardware for one of the matching architectures. Happy to do the convert_hf_to_gguf.py work and write it up properly if this is a direction worth pursuing.

Relevant log output

mul_mat_vec_q<(ggml_type)14, (int)1, (bool)0, (bool)0>  [baseline]
  Total Time: 407,053,180 ns   Instances: 655   Avg: 621,455.2 ns

mul_mat_vec_q<(ggml_type)12, (int)1, (bool)1, (bool)0>  [trimmed, same kernel slot]
  Total Time: 61,598,225 ns    Instances: 675   Avg: 91,256.6 ns

draft acceptance (temp=0, deterministic, baseline vs trimmed, identical prompt):
  baseline: draft 215/251 accepted, mean len 3.56
  trimmed:  draft 215/251 accepted, mean len 3.56   <- identical, confirms determinism preserved

Disclosure: The research and the writing were done with help from an LLM, I noticed the requirement not to use AI assistants heavily, and in case that's unacceptable, I will close the issue with understanding.
However, LLM usage done under provision was steered and direct, reviewed and approved by me and I'm taking ownership and responsability over it.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions