Appearance
第12章 投机解码:以小博大
"It is better to be approximately right than precisely wrong." — Warren Buffett
本章要点
- 理解 LLM Decode 是 memory-bound 的根本瓶颈:每步要把 140 GB 模型权重从 HBM 搬一遍,算力 TensorCore 在空转
- 掌握投机解码的数学等价性——拒绝采样证明了输出分布和原始 decode 完全相同,不是近似
- 读懂四种 Proposer 的源码:Draft Model / EAGLE / n-gram / MTP (multi-token prediction),各自的适用场景
- 看懂
num_speculative_tokens=k时,一次 forward 到底塞了什么进 GPU:k+1 个 position、cu_seqlens_q 的混合构造、attention mask 的特殊处理 - 学会推导加速比闭式公式: 及其指导意义
- 理解"batch 大了投机解码反而变慢"这个反直觉现象:draft cost 不再可忽略、budget 抢占 + decode 请求变多
- 识别三类高接受率场景(代码生成、翻译、长文 summarization)和三类低接受率场景(创意写作、角色扮演、非英文 LoRA 模型)
- 拿到 V1 spec decode 的完整配置模板与调优建议
12.1 自回归枷锁:memory-bound 的根本代价
要理解投机解码为什么能工作,得先理解它在解决什么——LLM Decode 是被内存带宽而不是算力拖慢的。
12.1.1 一次 decode step 的时间分解
跑 Llama-2-70B FP16 (140 GB 权重) 在 A100-80GB (2 TB/s HBM 带宽) 上做 decode。单请求、context_len=1024:
| 操作 | 时间 | 占比 |
|---|---|---|
| 读取所有模型权重一次 | 140 GB / 2 TB/s = 70 ms | 80% |
| 读取 KV Cache (1024 tokens × 12 MB/token) | 12 MB / 2 TB/s = 6 ms | 7% |
| 实际算术运算(FLOPs) | 300 GFLOPS / 312 TFLOPS = < 1 ms | 1% |
| 其他(kernel launch, tokenization) | 10 ms | 12% |
decode 总时长 ≈ 87 ms。其中 GPU 的 TensorCore 算力实际只利用了 1%——99% 的时间在"把数据从 HBM 搬到 SRAM"。
这是一个令人沮丧的事实:你花 20 万买了一张 A100,它的 312 TFLOPS TensorCore 算力在 Decode 时几乎全空转。
12.1.2 Batch 能救一点,但不多
提高 batch size 可以分摊权重读取成本——一次把 140 GB 读进来可以同时处理 N 个请求。但:
- batch_size=1 → 单请求 87 ms/token
- batch_size=8 → 8 个请求共享权重读取,单步 ~95 ms(每请求 12 ms)
- batch_size=64 → ~140 ms(每请求 2.2 ms),开始逼近 GPU 计算上限
- batch_size=256 → 进入 compute-bound,加速饱和
问题:单个请求在低并发下依然慢得令人发指。一个人问 ChatGPT 一句话,希望 50 tokens/s 的流畅感——87 ms/token 意味着 11.5 tokens/s,体验不合格。
12.1.3 投机解码的直觉:反正要读一遍权重,顺便多验几个 token
核心洞察:既然每步都要读一遍 140 GB 权重,为什么不在这一次读取中"顺便"验证多个候选 token?
一次 forward pass 喂进去 k 个 token,GPU 输出 k 个位置的 logits——FLOPs 是 k 倍,但权重只读一次。由于 decode 是 memory-bound,时间几乎不增加:
- 1 个 token 的 decode:87 ms
- 5 个 token 的 batched forward:~95 ms(多 10%,因为 FLOPs 稍增)
- 这多出的 10% 时间,换来了一次性验证 5 个位置
如果 5 个候选 token 里能有 3 个被接受,那这一 step 等于 3 个 decode 的效果。吞吐就提升了 3 倍。
这就是"以小博大"——用一个廉价的 draft(小模型 / 预测头 / n-gram)产生候选,用大模型一次 forward 并行验证。如果 draft 猜得准,加速立竿见影。
12.2 拒绝采样:为什么投机解码是精确的而不是近似的
一个常见的误解:投机解码是"用小模型近似大模型"。错。投机解码产出的 token 分布与直接用大模型采样完全一致。这是通过一个叫"拒绝采样(Rejection Sampling / Speculative Sampling)"的精妙算法保证的。
12.2.1 算法原理
设大模型的 token 分布为 ,草稿模型的分布为 。
对每个候选位置:
- 草稿阶段:从 中采样一个 token
- 验证阶段:大模型计算
- 接受/拒绝:
- 如果 (大模型比草稿更看好这个 token),无条件接受
- 否则,以概率 接受
- 拒绝时,从修正分布 中重新采样
12.2.2 数学证明(接受的 token 分布 = )
考虑任一 token 被最终采纳为输出的概率:
\Pr(\text{output} = x) = \underbrace{q(x) \cdot \min\left(1, \frac{p(x)}{q(x)}\right)}_{\text{作为候选被接受}} + \underbrace{(1 - A) \cdot p'(x)}_{\text{上一个被拒后作为重采样}}其中 是平均接受概率。
化简第一项:
化简第二项:
注意 ,所以第二项 = 。
合起来:
- 如果 := ✓
- 如果 := ✓
两种情况下都等于 。QED.
12.2.3 直觉解释
小模型和大模型的概率分布如果"大致一致",小模型提议的候选 token 通常会被接受(因为 的概率高)。只有当小模型"错判"(给了一个大模型不看好的 token 高概率),才会被拒绝——被拒绝后从"大模型独有的倾向分布"中重采样,保证整体分布等于大模型。
这个算法最早由 DeepMind 的 Chen et al. (arXiv:2302.01318) 和 Google 的 Leviathan et al. (arXiv:2211.17192) 独立提出,2023 年成为 LLM 推理优化的显学。vLLM 的 V1 spec decode 实现完全忠于这个算法,没有做任何"为了性能而精度打折"的简化。
12.2.4 贪心采样(temperature=0)的退化形式
当 ,分布退化为 one-hot(所有概率集中在 argmax token)。拒绝采样变成简单的相等比较:
draft_token == target_token → 接受
draft_token != target_token → 拒绝,用 target 的 argmax接受率就是"两个模型在当前上下文下恰好选同一个 argmax 的概率"。这种 case 的接受率通常比随机采样更高(因为模型在贪心模式下更确定),但也更依赖模型相似度。
12.3 num_speculative_tokens=k 一次 forward 里发生了什么
弄清楚数学之后,我们看工程——给定 k=5,一次 spec decode step 实际上 GPU 里跑的是什么。
12.3.1 Draft 阶段(5 个并行 step)
如果用 Draft Model(一个独立的小 LLM),5 次自回归 forward:
step-draft-1: input = [last_target_token], output = draft_token_1
step-draft-2: input = [draft_token_1], output = draft_token_2
step-draft-3: input = [draft_token_2], output = draft_token_3
step-draft-4: input = [draft_token_3], output = draft_token_4
step-draft-5: input = [draft_token_4], output = draft_token_5小模型(比如 Llama-1B)每步 ~3 ms,5 步 = 15 ms。
如果用 EAGLE / MTP(共享大模型 hidden state 的预测头),5 次预测头 forward:
hidden_0 (from target) → head(hidden_0) → draft_token_1 + hidden_1
hidden_1 → head(hidden_1) → draft_token_2 + hidden_2
hidden_2 → head(hidden_2) → draft_token_3 + hidden_3
...预测头 < 1M 参数,每步 < 0.5 ms,5 步 < 3 ms。
如果用 n-gram,纯 CPU 查表 < 0.1 ms。
12.3.2 Verify 阶段:大模型一次 forward 5+1 个 token
Verify 阶段把 5 个 draft token 追加到输入序列,调用大模型 forward。大模型会输出6 个位置的 logits:
- 位置 0(last_target_token 之后):验证 draft_token_1
- 位置 1(draft_token_1 之后):验证 draft_token_2
- 位置 2(draft_token_2 之后):验证 draft_token_3
- 位置 3(draft_token_3 之后):验证 draft_token_4
- 位置 4(draft_token_4 之后):验证 draft_token_5
- 位置 5(draft_token_5 之后):总会被用到的 bonus token
第 5 位的 logits 是"如果 draft 全接受,顺便给你生成的下一个 token"。这是 spec decode 的免费午餐——无论前面接受几个,总会多得这 1 个 bonus token。
12.3.3 cu_seqlens 的构造
FlashAttention-3 的 varlen batch(第 11 章)为此优化:
python
# 假设 batch 里 3 个 decode 请求都做 spec decode with k=5
# 每个请求贡献 6 个 Q 位置(5 draft + 1 bonus)
query_tensor.shape = [3 * 6, num_heads, head_dim] = [18, H, D]
cu_seqlens_q = [0, 6, 12, 18] # 每请求 6 个 Q
# K/V 方面,每请求的 KV 长度是它的 context + 5 draft
# req_A: context=2000, kv_len=2005
# req_B: context=1500, kv_len=1505
# req_C: context=3000, kv_len=3005
cu_seqlens_k = [0, 2005, 3510, 6515]Attention mask 有一个 subtle 细节——5 个 draft token 之间要有因果掩码(draft_token_2 能看 draft_token_1,但不能看 draft_token_3-5)。FlashAttention-3 原生支持这种 "causal mask within each q segment" 模式,通过 causal=True 标志启用。
12.3.4 接受 n 个 token 后的状态更新
Verify 返回 6 个位置的 logits 后,拒绝采样逐个检查 5 个 draft token:
if accepted all 5: next step starts at position + 6 (5 accepted + 1 bonus)
if accepted 3: next step starts at position + 4 (3 accepted + 1 resampled)
if accepted 0: next step starts at position + 1 (0 accepted + 1 resampled)关键是 KV Cache 的处理——draft 阶段我们已经把所有 5 个 token 的 KV 写进 KV Cache 了。如果只接受了 3 个:
- 前 3 个 draft token 的 KV 是正确的(大模型验证通过)
- 第 4 个位置:draft 被拒,从大模型重采样出一个新 token;这个新 token 的 KV 已经在 Verify 阶段被大模型算好了(position=3 位置输出的 logits 就用了正确的前缀)
- 第 5 个 draft token 的 KV 需要废弃 — 因为它是基于错误的 draft_4 算的
V1 的 KVCacheManager 会"逻辑上"rollback 被拒部分的 KV——把 num_computed_tokens 回退到正确位置。由于 KV 是 block 级的,如果被拒的 token 恰好跨过 block 边界,可能整个 block 的部分内容需要重写。
12.4 四种 Proposer 深度对比
vLLM V1 支持四种 proposer,在 vllm/v1/spec_decode/ 下各有实现:
12.4.1 Draft Model
最经典的形态——用一个小得多的同系列模型做 draft。例如 Llama-3-8B-Instruct 给 Llama-3-70B-Instruct 做 draft。
python
# vllm/v1/spec_decode/draft_model_proposer.py(概念性)
class DraftModelProposer:
def __init__(self, draft_model_config, ...):
# 加载 draft 模型到 GPU(占一部分显存)
self.draft_runner = GPUModelRunner(draft_model_config, ...)
def propose(self, target_ids, num_speculative_tokens, ...):
# 自回归跑 k 步 draft
draft_ids = []
current_input = target_ids[-1:]
for _ in range(num_speculative_tokens):
logits = self.draft_runner.forward(current_input, past_kv=...)
draft_token = sample(logits, temperature=draft_temp)
draft_ids.append(draft_token)
current_input = draft_token.unsqueeze(0)
return draft_ids优点:
- 接受率最高(同系列模型训练数据、tokenizer、架构一致)
- 草稿质量可控(可以用
draft_temperature单独调小模型的随机性) - 现成的 checkpoint 可直接用(Llama-3 系列的 1B/8B/70B 都有)
缺点:
- 显存占用大(Llama-3-1B FP16 = 2 GB,8B = 16 GB)
- 每 draft step 有不可忽略的 GPU 开销(2-5 ms)
- 长序列 draft 时,小模型自己也要做 KV Cache 管理
12.4.2 EAGLE / EAGLE3
EAGLE (arXiv:2401.15077) 的洞察:大模型最后一层的 hidden state 已经包含了"下一个 token 应该是什么"的几乎所有信息。只需要一个超轻量的"预测头"就能从 hidden state 里解码出后续 token。
python
# vllm/v1/spec_decode/eagle.py(概念性)
class EagleProposer:
def __init__(self, eagle_head_config, target_model, ...):
self.eagle_head = load_eagle_head(eagle_head_config) # 1-2 层 Transformer
# 共享 target 模型的 hidden states,不需要独立跑大模型
def propose(self, target_hidden_states, target_token_ids, ...):
# 从 target 最后一层 hidden 出发,跑 k 步 eagle head
hidden = target_hidden_states[-1:]
draft_ids = []
for _ in range(num_speculative_tokens):
# eagle_head 是个小 transformer,输入 hidden + last_token
next_hidden, next_logits = self.eagle_head(hidden, ...)
next_token = sample(next_logits)
draft_ids.append(next_token)
hidden = next_hidden
return draft_idsEAGLE3 在 EAGLE 基础上进一步压缩预测头规模、引入多层 hidden 融合,接受率更高(0.6-0.7 对比 EAGLE 的 0.5)。
优点:
- 显存开销极小(预测头 < 500 MB)
- Draft cost 极低(单层 transformer < 1 ms)
- 不需要额外的 tokenizer / config(复用 target 的一切)
缺点:
- 需要针对特定 target 模型预训练 EAGLE head——不是所有模型都有现成 checkpoint
- 接受率比 Draft Model 略低(因为预测头容量有限)
12.4.3 MTP (Multi-Token Prediction)
DeepSeek-V3 引入的架构创新——模型本身就内置了多 token 预测头,训练时就按 "预测 next-4-tokens" 的目标训练。使用时:
python
# DeepSeek-V3 / V3.1 风格
def propose_mtp(target_output):
# MTP heads 是模型的一部分,训练时已对齐
for i in range(num_mtp_heads):
draft_token_i = target_output.mtp_heads[i].logits.argmax()
return draft_tokens优点:
- 接受率极高(0.85+,因为预测头和主模型完全端到端协同训练)
- 零额外显存(MTP 头本身就是模型的一部分)
- 零额外延迟(MTP 头在主模型一次 forward 里已经算完)
缺点:
- 只有原生支持 MTP 的模型能用(目前 DeepSeek-V3/V3.1 / R1,还会扩展到更多)
- 不能跨模型适配(不像 Draft Model 那样你可以自选一对模型)
V1 对 MTP 的支持是 2025 年新增的,是 LLM 推理领域 spec decode 进化的一个里程碑。
12.4.4 n-gram
最简单但在对的场景下惊人有效。原理:在已生成的上下文里找重复模式。
python
# vllm/v1/spec_decode/ngram_proposer.py(概念性)
@numba.jit(nopython=True)
def ngram_match(context: np.ndarray, min_n: int, max_n: int, k: int):
"""从 context 的末尾找匹配,返回匹配后的 k 个 token。"""
for n in range(max_n, min_n - 1, -1): # 先试长 n-gram
suffix = context[-n:]
# 在 context[:-n] 里找 suffix 的出现位置
for i in range(len(context) - 2 * n + 1):
if (context[i:i+n] == suffix).all():
# 找到了!返回 i+n 开始的 k 个 token
end = min(i + n + k, len(context))
return context[i+n:end]
return None # 没找到,放弃 spec decode 这一步典型场景是代码生成:
python
# 已生成的 context
"def foo(a, b): return a + b\ndef bar(x, y): return x + "
# 末尾 "return x + " 没有完全匹配,但 "return " 之前出现过
# 查表找到 "return a + b\n" 这个模式,猜测后续是 "y\n"
# 大多数情况会被验证通过优点:
- 零 GPU 开销(纯 CPU + Numba JIT)
- 零显存占用
- 实现简单,几十行代码
- 在代码/翻译/摘要等高重复性任务上接受率可以超过 0.5
缺点:
- 创意写作、对话场景接受率极低(0.1-0.3)
- 依赖 context 长度(越长找到匹配概率越高)
12.4.5 选择建议
12.5 加速比的闭式公式
推一下 spec decode 的理论加速比。
12.5.1 接受率与期望接受长度
设每个位置被接受的独立概率为 (接受率),num_speculative_tokens=k。期望接受的 token 数:
加上那 1 个 bonus token,每 step 净产出:
12.5.2 时间成本
设 verify 一次 forward 的时间为 (和原始 decode 接近,略多一点因为 FLOPs 略大),draft 的相对开销为 (比如 Draft Model 的 ,EAGLE 的 ,n-gram 的 )。
每 step 总时间:
12.5.3 加速比
原始 decode 每 step 产出 1 token,时间 (简化:忽略 verify 比原 decode 稍多的那一点)。所以:
12.5.4 数值计算
几个常见配置的理论加速比:
| Proposer | α | c | k=3 | k=5 | k=7 | 最优 k |
|---|---|---|---|---|---|---|
| Draft Model (同系列) | 0.75 | 0.2 | 2.28× | 2.62× | 2.72× | 7 |
| Draft Model (跨系列) | 0.55 | 0.2 | 1.74× | 1.79× | 1.73× | 5 |
| EAGLE3 | 0.65 | 0.05 | 2.15× | 2.52× | 2.68× | 8-10 |
| MTP (DeepSeek) | 0.85 | 0.01 | 2.78× | 3.71× | 4.33× | ~12 |
| n-gram (代码) | 0.50 | 0.00 | 1.75× | 1.93× | 1.98× | 6-8 |
| n-gram (对话) | 0.25 | 0.00 | 1.29× | 1.31× | 1.30× | 5 |
几个洞察:
- 高 时,k 越大越好(MTP 可以把 k 拉到 10+)
- 低 时,k 存在饱和点(跨系列 Draft Model 过 k=5 后收益递减)
- 决定了"最优 k"的位置—— 越小,最优 k 越大
实际生产中:
- MTP: k=8
- Draft Model: k=5
- EAGLE: k=5-6
- n-gram: k=5(再大也没用)
12.6 为什么"batch 大了投机解码反而变慢"
这是一个经常让工程师困惑的现象。原因是高并发下三个变化:
12.6.1 Draft cost 不再可忽略
低并发下 GPU 算力大量空闲,draft 的额外 FLOPs 几乎不增加墙钟时间。但 batch=128 时 GPU 已经接近 compute-bound,draft 每 step 的 2-5 ms 开始实打实加在每 step 总时间上。
12.6.2 Verify 的 k 倍 Q 占用 budget
一个 running 请求 decode 时 num_scheduled_tokens=1;开了 spec=5 后变成 6。如果 max_num_batched_tokens=4096,原本能塞 4000 个 decode 请求的 step,现在只能塞 650 个。
换句话说,spec decode 把 budget 消费放大了 k 倍。在中高并发下,这个放大效应让 batch 压不下来,吞吐反而降。
12.6.3 拒绝采样的部分浪费
接受率 α=0.7 时,平均每 5 个 draft 浪费 1.5 个——它们被写进 KV 又被逻辑 rollback。这部分显存带宽的浪费,在 compute-bound 场景下体现为纯开销。
12.6.4 经验法则
- batch_size < 8:spec decode 几乎总是赢的(低并发是它的主场)
- batch_size 8-32:看接受率,α>0.7 仍能赢
- batch_size > 64:通常弊大于利,关掉 spec decode
- 混合流量:V1 允许按请求动态开关 spec——高优先级 / 小 batch 的请求开,高并发的 decode 请求关
V1 支持在 SamplingParams 层面打开或关闭 spec decode:
python
sampling_params = SamplingParams(
temperature=0.7,
use_spec_decode=True, # 这个请求用投机解码
# use_spec_decode=False 则走标准 decode
)这让 vLLM 可以按请求权衡——单用户 chatbot 会话开 spec decode 降延迟,高并发 API 场景关掉保吞吐。
12.7 生产配置模板与调优
12.7.1 单用户对话(低并发,追求 TPOT)
bash
vllm serve meta-llama/Llama-3-70B-Instruct \
--speculative-model meta-llama/Llama-3-8B-Instruct \
--num-speculative-tokens 5 \
--speculative-draft-tensor-parallel-size 1 \
--max-num-seqs 16 \
--gpu-memory-utilization 0.92期望效果:TPOT 从 25 ms → 12 ms(2× 提升),总显存多占 16 GB。
12.7.2 DeepSeek-V3 启用 MTP
bash
vllm serve deepseek-ai/DeepSeek-V3 \
--speculative-config '{"method": "deepseek_mtp", "num_speculative_tokens": 3}' \
--max-num-seqs 64DeepSeek 原生的 MTP 头,接受率 ~0.85,提速 2.5×+。
12.7.3 代码生成工作流(n-gram)
bash
vllm serve meta-llama/Llama-3-70B-Instruct \
--speculative-config '{"method": "ngram", "num_speculative_tokens": 5, "prompt_lookup_min": 3, "prompt_lookup_max": 8}' \
--max-num-seqs 32零显存开销,代码场景接受率 ~0.55,提速 1.6×。
12.7.4 调优关键指标
vLLM 暴露 spec decode 的专用 Prometheus 指标:
vllm:spec_decode_num_accepted_tokens_total # 累计接受 token 数
vllm:spec_decode_num_draft_tokens_total # 累计 draft token 数
vllm:spec_decode_num_emitted_tokens_total # 累计 emit token 数计算实时接受率:,如果持续 < 0.4,考虑降 k 或关掉 spec。
12.8 投机解码的未来
Spec Decode 这个方向从 2022 年初出现以来,每半年就有一代新方法。我们可以预期的演进方向:
- 模型协同设计——像 MTP 这样"训练时就考虑 spec"的模型会越来越多
- Tree-based 投机——一次 draft 多个分支(tree),验证时挑最好的那支;已在 EAGLE2 / Medusa-2 中实现
- 层次化投机——draft 嵌套 draft(小小模型 + 小模型 + 大模型),像 CPU cache 的 L1/L2/L3
- 跨请求共享 draft——同 prompt 前缀的多个请求共用同一段 draft
- 硬件协同——draft 上 CPU / NPU 异构加速,verify 上 GPU
vLLM 的 V1 为 spec decode 设计了可插拔接口(第 18 章讲过的 Pluggable),新方法发布后通常一个月内就有 PR 合并进主线。这让它能保持对前沿的跟进能力。
12.9 本章小结
投机解码是打破 LLM decode memory-bound 枷锁的关键武器:
- 动因:decode 99% 时间在搬权重,计算空转;一次读权重顺便验多 token 几乎免费
- 数学等价:拒绝采样证明输出分布 = 原始大模型 decode,不是近似,是精确
- 一次 forward 内部:k+1 个 Q 位置、varlen cu_seqlens、causal mask within draft span
- 四种 Proposer:Draft Model(通用)/ EAGLE(轻量)/ MTP(原生最强)/ n-gram(CPU 零开销)
- 加速比闭式公式:,用来预测不同配置收益
- 反直觉现象:batch 大了 spec decode 反而变慢;V1 支持按请求动态开关
- 实战配置:低并发 / MTP 模型 / 代码场景给出三种典型配方
下一章我们进入 量化(Quantization)——用数值精度换速度与显存的系统性工程。如果说 Chunked Prefill 和 Spec Decode 是"策略层"的优化,量化就是"电流层"的优化——它让每一次权重读取、每一次矩阵乘法都变得更便宜。
源码导航
- V1 投机解码主目录:
vllm/v1/spec_decode/- Proposer 接口:
vllm/v1/spec_decode/interface.py- Draft Model 实现:
vllm/v1/spec_decode/draft_model_proposer.py- EAGLE 实现:
vllm/v1/spec_decode/eagle.py- n-gram 实现:
vllm/v1/spec_decode/ngram_proposer.py- MTP 实现:
vllm/v1/spec_decode/deepseek_mtp.py- 拒绝采样:
vllm/v1/sample/rejection_sampler.py- Metrics:
vllm/v1/metrics/stats.py论文
- Leviathan et al., "Fast Inference from Transformers via Speculative Decoding", ICML 2023 (arXiv:2211.17192)
- Chen et al., "Accelerating Large Language Model Decoding with Speculative Sampling", 2023 (arXiv:2302.01318)
- Cai et al., "Medusa: Simple LLM Inference Acceleration Framework", 2024 (arXiv:2401.10774)
- Li et al., "EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty", ICML 2024 (arXiv:2401.15077)
- DeepSeek-AI, "DeepSeek-V3 Technical Report", 2024 (arXiv:2412.19437) — MTP section