Research Stage
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:
- 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.
- 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.
Research Stage
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: ahot_token_idtensor gathers the draft'slm_headrows 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 ad2t("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-infelsewhere) viaggml_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, ormodel.outputwhen that's absent) to the trunk's fulln_vocab.Hypothesis
d2tis architecture-agnostic infrastructure, not EAGLE-3-specific. If a standalone MTP-only draft GGUF ships ad2ttensor, 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 (commit047bfa508).The change is ~30 lines, gated so it's a no-op for every existing GGUF:
load_arch_tensors: if the draft is MTP-only (mtp_only) and ad2ttensor is present in the GGUF, sizeoutput.weightfromd2t->ne[0]instead ofn_vocab, and loadd2tvia the sameLLM_TENSOR_D2Ttensor name EAGLE-3 already uses.build_lora_mm(head_w, ...)): ifmodel.d2tis set, scatter the compressed logits back to full-vocab shape withggml_set_rows, identical to the existing code ineagle3.cpp.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 zerod2t-aware code, for the same reason).A draft GGUF with a trimmed
output.weight+ ad2ttensor was produced offline withgguf-py(gather theoutput.weightrows by a frequency-ranked token-ID list, store that list asd2t). Noconvert_hf_to_gguf.pyproducer 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:
codeparrot/codeparrot-clean-valid,codeparrot/github-jupyter-code-to-text,Salesforce/wikitextwikitext-103,allenai/c4en,HuggingFaceFW/fineweb-edu), tokenized with the target model's own tokenizer, top-32768 by raw frequency.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):
mul_mat_vec_q(Q6_K, draft LM-head, batch=1)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):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, andbailingmoe2.cppdeclare the identicalnextn.shared_head_head/{n_embd, n_vocab}tensor pattern this patch targets inqwen35.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 reportsnum_nextn_predict_layers: 1in its sourceconfig.json, but converts to llama.cpp'sdeepseek2architecture, 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.pyproducer 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 existingd2ttensor/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 theconvert_hf_to_gguf.pywork and write it up properly if this is a direction worth pursuing.Relevant log output
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.