Prhub

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

原始 PR 作者 wan-danfeng 合并时间 2026-07-02 16:34 文件变更 8 提交数 21 评论 40 代码增减 +364 / -5

执行摘要

实现异质词汇表间的通用投机解码 (TLI)

当前 vLLM 要求草稿模型与目标模型共享完全相同的词汇表,严重限制草稿模型选择,通常需从头训练专门草稿。TLI 算法(ICML 2025 oral)通过 token 级别交集实现异质词汇表间无损投机解码。例如 Qwen2.5 家族词汇重叠 99.8%,TLI 可达约 61% 草稿接受率。关联 issue #38173。

建议重点关注 vocab_mapping.py 中的核心算法(空间前缀探测、归一化、交集构建),以及 speculative.py 中的配置验证逻辑。对于考虑使用异质草稿模型的团队,此 PR 是关键里程碑。推荐精读源码和测试。

讨论亮点

@benchislett 反对独立 Proposer 类型,要求使用标志集成减少维护。作者重构为配置标志方式。

@gemini-code-assist[bot] 指出 unk_token_id 默认 0 对 Llama 错误,建议改抛异常。作者实现阶梯 fallback(unk → eos → ValueError)。

@jmamou (论文共同作者) 提出重复 token 去重、bonus token round-trip 等差距。去重已修复;bonus 映射计划后续 PR。

@benchislett 要求禁止概率采样下使用 TLI 以防越界。配置层强制 draft_sample_method='greedy',采样路径加断言。

@benchislett 要求为 _detect_space_prefix 添加真实 tokenizer 测试。已添加参数化测试。

实现拆解

1. 配置层扩展

在 vllm/config/speculative.py 的 SpeculativeConfig 中添加 use_heterogeneous_vocab: bool 字段。__post_init__ 中启用时,为 draft 模型设置独立 tokenizer。_verify_args 校验:仅当 method='draft_model' 且 draft_sample_method='greedy' 时可用。

2. 词汇映射模块

新增 vllm/v1/spec_decode/vocab_mapping.py,定义 VocabMapping。通过 _detect_space_prefix 动态检测 BPE/ SentencePiece 空格前缀,_normalize_token 归一化。计算交集后生成三个张量:draft_to_target_ids、target_to_draft_ids、intersection_mask_draft。constrain_draft_logits 将非交集 logits 置为 -inf。

3. 集成 DraftModelProposer

在 DraftModelProposer.__init__(vllm/v1/spec_decode/draft_model.py)中,当标志开启时加载 tokenizer 并初始化 VocabMapping,跳过词汇大小检查;否则保持原逻辑。

4. 基类采样路径适配

在 SpecDecodeBaseProposer(llm_base_proposer.py)中新增相关属性和分支。_greedy_sample 中应用约束和 ID 映射;_sample_draft_tokens 的概率路径同样约束但断言 draft_probs 为 None。

5. 测试与文档

新增 test_vocab_mapping.py,测试 _detect_space_prefix 在真实 tokenizer 上的行为及异常回退。更新示例脚本和文档(README.md、draft_model.md)说明 TLI 用法。

文件 模块 状态 重要度
vllm/v1/spec_decode/vocab_mapping.py 投机解码 added 8.92
vllm/v1/spec_decode/draft_model.py 投机解码 modified 6.92
vllm/v1/spec_decode/llm_base_proposer.py 投机解码 modified 6.91
vllm/config/speculative.py 配置层 modified 6.4
tests/v1/spec_decode/test_vocab_mapping.py 测试 added 6.91
examples/features/speculative_decoding/spec_decode_offline.py 示例 modified 4.49
docs/features/speculative_decoding/README.md 文档 modified 3.26
docs/features/speculative_decoding/draft_model.md 文档 modified 3.46

关键符号

VocabMapping.__init__ _detect_space_prefix _normalize_token _get_unk_token_id constrain_draft_logits map_draft_to_target_ids DraftModelProposer.__init__ SpecDecodeBaseProposer._greedy_sample SpecDecodeBaseProposer._sample_draft_tokens SpeculativeConfig._verify_args

关键源码片段

vllm/v1/spec_decode/vocab_mapping.py core-logic

核心新文件,实现 TLI 算法的词汇映射逻辑,包括空间前缀探测、归一化、交集构建和 logits 约束。

# 探测 BPE/ SentencePiece 空格前缀
# BPE 使用 Ġ (U+0120),SentencePiece 使用 ▁ (U+2581)
def _detect_space_prefix(tokenizer) -> tuple[str, ...]:
    try:
        # 编码 " a" 来发现前缀字符
        ids = tokenizer.encode(" a", add_special_tokens=False)
        if ids:
            tok = tokenizer.convert_ids_to_tokens(ids[0])
            if isinstance(tok, str) and len(tok) > 1 and tok.endswith("a") and tok[0] not in (" ", "\t"):
                return (tok[:-1],)
    except Exception:
        pass
    # fallback: 覆盖 BPE 和 SentencePiece
    return ("\u0120", "\u2581")
