系列 《Transformer 与 LLM:结构、实现与演进》 第 12 / 15 篇
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
- Transformer 长什么样——从一句话到下一个 token
- 一个 token 的旅程——训练侧与推理侧
- 手搓 GPT(上)——nanoGPT model.py 逐行解析
- 手搓 GPT(下)——nanoGPT train.py 与训一个会续写的模型
- 从 GPT-2 到 Llama 与 DeepSeek——读真实模型的配置与参数量
- 前向的算量与访存量
- 位置编码与外推
- Attention 变体与 KV cache
- 长上下文的成本与结构手段
- MoE 的路由、激活参数量与通信形态
- MTP——改训练目标而不改主干的多 token 预测
- 投机解码——草稿、验证与收益条件
- 多模态:vision encoder 的算量与 image token 的 KV 代价
- 浮点格式、数值稳定性与混合精度
- Transformer 与 LLM:系列总结与通关自测
上一篇:MTP——改训练目标而不改主干的多 token 预测下一篇:多模态:vision encoder 的算量与 image token 的 KV 代价
本篇在系列中的位置。 第二段的第八篇。第 06 篇算出 batch 小时 decode 受带宽限制、Tensor Core 大多空闲;第 11 篇的 MTP 模块给了一个现成的草稿来源。本篇不改结构、不改数值格式,改的是解码流程:先猜几个 token,再用一次前向验证,输出分布严格不变。LoRA 不在本系列:参数高效微调的账在《LoRA 专题》。完整地图见总纲。
第六篇算过 decode 的账:batch 为 \(B\) 时权重 GEMM 的算术强度约等于 \(B\),距 H100 的 ridge point 295 差两个数量级,每步时间被”把权重读一遍”钉在 4.8 ms 上,Tensor Core 几乎空转。第十一篇的 MTP 模块在训练时多预测一个 token,推理时可以丢掉——也可以留下来当”草稿”。本篇把这两件事接起来:既然多算几行几乎不花时间,能不能先猜几个 token,再用一次前向把它们全部验证掉?
投机解码(speculative decoding)就是这个想法。它不改结构、不改数值格式,只改解码流程;收益却不是无条件的。本篇要回答的核心问题是:
同一套投机解码,为什么 batch 1 时加速 2 倍,batch 64 时没有收益?1
一、总览:一个时间模型
1. 本文的路线
先把第六篇的 Roofline 压成一个时间模型(本章第 2 节),后面所有估算都用它;再讲投机解码本身:算法与”输出分布严格不变”的证明、期望接受数与加速比、验证多个 token 为什么几乎免费又何时不再免费、草稿从哪里来(第二章);最后把它加进 llm_cost.py,合成文本模型的成本表(第三章)。
2. 一个时间模型
一次前向处理 \(m\) 个 token 行,时间下界是访存时间与计算时间的较大者:
\[T(m) = \max\left( \frac{W_{\text{bytes}}}{BW},\ \frac{2 N m}{F} \right)\]其中 \(W_{\text{bytes}}\) 是要读的权重字节数,\(N\) 是参与 GEMM 的参数量(embedding 是查表,不算),\(F\) 是算力。代入 Llama-3-8B(8.03B 参数,BF16 权重 16.06 GB,每 token 权重 FLOPs 约 15.0 GFLOPs):
\[T_{\text{mem}} = \frac{16.06 \ \text{GB}}{3.35 \ \text{TB/s}} \approx 4.8 \ \text{ms}, \qquad T_{\text{cmp}}(m) = m \times \frac{15.0 \ \text{GFLOPs}}{989 \ \text{TFLOPS}} \approx m \times 15.2 \ \mu\text{s}\]两者相等在 \(m \approx 316\),与 ridge 295 同一量级(差别来自 16.06 GB 包含了 embedding 而 15.0 GFLOPs 不含)。这个模型忽略了 KV cache 读取、activations 与 kernel 效率,是理论下界,不是任何实现的实测。\(m < 316\) 时 \(T(m)\) 是常数——多算几行不多花时间,这就是投机解码的全部空间。
3. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 投机解码:改变 m | 分布等式、期望接受数与加速比、验证 γ+1 个 token 何时免费、草稿从哪里来 |
| 三 | 实践 | 脚本新增的函数、文本模型的成本表 |
| 四 | 本文小结 | |
| 五 | 自测 | 3 道题 |
二、投机解码:改变 m
1. 问题:一次前向只产出一个 token
decode 每步读 16 GB 权重、产出 \(B\) 个 token。\(B = 1\) 时,4.8 ms 里 Tensor Core 只做了 15 GFLOPs,利用率约 0.3%。第一章的时间模型说:在斜线上,多算几行几乎不多花时间——\(T(m)\) 在 \(m < 316\) 时是常数。如果能一次前向验证多个候选 token,就把串行的产出变成了并行的验证。
问题是候选从哪里来,以及怎么保证结果与原模型一致。
2. 算法与分布等式
记目标模型的分布为 \(p(\cdot \mid \text{prefix})\),一个便宜的草稿模型的分布为 \(q(\cdot \mid \text{prefix})\)。投机解码(Leviathan 等 2023;Chen 等 2023)一轮做四件事:
- 草稿模型自回归采样 \(\gamma\) 个 token \(x_1, \ldots, x_\gamma\),\(x_i \sim q(\cdot \mid \text{prefix}, x_{<i})\);
- 目标模型对 \(\text{prefix}, x_1, \ldots, x_\gamma\) 做一次前向,得到 \(\gamma + 1\) 个位置的分布 \(p_1, \ldots, p_{\gamma+1}\);
- 从 \(i = 1\) 起逐个判定:以概率 \(\min(1, p_i(x_i) / q_i(x_i))\) 接受 \(x_i\);一旦拒绝,从修正分布 \(\text{norm}(\max(0, p_i - q_i))\) 采样一个 token 替代 \(x_i\),本轮结束;
- 若 \(\gamma\) 个全部接受,再从 \(p_{\gamma+1}\) 采样一个 token。
每轮至少产出 1 个 token(拒绝时的重采样或全接受时的额外采样),最多 \(\gamma + 1\) 个。一轮的分支与回退如下:
%% 图:投机解码的一轮:草稿模型自回归 γ 步,目标模型一次前向验证,逐位置接受或拒绝后重采样
flowchart TB
dr["草稿模型自回归 γ 步<br/>x_1 … x_γ ~ q,成本 γ · c · T(B)"] --> vf["目标模型一次前向 prefix, x_1 … x_γ<br/>得到 p_1 … p_γ+1,成本 T(B(γ+1))"]
vf --> i1["i = 1"]
i1 --> acc{"以概率 min(1, p_i(x_i) / q_i(x_i))<br/>接受 x_i?"}
acc -->|"接受"| more{"i = γ?"}
more -->|"否,i ← i + 1"| acc
more -->|"是,γ 个全接受"| bonus["从 p_γ+1 再采样 1 个<br/>本轮产出 γ + 1 个"]
acc -->|"拒绝"| rs["从 norm(max(0, p_i − q_i)) 重采样替代 x_i<br/>本轮产出 i 个"]
rs --> rb["回退:丢弃位置 i 及之后的草稿 token 与其 KV;<br/>位置 i 换成重采样的 token,<br/>它的 KV 下一轮才算"]
rb --> nx["下一轮"]
bonus --> nx
nx --> dr
classDef draft fill:#fdebd0,stroke:#b9770e;
classDef target fill:#d6eaf8,stroke:#2e6da4;
classDef ok fill:#d5f5e3,stroke:#1e8449;
classDef bad fill:#fadbd8,stroke:#c0392b;
class dr draft;
class vf target;
class bonus ok;
class rs,rb bad;
关键性质:输出分布严格等于 \(p\)。看单步。在某一位置,草稿提出 \(x\) 的概率是 \(q(x)\),被接受的概率是 \(\min(1, p(x)/q(x))\),所以”接受且输出 \(x\)“的概率是
\[q(x) \min\left(1, \frac{p(x)}{q(x)}\right) = \min(q(x), p(x))\]总接受概率
\[\beta = \sum_x \min(p(x), q(x))\]拒绝的概率是 \(1 - \beta\),拒绝后从 \(\text{norm}(\max(0, p - q))\) 采到 \(x\) 的概率是 \(\max(0, p(x) - q(x)) / Z\),归一化常数
\[Z = \sum_y \max(0, p(y) - q(y)) = \sum_y \left( p(y) - \min(p(y), q(y)) \right) = 1 - \beta\]于是输出 \(x\) 的总概率
\[P(x) = \min(p(x), q(x)) + (1 - \beta) \cdot \frac{\max(0, p(x) - q(x))}{1 - \beta} = \min(p(x), q(x)) + \max(0, p(x) - q(x)) = p(x)\]每个位置都从 \(p\) 采样,且被接受的 token 之后的位置以它为条件——与目标模型自回归采样的联合分布逐位相同。greedy 解码是 \(p\) 退化为 one-hot 的特例:接受当且仅当草稿与目标 argmax 一致。这个证明不依赖 \(q\) 是什么——\(q\) 只影响效率,不影响正确性。
两点补充。第一,拒绝后的重采样分布 \(\text{norm}(\max(0, p - q))\) 有直观含义:它只在 \(p(x) > q(x)\) 的 token 上有质量,即”目标模型认为比草稿更可能”的那些 token——草稿高估的 token 已经被接受步骤按 \(p/q\) 的比例采纳过了,剩下的概率质量正好是目标模型比草稿多出来的部分。第二,验证时目标模型输出的 \(\gamma + 1\) 个分布只需要一次前向,是因为因果掩码下每个位置的输出只依赖它之前的 token,草稿序列的每个前缀恰好对应一个位置——这与 prefill 一次算出整个 prompt 所有位置的 KV 是同一件事,投机解码的验证本质上是一次长度为 \(\gamma + 1\) 的小 prefill。KV cache 的回退要精确到位置:被拒绝的位置 \(i\) 本身的 KV 是按草稿 token \(x_i\) 算的,也要丢掉(或覆盖),保留到 \(i - 1\);重采样出的新 token 占据位置 \(i\),它的 KV 在下一轮验证前向里才会算出来;全接受时的 bonus token 同理。这是引擎实现中需要处理的细节。
3. 期望接受数与加速比
记单个位置的接受率 \(\alpha = \mathbb{E}[\beta]\)(它等于 \(1 - \text{TV}(p, q)\),\(p\) 与 \(q\) 的总变差距离的补)。假设各位置独立且接受率相同,一轮产出的 token 数是”连续接受的个数 + 1”,期望
\[\mathbb{E}[\text{tokens}] = 1 + \alpha + \alpha^2 + \cdots + \alpha^\gamma = \frac{1 - \alpha^{\gamma+1}}{1 - \alpha}\]\(\alpha = 0.8\)、\(\gamma = 4\):\((1 - 0.8^5)/0.2 = (1 - 0.328)/0.2 = 3.36\)。
一轮的成本:\(\gamma\) 次草稿前向加一次目标前向。记草稿一次前向的时间是目标前向的 \(c\) 倍,并且——这是关键假设——目标模型验证 \(\gamma + 1\) 个 token 的时间与验证 1 个相同。那么
\[\text{speedup} = \frac{\mathbb{E}[\text{tokens}]}{\gamma c + 1}\]\(c = 0.1\):\(3.36 / 1.4 = 2.4\)。若草稿几乎免费(\(c = 0.02\),多头或 n-gram 方案),\(3.36 / 1.08 \approx 3.1\)。
几个变体的数字:
| alpha | gamma | E[tokens] | speedup(c=0.1) | speedup(c=0.02) |
|---|---|---|---|---|
| 0.6 | 4 | 2.31 | 1.65 | 2.13 |
| 0.8 | 2 | 2.44 | 2.03 | 2.35 |
| 0.8 | 4 | 3.36 | 2.40 | 3.11 |
| 0.8 | 8 | 4.33 | 2.40 | 3.73 |
| 0.9 | 4 | 4.10 | 2.93 | 3.79 |
\(\gamma\) 越大,期望接受数增长越慢(\(\alpha^\gamma\) 衰减),而草稿成本线性增长;\(c = 0.1\) 时 \(\gamma = 4\) 与 \(\gamma = 8\) 的加速比相同,最优 \(\gamma\) 由 \(\alpha\) 与 \(c\) 共同决定。
4. 为什么验证 γ+1 个 token 几乎免费——以及何时不再免费
“验证 \(\gamma + 1\) 个与验证 1 个同样贵”是 Roofline 的直接推论。目标模型的 GEMM 从 \([B, k] \times [k, n]\) 变成 \([B(\gamma + 1), k] \times [k, n]\):FLOPs 乘 \(\gamma + 1\),权重读取不变。只要 \(B(\gamma + 1)\) 仍在 ridge 之下,
\[T(B(\gamma + 1)) = T(B) = \frac{W_{\text{bytes}}}{BW}\]多出的 FLOPs 填的是本来空转的 Tensor Core。KV cache 读取也一样:验证 5 个 token 的 attention 读同一份 KV cache。
条件是 \(B(\gamma + 1) \lesssim \text{ridge}\),即
\[B \lesssim \frac{\text{ridge}}{\gamma + 1} \approx \frac{300}{5} = 60\]超过这个 batch,验证前向进入 compute-bound,\(T(B(\gamma+1))\) 开始以 \((\gamma + 1)\) 倍于 \(T(B)\) 的斜率增长。极限情况(完全 compute-bound)加速比变为
\[\frac{\mathbb{E}[\text{tokens}]}{\gamma c + (\gamma + 1)} = \frac{3.36}{0.4 + 5} \approx 0.62\]低于 1。原因很朴素:投机解码是用 FLOPs 换延迟——每产出 3.36 个 token 要为 5 个位置做完整前向,FLOPs 效率是 \(3.36/5 = 67\%\),被拒绝的 token 的计算是白做的。当 FLOPs 是瓶颈时,这笔交易亏本。
用第一章的时间模型算 Llama-3-8B 在 H100 上各 batch 的加速比(\(\alpha = 0.8\)、\(\gamma = 4\)、\(c = 0.1\),草稿成本按 \(c \cdot T(B)\) 计):
\[\text{speedup}(B) = \frac{\mathbb{E}[\text{tokens}] \cdot T(B)}{\gamma c \cdot T(B) + T(B(\gamma + 1))}\]| batch B | T(B) ms | T(5B) ms | 加速比(峰值算力) | 加速比(60% MFU) |
|---|---|---|---|---|
| 1 | 4.79 | 4.79 | 2.40 | 2.40 |
| 8 | 4.79 | 4.79 | 2.40 | 2.40 |
| 32 | 4.79 | 4.79 | 2.40 | 2.40 |
| 64 | 4.79 | 4.85 | 2.38 | 1.61 |
| 128 | 4.79 | 9.71 | 1.39 | 0.89 |
| 256 | 4.79 | 19.4 | 0.76 | 0.62 |
第二列按 60% MFU 折算实际可达算力(593 TFLOPS,有效 ridge 约 177,转折 batch 约 35):\(B = 64\) 时验证前向已进入计算区,加速比掉到 1.6;\(B = 128\) 时低于 1。再算上真实系统里草稿模型在大 batch 下的开销、每轮调度与采样的固定成本、以及 \(\alpha\) 在不同位置并不独立同分布,“batch 64 时没有收益” 是这条曲线的工程表述——精确的转折位置随模型、硬件、\(\gamma\) 移动,但它的量级由 \(\text{ridge}/(\gamma + 1)\) 决定。
另一种常见的 decode 加速——weight-only 量化(把 BF16 权重压成 INT4,字节数降到四分之一;原理与方法在算法地图的《高效推理与压缩》)——与本章的数字画在同一条 \(T(m)\) 曲线上(对数坐标):量化把 memory-bound 的平台向下移,投机解码把工作点向右推,两者的收益都止于平台与斜线的交点:
INT4 在 \(B < \text{ridge}/4\) 时兑现字节收益,投机解码在 \(B < \text{ridge}/(\gamma+1)\) 时兑现并行验证的收益——两者都只在 Roofline 的斜线上有效,越过 ridge 就消失甚至反转。它们优化的是同一个量:memory-bound 区间里被浪费的算力。这也意味着两者可以叠加:W4A16 的目标模型验证 5 个 token 同样几乎免费,只是转折 batch 变成 \(\text{ridge}/(4 \times 5) \approx 15\)。
5. 草稿从哪里来
草稿方案决定 \(\alpha\) 与 \(c\)。以下区间是各论文与工程报告中通常报告的量级,不是本文实测:
| 方案 | 草稿形态 | c(相对目标一次前向) | alpha(通常报告) |
|---|---|---|---|
| 独立小模型 | 同 tokenizer 的小模型自回归 γ 步 | 参数量之比,~0.05–0.15 | 0.6–0.8(取决于配对) |
| Medusa(Cai 等 2024) | 目标模型顶层加 K 个头并行预测 t+2.. | ≈0(一次前向内) | 第 1 头 ~0.6–0.7,逐头下降 |
| EAGLE(Li 等 2024) | 一层 Transformer 在特征级自回归起草 | ~0.02–0.05 | ~0.75–0.85(论文报告) |
| n-gram / prompt lookup | 在上下文中查找 n-gram 复制后续 token | ≈0 | 任务依赖:改写/摘要/RAG 高,自由生成低 |
| DeepSeek-V3 MTP | 训练时联合训练的一个额外 block | 1/61 层的量级 | 第二 token 85–90%(技术报告) |
- 独立小模型要求与目标共享 tokenizer(Llama-3-8B 给 70B 起草),\(c\) 约等于参数量之比,在 memory-bound 区间也等于字节数之比。\(\alpha\) 取决于两者分布的接近程度,同系列同数据训练的模型配对最好。
- Medusa 在目标模型最后一层 hidden state 上接 \(K\) 个轻量头,第 \(k\) 个头预测第 \(t + k + 1\) 个 token,一次前向同时出所有草稿;用 tree attention 一次验证多条候选路径。\(c \approx 0\),但各头独立预测(没有以前一个草稿为条件),\(\alpha\) 随头序号下降。论文报告 Medusa-1 约 2.2×、Medusa-2 约 2.3–3.6×。
- EAGLE 的观察是:在特征(倒数第二层的 hidden state)而非 token 层面做自回归,不确定性更低;草稿模块只有一层 decoder,输入是目标模型的特征与已采样 token 的 embedding。论文报告 LLaMA2-Chat 70B 上约 2.7–3.5×,EAGLE-2 用动态草稿树进一步提高。它的 \(\alpha\) 通常高于 Medusa,\(c\) 是一层对全模型的比例。
- n-gram / prompt lookup:把上下文里最近出现的 n-gram 后面接的 token 当草稿,零成本、零训练,在有大量复制的任务(改写、摘要、代码编辑、RAG)上 \(\alpha\) 很高,在自由生成上接近 0——加速比完全依赖任务。
- DeepSeek-V3 的 MTP:训练时就带一个预测下一下个 token 的额外模块,推理时可当草稿用(\(\gamma = 1\))。技术报告称第二个 token 的接受率在 85–90% 之间,对应 \(\mathbb{E}[\text{tokens}] = 1 + \alpha \approx 1.85 \sim 1.9\),报告的解码吞吐提升约 1.8×,与 \(c\) 很小时 \(1.9 / (c + 1)\) 的估算一致。
所有这些方案共享同一条约束:它们提升的是 \(\alpha\) 或降低 \(c\),但都改不了 \(B \lesssim \text{ridge}/(\gamma + 1)\) 这个收益区间。
三、实践:投机解码的期望加速比
1. 脚本新增的函数
延续贯穿全系列的 llm_cost.py,本篇新增 roofline_step_time(第一章的时间模型)与 speculative_speedup。配套仓库里这一版脚本(llm_cost_07_quant_specdec_lora.py)还带着 weight-only 量化字节数与 LoRA 参数量两组函数——它们原来与投机解码同在一篇,现在分别归入算法地图的《高效推理与压缩》与《LoRA 专题》;脚本原样保留以便独立运行与对照输出,下面只讲投机解码那一组。前几篇用到的 param_count、forward_flops_per_token、kv_bytes_per_token 同样重给 dense 版本(MoE 与 MLA 的版本在第八、十篇)。
from dataclasses import dataclass
@dataclass
class ModelConfig:
name: str
hidden: int
layers: int
n_heads: int
n_kv_heads: int
head_dim: int
d_ff: int
vocab: int
tie_embeddings: bool = False
LLAMA3_8B = ModelConfig("Llama-3-8B", 4096, 32, 32, 8, 128, 14336, 128256)
LLAMA3_70B = ModelConfig("Llama-3-70B", 8192, 80, 64, 8, 128, 28672, 128256)
@dataclass
class GPU:
name: str
hbm_bytes: float
bandwidth: float # bytes/s
bf16_flops: float # FLOP/s
H100 = GPU("H100 SXM", 80e9, 3.35e12, 989e12)
A100 = GPU("A100 80GB", 80e9, 2.0e12, 312e12)
# ---- 前几篇的函数(dense 版本)----
def embedding_params(cfg):
return cfg.vocab * cfg.hidden * (1 if cfg.tie_embeddings else 2)
def param_count(cfg):
"""第五篇的参数量(重给以便独立运行),返回 dict。"""
d = cfg.hidden
q, kv = cfg.n_heads * cfg.head_dim, cfg.n_kv_heads * cfg.head_dim
attn = d * q + d * kv + d * kv + q * d
ffn = 3 * d * cfg.d_ff
per_layer = attn + ffn + 2 * d
embed = cfg.vocab * d
lm_head = 0 if cfg.tie_embeddings else cfg.vocab * d
total = per_layer * cfg.layers + embed + lm_head + d
return {"per_layer": per_layer, "embedding": embed, "lm_head": lm_head, "total": total}
def forward_flops_per_token(cfg, ctx=0):
# embedding 查表不算 GEMM;lm_head 算
# 输入 embedding 是查表不算 GEMM;tied 模型那张表兼作 lm_head,仍要算
gemm_params = param_count(cfg)["total"] - (0 if cfg.tie_embeddings else cfg.vocab * cfg.hidden)
return 2 * gemm_params + 4 * cfg.hidden * ctx * cfg.layers
def kv_bytes_per_token(cfg, dtype_bytes=2):
return 2 * cfg.layers * cfg.n_kv_heads * cfg.head_dim * dtype_bytes
# ---- 本篇:时间模型与投机解码(量化、LoRA 两组函数见对应专题系列)----
def quantized_weight_bytes(cfg, bits=4, group_size=128, scale_bits=16,
zero_bits=16, keep_embed_bf16=False):
"""weight-only 量化后的权重字节数;返回 (bytes, 等效 bit/权重)。"""
eff_bits = bits + (scale_bits + zero_bits) / group_size
n = param_count(cfg)["total"]
if keep_embed_bf16:
e = embedding_params(cfg)
return (n - e) * eff_bits / 8 + e * 2, eff_bits
return n * eff_bits / 8, eff_bits
def roofline_step_time(cfg, gpu, rows, weight_bytes, mfu=1.0):
"""一次前向处理 rows 个 token 行的时间下界 max(访存, 计算),只计权重部分。"""
t_mem = weight_bytes / gpu.bandwidth
t_cmp = forward_flops_per_token(cfg) * rows / (gpu.bf16_flops * mfu)
return max(t_mem, t_cmp)
def speculative_speedup(alpha, gamma, c, batch, cfg, gpu, mfu=1.0):
"""投机解码相对普通 decode 的加速比;返回 (speedup, E[tokens])。"""
assert 0.0 <= alpha <= 1.0
# alpha == 1 时几何级数的闭式是 0/0,极限是 gamma + 1(全部接受 + bonus)
exp_tokens = gamma + 1 if alpha == 1.0 else (1 - alpha ** (gamma + 1)) / (1 - alpha)
w = param_count(cfg)["total"] * 2 # BF16 目标模型
t_base = roofline_step_time(cfg, gpu, batch, w, mfu)
t_verify = roofline_step_time(cfg, gpu, batch * (gamma + 1), w, mfu)
t_round = gamma * c * t_base + t_verify
return exp_tokens * t_base / t_round, exp_tokens
LORA_TARGETS_ATTN = ("q", "k", "v", "o")
LORA_TARGETS_ALL = ("q", "k", "v", "o", "gate", "up", "down")
def lora_params(cfg, rank=16, targets=LORA_TARGETS_ALL):
d = cfg.hidden
q, kv = cfg.n_heads * cfg.head_dim, cfg.n_kv_heads * cfg.head_dim
shapes = {"q": (d, q), "k": (d, kv), "v": (d, kv), "o": (q, d),
"gate": (d, cfg.d_ff), "up": (d, cfg.d_ff), "down": (cfg.d_ff, d)}
per_layer = sum(rank * (din + dout)
for t in targets for din, dout in [shapes[t]])
return per_layer * cfg.layers
if __name__ == "__main__":
for cfg in (LLAMA3_8B, LLAMA3_70B):
n = param_count(cfg)["total"]
b4, eb = quantized_weight_bytes(cfg)
print(f"{cfg.name}: {n/1e9:.2f}B BF16 {n*2/1e9:.1f} GB "
f"INT4(g128) {eb:.2f} bit -> {b4/1e9:.2f} GB")
print(f" decode 下界 BF16 {n*2/H100.bandwidth*1e3:.2f} ms "
f"W4A16 {b4/H100.bandwidth*1e3:.2f} ms")
print(f" LoRA r=16 attn {lora_params(cfg, 16, LORA_TARGETS_ATTN)/1e6:.2f}M "
f"all {lora_params(cfg, 16)/1e6:.2f}M ({lora_params(cfg, 16)/n*100:.2f}%)")
print("投机解码 alpha=0.8 gamma=4 c=0.1 (Llama-3-8B, H100):")
for B in (1, 8, 32, 64, 128, 256):
s, e = speculative_speedup(0.8, 4, 0.1, B, LLAMA3_8B, H100)
s60, _ = speculative_speedup(0.8, 4, 0.1, B, LLAMA3_8B, H100, mfu=0.6)
print(f" B={B:4d} peak {s:.2f} 60%MFU {s60:.2f} E[tokens]={e:.2f}")
输出:
Llama-3-8B: 8.03B BF16 16.1 GB INT4(g128) 4.25 bit -> 4.27 GB
decode 下界 BF16 4.79 ms W4A16 1.27 ms
LoRA r=16 attn 13.63M all 41.94M (0.52%)
Llama-3-70B: 70.55B BF16 141.1 GB INT4(g128) 4.25 bit -> 37.48 GB
decode 下界 BF16 42.12 ms W4A16 11.19 ms
LoRA r=16 attn 65.54M all 207.09M (0.29%)
投机解码 alpha=0.8 gamma=4 c=0.1 (Llama-3-8B, H100):
B= 1 peak 2.40 60%MFU 2.40 E[tokens]=3.36
B= 8 peak 2.40 60%MFU 2.40 E[tokens]=3.36
B= 32 peak 2.40 60%MFU 2.40 E[tokens]=3.36
B= 64 peak 2.38 60%MFU 1.61 E[tokens]=3.36
B= 128 peak 1.39 60%MFU 0.89 E[tokens]=3.36
B= 256 peak 0.76 60%MFU 0.62 E[tokens]=3.36
最后六行是本篇的数字:\(B \le 32\) 时加速比稳定在 2.40,\(B = 64\) 起按 60% MFU 折算的曲线先掉到 1.61,\(B = 128\) 低于 1——与第二章第 4 节的表一致。70B 的 BF16 decode 下界 42 ms 是”假设能放进一张卡”的数值,实际放不进。
2. 文本模型的成本表
前面各篇的数字合到一张表(H100 SXM,理论值;DeepSeek-V3 列用第八、十篇的 MLA 与 MoE 版本函数):
| Llama-3-8B | Llama-3-70B | DeepSeek-V3 | |
|---|---|---|---|
| 参数量 | 8.03B | 70.55B | 671B(每 token 激活 37B) |
| 权重字节 BF16 | 16.06 GB | 141 GB | 1342 GB(FP8 671 GB) |
| 每 token 权重 FLOPs | 15.0 GFLOPs | ~141 GFLOPs | ~74 GFLOPs |
| KV cache / token(BF16) | 128 KiB | 320 KiB | 68.6 KiB(MLA) |
| 128K 上下文 KV cache(BF16) | 16 GiB | 40 GiB | 8.6 GiB |
| decode 下界 B=1,BF16 | 4.8 ms | 不能单卡 | 不能单卡 |
| 投机解码 α=0.8 γ=4 c=0.1 | 2.4×(B ≲ 60) | 2.4×(B ≲ 60) | MTP α≈0.85–0.9 γ=1 → ~1.8× |
DeepSeek-V3 的投机一行按其技术报告的 MTP 接受率转述。第十三篇给这张表加”一张 1024² 图片”一行,第十四篇加精度一列(权重与 KV 换成 FP8 各是多少字节、训练状态每参数几字节)。
四、本文小结
投机解码只改一个变量——每步验证的 token 数:
| 项 | 结论 |
|---|---|
| 正确性 | 以 \(\min(1, p/q)\) 接受、拒绝后从 \(\text{norm}(\max(0, p - q))\) 重采样,输出分布严格等于目标模型 \(p\);草稿 \(q\) 只影响效率 |
| 期望产出 | 一轮 \(\frac{1 - \alpha^{\gamma+1}}{1 - \alpha}\) 个 token;\(\alpha = 0.8\)、\(\gamma = 4\) 时 3.36 |
| 加速比 | \(\mathbb{E}[\text{tokens}] / (\gamma c + 1)\),\(c = 0.1\) 时 2.4×;成立的前提是验证 \(\gamma + 1\) 个 token 与验证 1 个同样贵 |
| 收益区间 | \(B \lesssim \text{ridge}/(\gamma + 1) \approx 60\);越过之后验证进入 compute-bound,被拒绝 token 的 FLOPs 开始花真时间,大 batch 下可低于 1 |
| 草稿来源 | 独立小模型、Medusa、EAGLE、n-gram、MTP 模块——改变的是 \(\alpha\) 与 \(c\),改不了收益区间 |
本篇的数字:
| Llama-3-8B | Llama-3-70B | DeepSeek-V3 | |
|---|---|---|---|
| 投机 E[tokens](α=0.8, γ=4) | 3.36 | 3.36 | 1.85–1.9(MTP) |
| 投机加速比(c=0.1,B ≲ 60) | 2.4× | 2.4× | ~1.8× |
| 投机转折 batch(ridge/(γ+1)) | ~60 | ~60 | — |
本篇只算了投机解码的账。怎么把接受率提上去(草稿的训练目标是蒸馏)、Medusa / EAGLE 的草稿各看到了什么、树状草稿怎么一次验证多条路径、何时投机反而变慢,在算法地图的《高效推理与压缩》第 02 篇展开。下一篇《Transformer 与 LLM(13):多模态:vision encoder 的算量与 image token 的 KV 代价》把输入从 token 换成图片:vision encoder 与 connector 加在哪里、一张图变成多少 token、这些 token 在 decoder 里占多少 KV。
配套代码:transformer-and-llm/llm_cost_07_quant_specdec_lora.py。
五、自测
-
投机解码 \(\alpha = 0.8\)、\(\gamma = 4\)、\(c = 0.1\):一轮期望产出几个 token?加速比多少?batch 多大时失效?
答案
\(E = (1 - 0.8^5)/(1 - 0.8) = 3.36\);加速 \(3.36 / (4 \times 0.1 + 1) = 2.4\times\);验证一次前向要算 \(\gamma + 1 = 5\) 倍的 token,等效 batch 过 ridge 的点是 \(295 / 5 \approx 60\),之后验证不再免费、加速开始随 \(B\) 下降,但不是立刻低于 1:要到验证时间变成基线的 3.36 倍以上才亏(第四章的表里 \(B = 64\) 仍有 2.38 倍、\(B = 128\) 是 1.39 倍)。
-
为什么”验证 \(\gamma + 1\) 个 token 与验证 1 个同样贵”?这个说法在什么条件下失效?
答案
目标模型的 GEMM 从 \([B, k] \times [k, n]\) 变成 \([B(\gamma+1), k] \times [k, n]\):FLOPs 乘 \(\gamma + 1\),但权重只从 HBM 读一遍、KV cache 也只读一遍;只要 \(B(\gamma + 1)\) 仍在 ridge 之下,时间由字节数决定,多出的 FLOPs 落在空转的 Tensor Core 上。\(B(\gamma+1)\) 越过 ridge 后验证进入 compute-bound,时间随 \(\gamma + 1\) 线性增长,被拒绝 token 的计算成了真实成本。
-
投机解码的输出分布为什么严格等于目标模型?草稿模型越差,会出错还是只会变慢?
答案
单步”接受且输出 \(x\)“的概率是 \(q(x)\min(1, p(x)/q(x)) = \min(p(x), q(x))\),拒绝的概率是 \(1 - \beta\),拒绝后从 \(\text{norm}(\max(0, p - q))\) 采到 \(x\) 的概率是 \(\max(0, p(x) - q(x)) / (1 - \beta)\),两项相加恰好是 \(p(x)\);每个位置都以已接受的前缀为条件,与自回归采样的联合分布逐位相同。证明不依赖 \(q\),所以草稿再差也不会出错,只是接受率 \(\alpha\) 下降、每轮产出更少、更慢。
系列 《Transformer 与 LLM:结构、实现与演进》 第 12 / 15 篇
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
- Transformer 长什么样——从一句话到下一个 token
- 一个 token 的旅程——训练侧与推理侧
- 手搓 GPT(上)——nanoGPT model.py 逐行解析
- 手搓 GPT(下)——nanoGPT train.py 与训一个会续写的模型
- 从 GPT-2 到 Llama 与 DeepSeek——读真实模型的配置与参数量
- 前向的算量与访存量
- 位置编码与外推
- Attention 变体与 KV cache
- 长上下文的成本与结构手段
- MoE 的路由、激活参数量与通信形态
- MTP——改训练目标而不改主干的多 token 预测
- 投机解码——草稿、验证与收益条件
- 多模态:vision encoder 的算量与 image token 的 KV 代价
- 浮点格式、数值稳定性与混合精度
- Transformer 与 LLM:系列总结与通关自测
上一篇:MTP——改训练目标而不改主干的多 token 预测下一篇:多模态:vision encoder 的算量与 image token 的 KV 代价
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/speculative-decoding-draft-verify-and-payoff.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。