[Feature] Universal speculative decoding for heterogeneous vocabularies (TLI) (#38174)

Signed-off-by: wan-danfeng <wandanfeng0802@gmail.com> Signed-off-by: Wonderful <wandanfeng0802@gmail.com> Co-authored-by: Wan_DF <wonderful199082@126.com> Co-authored-by: Benjamin Chislett <chislett.ben@gmail.com> Co-authored-by: Benjamin Chislett <bchislett@nvidia.com>

Wonderful committed Jul 2, 2026 at 16:34 UTC 3af878955935dd356182f6dd2ea9660acb1757be
8 files changed +364 -5
docs/features/speculative_decoding/README.md
+29
@@ -86,6 +86,7 @@ only apply to model-based methods such as `draft_model`, `mtp`, `eagle3`, and
86 | `parallel_drafting` | `boolean` | `false` | Enable parallel draft token generation. Only compatible with EAGLE and draft-model methods. |
87 | `rejection_sample_method` | `string` | `strict` | `strict`, `probabilistic`, or `synthetic`. |
88 | `synthetic_acceptance_rate` | `float` | `None` | Average acceptance rate to target when `rejection_sample_method` is `synthetic`. Valid range is `[0, 1]`. |
89 + | `use_heterogeneous_vocab` | `boolean` | `false` | Allow draft and target models with different vocabularies. Builds a token-level intersection at initialisation and constrains draft logits to shared tokens only. Only compatible with `method=draft_model`. Probabilistic draft sampling (`draft_sample_method='probabilistic'`) is not yet supported when this option is enabled. |
90
91 !!! note
92 Gemma 4 assistant checkpoints are handled as Gemma 4 MTP speculators, not
@@ -142,6 +143,33 @@ vllm serve <target-model> \
143 }'
144 ```
145
146 +#### Cross-Vocabulary Draft Models (TLI)
147 +
148 + By default, vLLM requires the draft and target models to share the same
149 + vocabulary. Setting `use_heterogeneous_vocab: true` enables the
150 + **Token-Level Intersection (TLI)** algorithm, which allows draft models
151 + from a different model family with a different tokenizer.
152 +
153 + At initialisation, vLLM builds a mapping between the two vocabularies by
154 + normalising token strings and computing their intersection. Draft logits are
155 + constrained to the shared tokens before sampling, and the sampled token IDs
156 + are translated to the target vocabulary before rejection sampling.
157 +
158 + ```python
159 + from vllm import LLM, SamplingParams
160 +
161 + llm = LLM(
162 + model="Qwen/Qwen3-8B",
163 + speculative_config={
164 + "method": "draft_model",
165 + "model": "HuggingFaceTB/SmolLM2-135M-Instruct",
166 + "num_speculative_tokens": 3,
167 + "use_heterogeneous_vocab": True,
168 + },
169 + gpu_memory_utilization=0.5,
170 + )
171 +```
172 +
173 ### Notes
174
175 - `--speculative-config` expects a JSON object on the CLI. In YAML config
@@ -153,6 +181,7 @@ vllm serve <target-model> \
181 - Internal fields such as `target_model_config`, `draft_model_config`,
182 `target_parallel_config`, `draft_parallel_config`, and `draft_load_config`
183 are populated by vLLM and are not intended to be set by users.
184 +- `use_heterogeneous_vocab` currently supports greedy draft sampling only. Probabilistic acceptance (temperature > 0 draft sampling) is not yet supported and will be added in a future release.
185
186 ## Lossless guarantees of Speculative Decoding
187
docs/features/speculative_decoding/draft_model.md
+28
@@ -76,6 +76,34 @@ The code used to request as completions as a client remains unchanged:
76 print(completion)
77 ```
78
79 +## Draft Model Method with heterogeneous vocabs
80 +
81 + By default, vLLM requires the draft and target models to share the same vocabulary. Setting `use_heterogeneous_vocab: true` enables the **Token-Level Intersection (TLI)** algorithm, which allows draft models from a different model family with a different tokenizer.
82 +
83 + Currently,`use_heterogeneous_vocab` currently requires `draft_sample_method='greedy'` (the default). Probabilistic draft sampling is not yet supported and will be added in a
84 + future release.
85 +
86 + ```python
87 + from vllm import LLM, SamplingParams
88 +
89 + llm = LLM(
90 + model="Qwen/Qwen3-8B",
91 + speculative_config={
92 + "method": "draft_model",
93 + "model": "HuggingFaceTB/SmolLM2-135M-Instruct",
94 + "num_speculative_tokens": 3,
95 + "use_heterogeneous_vocab": True,
96 + },
97 + gpu_memory_utilization=0.5,
98 + )
99 +outputs = llm.generate(prompts,sampling_params)
100 +
101 +for output in outputs:
102 + prompt = output.prompt
103 + generated_text = output.outputs[0].text
104 + print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}")
105 +```
106 +
107 !!! warning
108 Note: Please use `--speculative-config` to set all configurations related
109 to speculative decoding. The previous method of specifying the model
examples/features/speculative_decoding/spec_decode_offline.py
+2
@@ -72,6 +72,7 @@ def parse_args():
72 parser.add_argument("--max-num-seqs", type=int, default=None)
73 parser.add_argument("--parallel-drafting", action="store_true")
74 parser.add_argument("--allowed-local-media-path", type=str, default="")
75 + parser.add_argument("--use-heterogeneous-vocab", action="store_true")
76 return parser.parse_args()
77
78
@@ -135,6 +136,7 @@ def main(args):
136 "enforce_eager": args.enforce_eager,
137 "max_model_len": args.max_model_len,
138 "parallel_drafting": args.parallel_drafting,
139 + "use_heterogeneous_vocab": args.use_heterogeneous_vocab,
140 }
141 elif args.method == "mtp":
142 speculative_config = {
tests/v1/spec_decode/test_vocab_mapping.py new
+48
@@ -0,0 +1,48 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import pytest
5 +from transformers import AutoTokenizer
6 +
7 +from vllm.v1.spec_decode.vocab_mapping import _detect_space_prefix
8 +
9 +
10 +@pytest.mark.parametrize(
11 + "model_name,expected_prefix",
12 + [
13 + # BPE tokenizer (GPT-2 family) uses Ġ (U+0120)
14 + ("HuggingFaceTB/SmolLM2-135M-Instruct", ("Ġ",)),
15 + # SentencePiece tokenizer (LLaMA family) uses ▁ (U+2581)
16 + ("TinyLlama/TinyLlama-1.1B-Chat-v1.0", ("▁",)),
17 + # BPE tokenizer (Qwen family) uses Ġ (U+0120)
18 + ("Qwen/Qwen2.5-0.5B-Instruct", ("Ġ",)),
19 + ],
20 +)
21 +def test_detect_space_prefix_real_tokenizers(model_name, expected_prefix):
22 + tokenizer = AutoTokenizer.from_pretrained(model_name)
23 + result = _detect_space_prefix(tokenizer)
24 + assert result == expected_prefix, (
25 + f"{model_name}: expected {expected_prefix!r}, got {result!r}"
26 + )
27 +
28 +
29 +def test_detect_space_prefix_fallback_on_failure():
30 + """When tokenizer lacks encode(), fall back to both known prefixes."""
31 +
32 + class BrokenTokenizer:
33 + def encode(self, text, **kwargs):
34 + raise RuntimeError("broken")
35 +
36 + result = _detect_space_prefix(BrokenTokenizer())
37 + assert result == ("Ġ", "▁")
38 +
39 +
40 +def test_detect_space_prefix_empty_encode():
41 + """When encode returns empty list, fall back."""
42 +
43 + class EmptyTokenizer:
44 + def encode(self, text, **kwargs):
45 + return []
46 +
47 + result = _detect_space_prefix(EmptyTokenizer())
48 + assert result == ("Ġ", "▁")
vllm/config/speculative.py
+25 -2
@@ -139,6 +139,12 @@ class SpeculativeConfig:
139 O(2 * tp_size) per token. Only applies to greedy draft selection in
140 non-tree speculation."""
141
142 + use_heterogeneous_vocab: bool = False
143 + """Allow draft and target models to use different vocabularies.
144 + When enabled, builds a token-level intersection at init and constrains
145 + draft logits to shared tokens only (TLI algorithm). Requires
146 + method='draft_model'."""
147 +
148 # Ngram proposer configuration
149 prompt_lookup_max: int | None = Field(default=None, ge=1)
150 """Maximum size of ngram token window when using Ngram proposer, required
@@ -734,7 +740,11 @@ class SpeculativeConfig:
740 self.draft_model_config = ModelConfig(
741 model=self.model,
742 runner="draft",
737 - tokenizer=self.target_model_config.tokenizer,
743 + tokenizer=(
744 + self.model
745 + if self.use_heterogeneous_vocab
746 + else self.target_model_config.tokenizer
747 + ),
748 tokenizer_mode=self.target_model_config.tokenizer_mode,
749 trust_remote_code=self.target_model_config.trust_remote_code,
750 allowed_local_media_path=self.target_model_config.allowed_local_media_path,
@@ -1103,7 +1113,20 @@ class SpeculativeConfig:
1113 self.draft_parallel_config
1114 )
1115
1106 - self.verify_equal_vocab_size_if_draft_model()
1116 + if self.use_heterogeneous_vocab and not self.uses_draft_model():
1117 + raise ValueError(
1118 + "use_heterogeneous_vocab only works with method='draft_model'"
1119 + )
1120 +
1121 + if self.use_heterogeneous_vocab and self.draft_sample_method != "greedy":
1122 + raise ValueError(
1123 + "use_heterogeneous_vocab currently only supports greedy draft "
1124 + "sampling. Set draft_sample_method='greedy' (the default) or "
1125 + "omit it."
1126 + )
1127 +
1128 + if not self.use_heterogeneous_vocab:
1129 + self.verify_equal_vocab_size_if_draft_model()
1130 return self
1131
1132 def verify_equal_vocab_size_if_draft_model(self):
vllm/v1/spec_decode/draft_model.py
+28 -1
@@ -9,7 +9,9 @@ from vllm.config import VllmConfig
9 from vllm.config.utils import replace
10 from vllm.logger import init_logger
11 from vllm.model_executor.model_loader import get_model
12 +from vllm.tokenizers.registry import get_tokenizer
13 from vllm.v1.spec_decode.llm_base_proposer import SpecDecodeBaseProposer
14 +from vllm.v1.spec_decode.vocab_mapping import VocabMapping
15
16 logger = init_logger(__name__)
17
@@ -27,9 +29,34 @@ class DraftModelProposer(SpecDecodeBaseProposer):
29 pass_hidden_states_to_model=False,
30 runner=runner,
31 )
30 - self._raise_if_vocab_size_mismatch()
32 self._raise_if_draft_tp_mismatch()
33
34 + self.use_heterogeneous_vocab = self.speculative_config.use_heterogeneous_vocab
35 +
36 + spec = self.speculative_config
37 + if self.use_heterogeneous_vocab:
38 + # Heterogeneous vocabularies: build a VocabMapping to translate
39 + # token IDs between the two tokenizers and constrain draft logits
40 + # to the intersection so rejection sampling stays lossless.
41 + target_tokenizer = get_tokenizer(
42 + spec.target_model_config.tokenizer,
43 + trust_remote_code=spec.target_model_config.trust_remote_code,
44 + )
45 + draft_tokenizer = get_tokenizer(
46 + spec.draft_model_config.model,
47 + trust_remote_code=spec.draft_model_config.trust_remote_code,
48 + )
49 + self.vocab_mapping: VocabMapping | None = VocabMapping(
50 + target_tokenizer=target_tokenizer,
51 + draft_tokenizer=draft_tokenizer,
52 + target_vocab_size=spec.target_model_config.get_vocab_size(),
53 + draft_vocab_size=spec.draft_model_config.get_vocab_size(),
54 + device=device,
55 + )
56 + else:
57 + self._raise_if_vocab_size_mismatch()
58 + self.vocab_mapping = None
59 +
60 def _raise_if_vocab_size_mismatch(self):
61 self.speculative_config.verify_equal_vocab_size_if_draft_model()
62
vllm/v1/spec_decode/llm_base_proposer.py
+50 -2
@@ -2,7 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import dataclasses
4 from importlib.util import find_spec
5 -from typing import Any, cast
5 +from typing import TYPE_CHECKING, Any, cast
6
7 import numpy as np
8 import torch
@@ -15,6 +15,10 @@ from vllm.config import (
15 get_layers_from_vllm_config,
16 replace,
17 )
18 +
19 +if TYPE_CHECKING:
20 + from vllm.v1.spec_decode.vocab_mapping import VocabMapping
21 +
22 from vllm.distributed.eplb.eplb_state import EplbState
23 from vllm.distributed.parallel_state import get_pp_group
24 from vllm.forward_context import set_forward_context
@@ -125,6 +129,11 @@ class SpecDecodeBaseProposer:
129 )
130 self.use_fp64_gumbel = vllm_config.model_config.use_fp64_gumbel
131
132 + self.use_heterogeneous_vocab: bool = (
133 + self.speculative_config.use_heterogeneous_vocab
134 + )
135 + self.vocab_mapping: VocabMapping | None = None
136 +
137 self.max_batch_size = vllm_config.scheduler_config.max_num_seqs
138 self.max_num_tokens = vllm_config.scheduler_config.max_num_batched_tokens
139 self.token_arange_np = np.arange(self.max_num_tokens, dtype=np.int32)
@@ -419,6 +428,12 @@ class SpecDecodeBaseProposer:
428 """Greedy-sample draft tokens from hidden states."""
429 if self.use_local_argmax_reduction:
430 return self.model.get_top_tokens(hidden_states)
431 + if self.use_heterogeneous_vocab:
432 + logits = self.model.compute_logits(hidden_states)
433 + assert self.vocab_mapping is not None
434 + logits = self.vocab_mapping.constrain_draft_logits(logits)
435 + draft_token_ids = logits.argmax(dim=-1)
436 + return self.vocab_mapping.map_draft_to_target_ids(draft_token_ids)
437 return self.model.compute_logits(hidden_states).argmax(dim=-1)
438
439 def _sample_from_logits(
@@ -457,7 +472,28 @@ class SpecDecodeBaseProposer:
472 if not self._enable_probabilistic_draft_probs or sampling_metadata.all_greedy:
473 return self._greedy_sample(hidden_states), None
474 logits = self.model.compute_logits(hidden_states)
460 - return self._sample_from_logits(logits, sampling_metadata)
475 + if self.use_heterogeneous_vocab:
476 + assert self.vocab_mapping is not None
477 + logits = self.vocab_mapping.constrain_draft_logits(logits)
478 + draft_token_ids, draft_probs = self._sample_from_logits(
479 + logits, sampling_metadata
480 + )
481 + if self.use_heterogeneous_vocab:
482 + assert self.vocab_mapping is not None
483 + draft_token_ids = self.vocab_mapping.map_draft_to_target_ids(
484 + draft_token_ids
485 + )
486 + # Config validation ensures draft_sample_method == "greedy" when
487 + # use_heterogeneous_vocab is True, so this branch should never be
488 + # reached. Kept as a safety fallback until probabilistic rejection
489 + # sampling with heterogeneous vocabularies is implemented.
490 + # TODO: remap draft_probs to target-vocab space for lossless
491 + # probabilistic rejection sampling with heterogeneous vocabularies.
492 + assert draft_probs is None, (
493 + "probabilistic draft sampling is not supported with "
494 + "use_heterogeneous_vocab"
495 + )
496 + return draft_token_ids, draft_probs
497
498 def take_last_draft_probs(self) -> torch.Tensor | None:
499 return self._last_draft_probs
@@ -647,6 +683,11 @@ class SpecDecodeBaseProposer:
683 # tensor.argmax() returns int64 by default.
684 input_ids = draft_token_ids_list[-1].int()
685
686 + if self.use_heterogeneous_vocab:
687 + # Map target token IDs to draft vocab space (TLI algorithm)
688 + assert self.vocab_mapping is not None
689 + input_ids = self.vocab_mapping.map_target_to_draft_ids(input_ids)
690 +
691 if not self.constant_draft_positions:
692 positions = self._update_positions_dependent_metadata(
693 positions,
@@ -785,6 +826,13 @@ class SpecDecodeBaseProposer:
826 cad: CommonAttentionMetadata,
827 num_rejected_tokens_gpu: torch.Tensor | None,
828 ) -> tuple[int, torch.Tensor, CommonAttentionMetadata]:
829 + # Map target token IDs to draft vocab space (TLI algorithm)
830 + if self.use_heterogeneous_vocab:
831 + assert self.vocab_mapping is not None
832 + target_token_ids = self.vocab_mapping.map_target_to_draft_ids(
833 + target_token_ids
834 + )
835 + next_token_ids = self.vocab_mapping.map_target_to_draft_ids(next_token_ids)
836 if not self.needs_extra_input_slots:
837 # Default EAGLE pathway: no reshaping of input tensors needed.
838 # Simply rotate the input ids and leave the positions unchanged,
vllm/v1/spec_decode/vocab_mapping.py new
+154
@@ -0,0 +1,154 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import torch
4 +
5 +from vllm.logger import init_logger
6 +
7 +logger = init_logger(__name__)
8 +
9 +
10 +def _detect_space_prefix(tokenizer) -> tuple[str, ...]:
11 + """Detect the space-prefix character(s) by tokenizing a literal space.
12 +
13 + Different tokenizer families mark word-initial spaces differently:
14 + BPE uses 'Ġ' (U+0120), SentencePiece uses '▁' (U+2581). Probing at
15 + runtime avoids hardcoding assumptions and correctly handles mixed-family
16 + pairs (e.g. BPE draft + SentencePiece target).
17 + """
18 + try:
19 + space_ids = tokenizer.encode(" a", add_special_tokens=False)
20 + if space_ids:
21 + tok_str = tokenizer.convert_ids_to_tokens(space_ids[0])
22 + if (
23 + isinstance(tok_str, str)
24 + and len(tok_str) > 1
25 + and tok_str.endswith("a")
26 + and tok_str[0] not in (" ", " ")
27 + ):
28 + return (tok_str[:-1],)
29 + except Exception:
30 + pass
31 + # Fallback: cover both BPE (Ġ U+0120) and SentencePiece (▁ U+2581)
32 + return ("\u0120", "\u2581")
33 +
34 +
35 +def _normalize_token(token: str, space_prefixes: tuple[str, ...]) -> str:
36 + for prefix in space_prefixes:
37 + if token.startswith(prefix):
38 + return " " + token[len(prefix) :]
39 + return token
40 +
41 +
42 +def _get_unk_token_id(tokenizer, role: str) -> int:
43 + """Return a safe fallback token ID for out-of-intersection tokens.
44 +
45 + Preferred: unk_token_id → eos_token_id → ValueError.
46 + Checking with ``is not None`` is required because token ID 0 is a valid
47 + (and common) unk ID on many tokenizers; using ``or 0`` would silently
48 + mishandle those cases.
49 + """
50 + unk = getattr(tokenizer, "unk_token_id", None)
51 + if unk is not None:
52 + return unk
53 + eos = getattr(tokenizer, "eos_token_id", None)
54 + if eos is not None:
55 + logger.warning(
56 + "VocabMapping: %s has no unk_token_id; "
57 + "falling back to eos_token_id=%d for out-of-intersection tokens",
58 + role,
59 + eos,
60 + )
61 + return eos
62 + raise ValueError(
63 + f"VocabMapping: {role} has neither unk_token_id nor eos_token_id; "
64 + "cannot safely map out-of-intersection tokens"
65 + )
66 +
67 +
68 +class VocabMapping:
69 + def __init__(
70 + self,
71 + target_tokenizer,
72 + draft_tokenizer,
73 + target_vocab_size,
74 + draft_vocab_size,
75 + device,
76 + ):
77 + self.target_vocab_size = target_vocab_size
78 + self.draft_vocab_size = draft_vocab_size
79 + self.device = device
80 + self.target_unk_token_id = _get_unk_token_id(
81 + target_tokenizer, "target tokenizer"
82 + )
83 + self.draft_unk_token_id = _get_unk_token_id(draft_tokenizer, "draft tokenizer")
84 +
85 + target_prefixes = _detect_space_prefix(target_tokenizer)
86 + draft_prefixes = _detect_space_prefix(draft_tokenizer)
87 +
88 + target_vocab = target_tokenizer.get_vocab()
89 + draft_vocab = draft_tokenizer.get_vocab()
90 +
91 + target_normalized = {}
92 + for token, tid in target_vocab.items():
93 + norm = _normalize_token(token, target_prefixes)
94 + if norm not in target_normalized:
95 + target_normalized[norm] = tid
96 +
97 + draft_normalized = {}
98 + for token, tid in draft_vocab.items():
99 + norm = _normalize_token(token, draft_prefixes)
100 + if norm not in draft_normalized:
101 + draft_normalized[norm] = tid
102 +
103 + common_tokens = set(target_normalized.keys()) & set(draft_normalized.keys())
104 +
105 + draft_to_target = torch.full((draft_vocab_size,), -1, dtype=torch.long)
106 + target_to_draft = torch.full((target_vocab_size,), -1, dtype=torch.long)
107 + intersection_mask_draft = torch.zeros(draft_vocab_size, dtype=torch.bool)
108 +
109 + for norm_token in common_tokens:
110 + t_id = target_normalized[norm_token]
111 + d_id = draft_normalized[norm_token]
112 + if t_id < target_vocab_size and d_id < draft_vocab_size:
113 + draft_to_target[d_id] = t_id
114 + target_to_draft[t_id] = d_id
115 + intersection_mask_draft[d_id] = True
116 +
117 + self.draft_to_target_ids = draft_to_target.to(device)
118 + self.target_to_draft_ids = target_to_draft.to(device)
119 + self.intersection_mask_draft = intersection_mask_draft.to(device)
120 + self.intersection_size = int(intersection_mask_draft.sum().item())
121 +
122 + logger.info(
123 + "VocabMapping initialized: target_vocab=%d, draft_vocab=%d, "
124 + "intersection=%d (%.1f%% of draft, %.1f%% of target)",
125 + target_vocab_size,
126 + draft_vocab_size,
127 + self.intersection_size,
128 + 100.0 * self.intersection_size / max(draft_vocab_size, 1),
129 + 100.0 * self.intersection_size / max(target_vocab_size, 1),
130 + )
131 +
132 + if self.intersection_size < 100:
133 + logger.warning(
134 + "Very small vocabulary intersection (%d tokens).",
135 + self.intersection_size,
136 + )
137 +
138 + def map_target_to_draft_ids(self, target_ids):
139 + draft_ids = self.target_to_draft_ids[target_ids] # new tensor; no clone needed
140 + missing = draft_ids == -1
141 + if missing.any():
142 + draft_ids[missing] = self.draft_unk_token_id
143 + return draft_ids.to(target_ids.dtype)
144 +
145 + def map_draft_to_target_ids(self, draft_ids):
146 + target_ids = self.draft_to_target_ids[draft_ids] # new tensor; no clone needed
147 + missing = target_ids == -1
148 + if missing.any():
149 + target_ids[missing] = self.target_unk_token_id
150 + return target_ids.to(draft_ids.dtype)
151 +
152 + def constrain_draft_logits(self, logits):
153 + # masked_fill returns a new tensor; no clone needed
154 + return logits.masked_fill(~self.intersection_mask_draft, float("-inf"))