​
def _normalize_token(token: str, prefixes: tuple[str, ...]) -> str:
    """去除空格前缀,使得不同 tokenizer 中同一词的字面表示一致。"""
    for p in prefixes:
        if token.startswith(p):
            return " " + token[len(p):]
    return token
​
def _get_unk_token_id(tokenizer, role: str) -> int:
    """
    获取 UNK token ID,优先 unk_token_id;其次 eos_token_id;最后抛 ValueError。
    使用 is not None 避免误判 token ID 0。
    """
    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 无 unk_token_id,回退到 eos_token_id=%d", role, eos)
        return eos
    raise ValueError(f"VocabMapping: {role} 既无 unk_token_id 也无 eos_token_id")
​
class VocabMapping:
    def __init__(self, target_tokenizer, draft_tokenizer, target_vocab_size, draft_vocab_size, device):
        self.target_unk = _get_unk_token_id(target_tokenizer, "target")
        self.draft_unk = _get_unk_token_id(draft_tokenizer, "draft")
​
        tgt_prefix = _detect_space_prefix(target_tokenizer)
        drf_prefix = _detect_space_prefix(draft_tokenizer)
​
        # 归一化并去重
        tgt_norm = {}
        for tok, tid in target_tokenizer.get_vocab().items():
            norm = _normalize_token(tok, tgt_prefix)
            if norm not in tgt_norm:
                tgt_norm[norm] = tid
​
        drf_norm = {}
        for tok, tid in draft_tokenizer.get_vocab().items():
            norm = _normalize_token(tok, drf_prefix)
            if norm not in drf_norm:
                drf_norm[norm] = tid
​
        # 计算归一化后的词汇交集
        common = set(tgt_norm.keys()) & set(drf_norm.keys())
​
        # 构建映射张量(-1 表示无映射)
        drf2tgt = torch.full((draft_vocab_size,), -1, dtype=torch.long)
        tgt2drf = torch.full((target_vocab_size,), -1, dtype=torch.long)
        mask = torch.zeros(draft_vocab_size, dtype=torch.bool)
​
        for norm in common:
            t_id = tgt_norm[norm]
            d_id = drf_norm[norm]
            if t_id < target_vocab_size and d_id < draft_vocab_size:
                drf2tgt[d_id] = t_id
                tgt2drf[t_id] = d_id
                mask[d_id] = True
​
        self.draft_to_target = drf2tgt.to(device)
        self.target_to_draft = tgt2drf.to(device)
        self.intersection_mask = mask.to(device)
        self.intersection_size = int(mask.sum().item())
​
    def constrain_draft_logits(self, logits: torch.Tensor) -> torch.Tensor:
        """将非交集位置的 logits 置为 -inf,保证 draft 只采样交集内 token。"""
        logits[:, ~self.intersection_mask] = float('-inf')
        return logits
​
    def map_draft_to_target_ids(self, draft_ids: torch.Tensor) -> torch.Tensor:
        """将 draft token ID 映射到 target token ID,未映射的替换为 target UNK。"""
        mapped = self.draft_to_target[draft_ids]
        mapped[mapped == -1] = self.target_unk
        return mapped
vllm/v1/spec_decode/draft_model.py data-contract

集成 TLI 的关键入口点,在 DraftModelProposer.__init__ 中根据标志条件初始化 VocabMapping。

from vllm.tokenizers.registry import get_tokenizer
from vllm.v1.spec_decode.vocab_mapping import VocabMapping
​
class DraftModelProposer(SpecDecodeBaseProposer):
    def __init__(self, vllm_config, device, runner=None):
        super().__init__(vllm_config, device, pass_hidden_states_to_model=False, runner=runner)
        self._raise_if_draft_tp_mismatch()
​
        self.use_heterogeneous_vocab = self.speculative_config.use_heterogeneous_vocab
​
        spec = self.speculative_config
        if self.use_heterogeneous_vocab:
            # 异质词汇表:加载两个 tokenizer 并构建映射
            target_tokenizer = get_tokenizer(
                spec.target_model_config.tokenizer,
                trust_remote_code=spec.target_model_config.trust_remote_code,
            )
            draft_tokenizer = get_tokenizer(
                spec.draft_model_config.model,
                trust_remote_code=spec.draft_model_config.trust_remote_code,
            )
            self.vocab_mapping = VocabMapping(
                target_tokenizer=target_tokenizer,
                draft_tokenizer=draft_tokenizer,
                target_vocab_size=spec.target_model_config.get_vocab_size(),
                draft_vocab_size=spec.draft_model_config.get_vocab_size(),
                device=device,
            )
        else:
            # 同质词汇表:继续原有的词汇大小一致性检查
            self._raise_if_vocab_size_mismatch()
            self.vocab_mapping = None
