-
-
Notifications
You must be signed in to change notification settings - Fork 20.6k
[Feature] Universal speculative decoding for heterogeneous vocabularies (TLI) #38174
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ywang96
merged 21 commits into
vllm-project:main
from
wan-danfeng:feat/universal-draft-tli
Jul 2, 2026
Merged
Changes from 9 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
10cf5f8
[Feature] Universal speculative decoding for heterogeneous vocabulari…
wonderful199082 6896cbd
fix: raise ValueError when tokenizer lacks unk_token_id in VocabMapping
wan-danfeng 90d145c
fix: remove stray colon in universal_draft condition
wan-danfeng 3bc2b3b
vocab_mapping: fix unk fallback, dynamic space prefix, remove redunda…
wan-danfeng eb4b2f1
spec_decode: merge UniversalDraftModelProposer into DraftModelProposer
wan-danfeng 9c012e9
Remove redundant functions
wan-danfeng 996ffee
chore: address pre-commit warnings
wan-danfeng aa804d7
fix: add use_heterogeneous_vocab flag instead of universal_vocab method
wan-danfeng 53179f5
fix: remove redundant function
wan-danfeng 7b40ced
fix: pre-commit
wan-danfeng dc05e7f
fix: vocab mapping in probabilistic sampling
wan-danfeng d8161db
Merge branch 'main' into feat/universal-draft-tli
wan-danfeng 6ab7a00
Merge branch 'main' into feat/universal-draft-tli
wan-danfeng 03fa9d8
Update vllm/v1/worker/gpu_model_runner.py
wan-danfeng 4b82b77
fix: validate greedy draft sampling only when TLI is enabled and add …
wan-danfeng 6b568aa
doc: pre-commit check
wan-danfeng e595009
Merge branch 'main' into feat/universal-draft-tli
wan-danfeng 3f9a0f1
Merge branch 'main' into feat/universal-draft-tli
benchislett 1323c52
Merge branch 'main' into feat/universal-draft-tli
wan-danfeng 461d8dd
Merge branch 'main' into feat/universal-draft-tli
wan-danfeng a71ce73
Merge branch 'main' into feat/universal-draft-tli
wan-danfeng File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # SPDX-FileCopyrightText: Copyright contributors to the vLLM project | ||
|
|
||
| import pytest | ||
| from transformers import AutoTokenizer | ||
|
|
||
| from vllm.v1.spec_decode.vocab_mapping import _detect_space_prefix | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "model_name,expected_prefix", | ||
| [ | ||
| # BPE tokenizer (GPT-2 family) uses Ġ (U+0120) | ||
| ("HuggingFaceTB/SmolLM2-135M-Instruct", ("Ġ",)), | ||
| # SentencePiece tokenizer (LLaMA family) uses ▁ (U+2581) | ||
| ("TinyLlama/TinyLlama-1.1B-Chat-v1.0", ("▁",)), | ||
| # BPE tokenizer (Qwen family) uses Ġ (U+0120) | ||
| ("Qwen/Qwen2.5-0.5B-Instruct", ("Ġ",)), | ||
| ], | ||
| ) | ||
| def test_detect_space_prefix_real_tokenizers(model_name, expected_prefix): | ||
| tokenizer = AutoTokenizer.from_pretrained(model_name) | ||
| result = _detect_space_prefix(tokenizer) | ||
| assert result == expected_prefix, ( | ||
| f"{model_name}: expected {expected_prefix!r}, got {result!r}" | ||
| ) | ||
|
|
||
|
|
||
| def test_detect_space_prefix_fallback_on_failure(): | ||
| """When tokenizer lacks encode(), fall back to both known prefixes.""" | ||
|
|
||
| class BrokenTokenizer: | ||
| def encode(self, text, **kwargs): | ||
| raise RuntimeError("broken") | ||
|
|
||
| result = _detect_space_prefix(BrokenTokenizer()) | ||
| assert result == ("Ġ", "▁") | ||
|
|
||
|
|
||
| def test_detect_space_prefix_empty_encode(): | ||
| """When encode returns empty list, fall back.""" | ||
|
|
||
| class EmptyTokenizer: | ||
| def encode(self, text, **kwargs): | ||
| return [] | ||
|
|
||
| result = _detect_space_prefix(EmptyTokenizer()) | ||
| assert result == ("Ġ", "▁") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # SPDX-FileCopyrightText: Copyright contributors to the vLLM project | ||
| import torch | ||
|
|
||
| from vllm.logger import init_logger | ||
|
|
||
| logger = init_logger(__name__) | ||
|
|
||
|
|
||
| def _detect_space_prefix(tokenizer) -> tuple[str, ...]: | ||
|
benchislett marked this conversation as resolved.
|
||
| """Detect the space-prefix character(s) by tokenizing a literal space. | ||
|
|
||
| Different tokenizer families mark word-initial spaces differently: | ||
| BPE uses 'Ġ' (U+0120), SentencePiece uses '▁' (U+2581). Probing at | ||
| runtime avoids hardcoding assumptions and correctly handles mixed-family | ||
| pairs (e.g. BPE draft + SentencePiece target). | ||
| """ | ||
| try: | ||
| space_ids = tokenizer.encode(" a", add_special_tokens=False) | ||
| if space_ids: | ||
| tok_str = tokenizer.convert_ids_to_tokens(space_ids[0]) | ||
| if ( | ||
| isinstance(tok_str, str) | ||
| and len(tok_str) > 1 | ||
| and tok_str.endswith("a") | ||
| and tok_str[0] not in (" ", " ") | ||
| ): | ||
| return (tok_str[:-1],) | ||
| except Exception: | ||
| pass | ||
| # Fallback: cover both BPE (Ġ U+0120) and SentencePiece (▁ U+2581) | ||
| return ("\u0120", "\u2581") | ||
|
|
||
|
|
||
| def _normalize_token(token: str, space_prefixes: tuple[str, ...]) -> str: | ||
| for prefix in space_prefixes: | ||
| if token.startswith(prefix): | ||
| return " " + token[len(prefix) :] | ||
| return token | ||
|
|
||
|
|
||
| def _get_unk_token_id(tokenizer, role: str) -> int: | ||
| """Return a safe fallback token ID for out-of-intersection tokens. | ||
|
|
||
| Preferred: unk_token_id → eos_token_id → ValueError. | ||
| Checking with ``is not None`` is required because token ID 0 is a valid | ||
| (and common) unk ID on many tokenizers; using ``or 0`` would silently | ||
| mishandle those cases. | ||
| """ | ||
| unk = getattr(tokenizer, "unk_token_id", None) | ||
| if unk is not None: | ||
| return unk | ||
| eos = getattr(tokenizer, "eos_token_id", None) | ||
| if eos is not None: | ||
| logger.warning( | ||
| "VocabMapping: %s has no unk_token_id; " | ||
| "falling back to eos_token_id=%d for out-of-intersection tokens", | ||
| role, | ||
| eos, | ||
| ) | ||
| return eos | ||
| raise ValueError( | ||
| f"VocabMapping: {role} has neither unk_token_id nor eos_token_id; " | ||
| "cannot safely map out-of-intersection tokens" | ||
| ) | ||
|
|
||
|
|
||
| class VocabMapping: | ||
| def __init__( | ||
| self, | ||
| target_tokenizer, | ||
| draft_tokenizer, | ||
| target_vocab_size, | ||
| draft_vocab_size, | ||
| device, | ||
| ): | ||
| self.target_vocab_size = target_vocab_size | ||
| self.draft_vocab_size = draft_vocab_size | ||
| self.device = device | ||
| self.target_unk_token_id = _get_unk_token_id( | ||
| target_tokenizer, "target tokenizer" | ||
| ) | ||
| self.draft_unk_token_id = _get_unk_token_id(draft_tokenizer, "draft tokenizer") | ||
|
|
||
| target_prefixes = _detect_space_prefix(target_tokenizer) | ||
| draft_prefixes = _detect_space_prefix(draft_tokenizer) | ||
|
|
||
| target_vocab = target_tokenizer.get_vocab() | ||
| draft_vocab = draft_tokenizer.get_vocab() | ||
|
|
||
| target_normalized = {} | ||
| for token, tid in target_vocab.items(): | ||
| norm = _normalize_token(token, target_prefixes) | ||
| if norm not in target_normalized: | ||
| target_normalized[norm] = tid | ||
|
|
||
| draft_normalized = {} | ||
| for token, tid in draft_vocab.items(): | ||
| norm = _normalize_token(token, draft_prefixes) | ||
| if norm not in draft_normalized: | ||
| draft_normalized[norm] = tid | ||
|
|
||
| common_tokens = set(target_normalized.keys()) & set(draft_normalized.keys()) | ||
|
|
||
| draft_to_target = torch.full((draft_vocab_size,), -1, dtype=torch.long) | ||
| target_to_draft = torch.full((target_vocab_size,), -1, dtype=torch.long) | ||
| intersection_mask_draft = torch.zeros(draft_vocab_size, dtype=torch.bool) | ||
|
|
||
| for norm_token in common_tokens: | ||
| t_id = target_normalized[norm_token] | ||
| d_id = draft_normalized[norm_token] | ||
| if t_id < target_vocab_size and d_id < draft_vocab_size: | ||
| draft_to_target[d_id] = t_id | ||
| target_to_draft[t_id] = d_id | ||
| intersection_mask_draft[d_id] = True | ||
|
|
||
| self.draft_to_target_ids = draft_to_target.to(device) | ||
| self.target_to_draft_ids = target_to_draft.to(device) | ||
| self.intersection_mask_draft = intersection_mask_draft.to(device) | ||
| self.intersection_size = int(intersection_mask_draft.sum().item()) | ||
|
|
||
| logger.info( | ||
| "VocabMapping initialized: target_vocab=%d, draft_vocab=%d, " | ||
| "intersection=%d (%.1f%% of draft, %.1f%% of target)", | ||
| target_vocab_size, | ||
| draft_vocab_size, | ||
| self.intersection_size, | ||
| 100.0 * self.intersection_size / max(draft_vocab_size, 1), | ||
| 100.0 * self.intersection_size / max(target_vocab_size, 1), | ||
| ) | ||
|
|
||
| if self.intersection_size < 100: | ||
| logger.warning( | ||
| "Very small vocabulary intersection (%d tokens).", | ||
| self.intersection_size, | ||
| ) | ||
|
|
||
| def map_target_to_draft_ids(self, target_ids): | ||
| draft_ids = self.target_to_draft_ids[target_ids] # new tensor; no clone needed | ||
| missing = draft_ids == -1 | ||
| if missing.any(): | ||
| draft_ids[missing] = self.draft_unk_token_id | ||
| return draft_ids.to(target_ids.dtype) | ||
|
|
||
| def map_draft_to_target_ids(self, draft_ids): | ||
| target_ids = self.draft_to_target_ids[draft_ids] # new tensor; no clone needed | ||
| missing = target_ids == -1 | ||
| if missing.any(): | ||
| target_ids[missing] = self.target_unk_token_id | ||
| return target_ids.to(draft_ids.dtype) | ||
|
|
||
| def constrain_draft_logits(self, logits): | ||
| # masked_fill returns a new tensor; no clone needed | ||
| return logits.masked_fill(~self.intersection_mask_draft, float("-inf")) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.