​
        # ... 其余初始化保持不变
vllm/v1/spec_decode/llm_base_proposer.py dependency-wiring

在基类的 _greedy_sample 和 _sample_draft_tokens 中添加 TLI 约束和映射,是推理路径的关键改动。

def _greedy_sample(self, hidden_states: torch.Tensor) -> torch.Tensor:
    if self.use_local_argmax_reduction:
        return self.model.get_top_tokens(hidden_states)
​
    if self.use_heterogeneous_vocab:
        logits = self.model.compute_logits(hidden_states)
        assert self.vocab_mapping is not None
        # 将非交集 logits 置为 -inf
        logits = self.vocab_mapping.constrain_draft_logits(logits)
        draft_token_ids = logits.argmax(dim=-1)
        # 将 draft ID 映射到 target ID 空间
        return self.vocab_mapping.map_draft_to_target_ids(draft_token_ids)
​
    return self.model.compute_logits(hidden_states).argmax(dim=-1)
​
def _sample_draft_tokens(self, hidden_states, sampling_metadata):
    if not self._enable_probabilistic_draft_probs or sampling_metadata.all_greedy:
        return self._greedy_sample(hidden_states), None
​
    logits = self.model.compute_logits(hidden_states)
​
    if self.use_heterogeneous_vocab:
        assert self.vocab_mapping is not None
        logits = self.vocab_mapping.constrain_draft_logits(logits)
        draft_token_ids, draft_probs = self._sample_from_logits(logits, sampling_metadata)
        draft_token_ids = self.vocab_mapping.map_draft_to_target_ids(draft_token_ids)
        # 配置已强制 greedy,此断言为安全网
        assert draft_probs is None, "TLI 不支持概率 draft 采样"
        return draft_token_ids, draft_probs
​
    return self._sample_from_logits(logits, sampling_metadata)

评论区精华

使用配置标志而非新 Proposer 类型 设计

@benchislett 反对独立 Proposer 增加维护负担,建议用标志集成。

结论:作者重构移除独立 proposer,改为在 DraftModelProposer 中条件分支。 · 已解决

UNK token ID fallback 策略 正确性

@gemini-code-assist[bot] 指出默认 0 对 Llama 等模型错误。

结论:实现 unk → eos → ValueError 阶梯逻辑。 · 已解决

概率采样场景的 TLI 安全性 正确性

@depthfirst-app[bot] 指出非 greedy 路径未应用约束可能导致越界。

结论:配置验证强制 draft_sample_method='greedy';采样路径加断言。 · 已解决

词汇映射的重复 token 去重 正确性

@jmamou 指出未对归一化后的重复 token 去重。

结论:在 VocabMapping 中添加 if norm not in normalized 去重。 · 已解决

空间前缀检测需要测试覆盖 测试

@benchislett 要求为 _detect_space_prefix 添加测试。

结论:添加 test_vocab_mapping.py,覆盖真实 tokenizer 和异常回退。 · 已解决

Bonus token 文本级 round-trip 映射 设计

@jmamou 建议将 bonus token 解码为文本再重新编码,而非映射到 UNK。

结论:未在当前 PR 实现,计划后续 PR。 · partially resolved

风险与影响

  • 词汇探测不确定性:_detect_space_prefix 依赖 tokenize " a",某些 tokenizer 可能返回意外结果导致 fallback 到双重前缀;若实际前缀不在 fallback 列表则映射错误。测试覆盖主流模型家族。
  • UNK 回退风险:当 unk_token_id 缺失时回退到 eos_token_id,eos 可能不适宜作为未知 token 占位,但仅影响 bonus token,影响面小。
  • 配置校验完整性:若用户误配非 greedy 或非 draft_model 方法,会得到明确错误提示,风险低。
  • 初始化性能开销:需加载两个 tokenizer 并遍历完整词汇表(O(V)),大词汇表(200k)有数秒延迟,但为一次性开销。
  • 回归风险:功能通过标志控制默认关闭,不影响现有路径;但修改了 DraftModelProposer 和基类热路径,分支和断言有极小性能影响,需维护双路径同步。
  • 用户影响:用户可自由选择词汇重叠的小模型作为草稿,无需训练或词汇对齐。仅需在 speculative config 设置 use_heterogeneous_vocab=True,极大降低使用投机解码门槛。
  • 系统影响:初始化解锁后占用少量 GPU 内存(映射张量),推理无额外开销。
  • 团队维护影响:功能嵌入 DraftModelProposer,后续对 init 或采样路径的修改需兼容 TLI 分支,但通过标志和统一配置字段可控。
核心路径变更 配置验证依赖 Greedy 采样限制 UNK 回退语义风险 词汇探测 fallback 不确定性

关联 Issue

#38173 [Feature]: Universal Speculative Decoding for Heterogeneous Vocabularies (TLI / Token-Level Intersection)

完整报告

参与讨论