教科书说投机解码给你 2-3× 加速。我在自己的 M5 Pro 上用 Qwen2.5-7B 当目标模型、同族的 0.5B 当草稿模型,把它完整跑了一遍:峰值 1.16×。把草稿长度从 4 调到 11——一个论文里随手就会写的数字——端到端从 1.11× 掉到 0.49×,比根本不投机还慢一倍。
问题不在我的模型选错了,也不在接受率太低(实测 α ≈ 0.69,不算差)。问题在于所有关于投机解码的讲解,都默认了一个我从没见谁去量过的前提:一次前向传播多塞几个 token 是免费的。 在我这台机器上,这句话只在 token 数 ≤ 8 的时候成立。第 9 个 token 让验证成本从 44.8 ms 跳到 79.0 ms。
这篇文章的起点是一张在时间线上流传的对比卡片(DailyDoseOfDS 出品),把投机解码的四种变体——Two-Model、EAGLE、Medusa、LayerSkip——并排画成四条流水线,底下一句总结:“The field is converging toward single-model drafting.”(这个领域正在收敛到单模型起草。)
卡片没错。但它和绝大多数同类讲解一样,把四种变体讲成了四个产品。我想说的是:它们是同一道账的四种解法,而这道账里有一项是纯硬件决定的、论文和卡片都不会告诉你的——一次前向到底能免费吞下几个 token。这一项我在自己机器上量出来了,顺便量出了它为什么会在第 9 个 token 上崩掉,以及这件事怎么反过来解释「为什么这个领域在收敛到单模型起草」。
名词速查
| 术语 | 一句话解释 |
|---|---|
| prefill / decode | 推理的两个阶段:一次性把输入全文算完,然后一个一个往外吐字。详见站内旧文 |
| memory-bound | 慢在「把权重从内存搬到计算单元」,不是慢在算术。decode 阶段的常态,见这篇 |
| 投机解码 | 让一个便宜的东西先猜几个 token,再让大模型一次性检查它们对不对,猜对就白赚 |
| 草稿长度 k | 一轮里让草稿方猜几个 token |
| 接受率 α | 草稿猜的 token 被大模型认可的比例(逐个位置看) |
| 草稿成本 γ | 草稿方生成一个 token 的时间 ÷ 目标模型生成一个 token 的时间 |
| batch 内 token 数 | 塞进同一次前向传播里一起算的 token 个数。在 ggml 源码里叫 ne11 |
| Q4_K_M | llama.cpp 的一种 4 比特量化格式,属于「K 系量化」。本文所有模型都是这个格式 |
| M-RoPE | 一种要求位置编号严格递增的旋转位置编码变体,本文第七节会因为它翻一次车 |
一、四张卡片背后只有一道账
投机解码的循环就三步:草稿方吐 k 个 token → 目标模型一次前向同时验证这 k 个 → 接受最长的正确前缀,再白送一个 bonus token。
所以一轮的时间是两项之和,产出是一个随机数的期望:
投机解码的第二本账那篇里我算过前三个变量(α、γ、k),Medusa 深读里拆过 α 怎么被架构影响。两篇都把 当成一个常数——「反正一次前向搬一遍权重的钱已经花了,多算几个 token 不要钱」。
这篇就是去量那个「常数」。剧透:它不是常数,而且它长得很难看。
这是全文的主线:四种变体拼命优化的是等式左边的 ,但等式右边的 才决定了你能玩多大,而且它不归你管——它归你的 GPU 内核调度规则管。
二、验证到底有多便宜:实测一次前向的成本曲线
llama-bench 的 -p N 就是在测「一次性处理 N 个 token 要多久」,这恰好就是验证 k+1 个草稿 token 的成本。于是:
llama-bench -m Qwen2.5-7B-Instruct-Q4_K_M.gguf \
-p 1,2,3,4,5,6,7,8,9,12,16,24,32 -n 0 -r 5 -o md
llama-bench 输出的是吞吐(t/s),换算成「一次前向的墙钟延迟」要做一次除法:N ÷ (t/s) × 1000。下面这张表是我实测的原始吞吐和换算后的延迟(M5 Pro / 24 GB / Metal,llama.cpp build 8ed274ef4)。
一处过程真相:这张表其实是两次 llama-bench 调用拼起来的——N=1/2/4/6/8/9/12/16/24/32 来自一次 -r 3 的运行,N=3/5/7 是后来发现需要细分才补跑的 -r 5,各档标准差都 < 2.3 t/s。写稿时我不放心这种拼接,又用上面那条命令把全部 13 档一次性重跑了一遍(-r 5),两次结果最大偏差 3.8%,悬崖和洼地都原样复现(重跑版 N=8 是 44.44 ms、N=9 是 81.95 ms,跳 1.84×)。下表用的是第一次的数,因为后面第四节的端到端实验是在那个时间点跑的,用同一批数字账才对得上。
| batch 内 token 数 N | 实测吞吐 t/s | 一次前向延迟 | 比 N=1 多花 |
|---|---|---|---|
| 1 | 55.11 | 18.15 ms | — |
| 2 | 95.54 | 20.93 ms | +2.79 ms |
| 3 | 106.71 | 28.11 ms | +9.97 ms |
| 4 | 146.87 | 27.23 ms | +9.09 ms |
| 5 | 172.36 | 29.01 ms | +10.86 ms |
| 6 | 158.46 | 37.86 ms | +19.72 ms |
| 7 | 157.54 | 44.43 ms | +26.29 ms |
| 8 | 178.68 | 44.77 ms | +26.63 ms |
| 9 | 113.95 | 78.98 ms | +60.84 ms |
| 12 | 151.01 | 79.46 ms | +61.32 ms |
| 16 | 199.73 | 80.11 ms | +61.96 ms |
| 24 | 303.24 | 79.15 ms | +61.00 ms |
| 32 | 402.04 | 79.59 ms | +61.45 ms |
xychart-beta
title "一次前向传播的墙钟延迟 vs batch 内 token 数"
x-axis [1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 16, 24, 32]
y-axis "毫秒" 0 --> 90
bar [18.2, 20.9, 28.1, 27.2, 29.0, 37.9, 44.4, 44.8, 79.0, 79.5, 80.1, 79.1, 79.6]
(横轴是分类刻度不是线性刻度——9 到 32 那段在真实数轴上要拉长很多,但高度是真的:那一段确实是平的。)
三个特征,每一个都会直接改写投机解码的账:
- N ≤ 8 的区间里,多塞 token 确实便宜:从 1 个到 8 个,延迟只从 18.15 ms 涨到 44.77 ms。多算 7 个 token 只多花 1.47 倍的时间——这就是投机解码赖以为生的那条物理规律。
- 第 9 个 token 是一道悬崖:44.77 → 78.98 ms,多一个 token 多花 34.21 ms,涨幅 1.76×。这个 token 一个人的代价,比前面 7 个加起来还贵。
- 越过悬崖之后完全平坦:9 个和 32 个 token 的成本几乎一样(78.98 vs 79.59 ms)。所以如果你已经被迫跨过 8,那就该一路冲到 32,中间停下来没有任何意义。
第 3 点意味着成本函数是个阶梯,不是斜坡。而阶梯最怕的就是「差一点」——草稿长度 8 和 9 的收益差距,比 1 和 8 的差距还大。
我在另外三个模型上重测了这道悬崖,它稳定得让人起疑:
| 模型 | N=8 延迟 | N=9 延迟 | 倍数 |
|---|---|---|---|
| Qwen2.5-7B Q4_K_M(28 层) | 44.77 ms | 78.98 ms | 1.76× |
| Qwen3.5-4B Q4_K_M(混合 SSM 架构) | 30.51 ms | 54.42 ms | 1.78× |
| Qwen3.5-0.8B Q4_K_M | 6.98 ms | 12.15 ms | 1.74× |
| Qwen2.5-0.5B Q4_K_M(24 层) | 5.30 ms | 10.29 ms | 1.94× |
跨两代模型、跨两种架构(纯 Transformer 和混合 SSM)、跨 15 倍的参数量,悬崖的位置一模一样,倍数都在 1.74–1.94 之间。这已经不像模型的性质了,像是软件里写死的一个数。
三、悬崖来自哪一行代码
是写死的。在 llama.cpp 的 Metal 后端里,选哪个矩阵乘内核是由一个布尔函数决定的(ggml/src/ggml-metal/ggml-metal-common.cpp:10-17,ne11 就是 batch 内的 token 数):
bool ggml_metal_op_mul_mat_use_mm(const struct ggml_tensor * op, bool has_simdgroup_mm) {
const int64_t ne00 = op->src[0]->ne[0];
const int64_t ne11 = op->src[1]->ne[1];
return !ggml_is_transposed(op->src[0]) &&
!ggml_is_transposed(op->src[1]) &&
has_simdgroup_mm && ne00 >= 64 && ne11 > 8;
}
ne11 > 8 —— 这就是那道悬崖。9 个及以上 token 时,Metal 切换到 simdgroup 矩阵乘内核(mul_mm)。这个内核的吞吐很高,但它按固定大小的 tile 工作:batch 只有 9 的时候整块 tile 基本是空的,你付了一整块 tile 的钱,只拿到 9 个 token 的货。(tile 具体多大源码里没直说,这是我从「9 到 32 延迟持平」反推的推测。)
而 8 及以下走的是另一条路,派发前有一段注释把设计意图写得很清楚(ggml-metal-ops.cpp:2417-2446):
// first try to use small-batch mat-mv kernels
// these should be efficient for BS [2, ~8]
if (op->src[1]->type == GGML_TYPE_F32 && (ne00%128 == 0) && ( ...
op->src[0]->type == GGML_TYPE_Q4_K || ...
false) && (ne11 >= 4 && ne11 <= 8)
一条文档里读不到的结论:注意 K 系量化(Q4_K / Q5_K / Q6_K / Q2_K / Q3_K)那一支的条件是 ne11 >= 4 && ne11 <= 8,下界是 4 不是 2。也就是说,对 Q4_K_M 模型,batch 为 2 或 3 时根本不走这条快路,只能退化成逐列的向量乘。
这正好对上了我表里那个乍看像噪声的反常:N=3 的延迟(28.11 ms)比 N=4(27.23 ms)还高。多算一个 token 反而更快,因为第 4 个 token 把它推进了快速内核的适用区间。我第一次看到这个数的时候以为是测量抖动,直到后来那次 13 档一次性重跑(-r 5)里它原样还在(N=3 是 27.81 ms,N=4 是 26.78 ms),才去翻的源码。
所以这台机器上验证成本的真实形状是三段:
| batch 内 token 数 | 走哪个内核 | 后果 |
|---|---|---|
| 1 | 纯向量乘 mul_mv | 基准,18.15 ms |
| 2–3(K 系量化) | 仍是逐列向量乘 | 有效率洼地,N=3 比 N=4 还慢 |
| 4–8 | 小批量融合内核 | 甜区,这一段每多一个 token 约 4.4 ms |
| ≥9 | simdgroup 矩阵乘 mul_mm | 直接跳到 ~79 ms,之后到 32 都不涨 |
四、端到端跑一遍:1.16× 和 0.49×
理论算够了,直接跑。目标模型 Qwen2.5-7B-Instruct-Q4_K_M,草稿模型同族 Qwen2.5-0.5B-Instruct-Q4_K_M(同一个 tokenizer,这是两模型方案的硬前提),贪心解码保证可复现:
llama-speculative -m Qwen2.5-7B-Instruct-Q4_K_M.gguf \
-md qwen2.5-0.5b-instruct-q4_k_m.gguf \
-p "Write a short technical explanation of how a hash table handles collisions, then give a Python implementation." \
-n 192 --spec-draft-n-max $K --spec-draft-n-min 0 \
--temp 0 -c 2048 -ngl 99 -ngld 99
基线用同一个 harness、同一个 prompt 的 llama-cli 测(跑了 4 次:56.9 / 57.9 / 57.4 / 57.8 t/s,取 57.6 t/s):
| 草稿长度 k | 验证 k+1 个 | 实测 t/s | llama.cpp 报告接受率 | 相对基线 |
|---|---|---|---|---|
| 1 | 2 | 66.67 | 71.43% | 1.16× |
| 2 | 3 | 59.22 | 61.63% | 1.03× |
| 3 | 4 | 65.28 | 52.00% | 1.13× |
| 4 | 5 | 64.07 | 45.65% | 1.11× |
| 5 | 6 | 50.52 | 38.18% | 0.88× |
| 7 | 8 | 42.26 | 29.46% | 0.73× |
| 11 | 12 | 28.20 | 21.00% | 0.49× |
| 16 | 17 | 25.24 | 14.98% | 0.44× |
峰值 1.16×,出现在 k=1。卡片上画的「Draft 5 Tokens」在我这台机器上是 0.88×,也就是净亏。
注意 k=2 那一行的塌陷(1.03×,夹在 k=1 的 1.16× 和 k=3 的 1.13× 中间)。我本以为是噪声,把 k=1..4 各重跑了 3 次:k=2 拿到 59.9 / 58.7 / 56.6,k=1 拿到 67.0 / 66.9 / 64.6,k=3 拿到 66.5 / 66.2 / 61.9。塌陷是稳定的——因为 k=2 要验证 3 个 token,正好掉进上一节那个「K 系量化下界是 4」的效率洼地。一行内核选择代码,在端到端吞吐上打出了一个肉眼可见的坑。
五、账本对得上:手算能预测这张表
上面两节的数据可以合起来验算。一轮的成本用实测的延迟表,一轮的产出用实测的周期数(n_drafted ÷ k 就是轮数):
其中 = 2.817 ms(0.5B 草稿模型的实测 decode,354.95 t/s)。全部代进去:
| k | 每轮产出 token | 草稿成本 | 验证成本 | 账本预测 t/s | 实测 t/s | 预测偏高 |
|---|---|---|---|---|---|---|
| 1 | 1.72 | 2.82 ms | 20.93 ms | 72.6 | 66.7 | +8.8% |
| 3 | 2.57 | 8.45 ms | 27.23 ms | 72.1 | 65.3 | +10.5% |
| 5 | 2.92 | 14.09 ms | 37.86 ms | 56.3 | 50.5 | +11.4% |
| 7 | 3.08 | 19.72 ms | 44.77 ms | 47.7 | 42.3 | +13.0% |
| 11 | 3.33 | 30.99 ms | 79.46 ms | 30.1 | 28.2 | +6.8% |
| 16 | 3.41 | 45.08 ms | 81.56 ms | 27.0 | 25.2 | +6.9% |
账本稳定地比实测乐观 7–13%(差额是 KV cache 回滚、采样、草稿侧 prefill 这些账本没算的开销),但形状完全吻合:从 k=5 开始的塌方,账本提前就算出来了。
再做一个交叉检查。用 k=7 那轮的实测(197 个 token / 64 轮 = 每轮 3.078 个),反解几何分布里的 α:
拿这个 α 回头预测 llama.cpp 该报告多少接受率:。实测报告 29.46%,误差 0.8%。(这一步我用一次性脚本二分求解验算过,不是心算。)
所以:α = 0.69 不低,账本没错,草稿模型也没选错。1.16× 就是这台机器上两模型方案的真实上限。
六、γ 有地板:为什么草稿模型没法再便宜
既然 α 没问题、账本没问题,那 1.16× 的锅就只能由两个系数背:验证成本的阶梯(第二、三节讲完了),和草稿成本 γ。
实测 γ = 2.817 ms ÷ 17.074 ms = 0.165。而按参数量算,0.63B ÷ 7.62B = 0.083。草稿模型贵了整整 2.00 倍。
为什么?先看层数:7B 是 28 层,0.5B 是 24 层——参数量只有 8%,层数却有 86%。每一层的 kernel 派发、同步、归一化都是固定开销,不随该层多小而缩水。
我当时想把这件事量化,于是拟合了一个两项模型「延迟 = a×层数 + b×权重字节」,两个方程两个未知数正好解出 a = 49.6 μs/层、b = 3.597 ms/GiB(对应约 298 GB/s,和这台机器 307 GB/s 的标称带宽只差 3%)。数字漂亮得让我差点直接写进文章。
然后我去证伪它。 下载同一个 0.5B 的 Q8_0 版本——层数不变,权重字节从 462.96 MiB 涨到 638.74 MiB——模型预测 3.434 ms(291 t/s)。实测:
| qwen2 1B Q8_0 | 638.74 MiB | 630.17 M | BLAS,MTL | 6 | tg192 | 328.44 ± 2.01 |
3.045 ms,328 t/s。模型偏高 12.8%,方向正好是致命的那个。
反过来看这两个点:字节多了 175.78 MiB,时间只多了 0.227 ms,隐含的边际带宽是 811 GB/s——比这台机器的物理带宽还高 2.6 倍。物理上不可能。结论只有一个:0.5B 的 decode 根本不是带宽受限的。 我那个线性模型的 b 项是虚的,只是两点拟合在零自由度下必然能对上而已。
换成最朴素、不能撒谎的算法——用权重字节除以实测时间,看每个模型跑到了标称带宽的百分之几:
| 模型 | 层数 | 权重字节 | 实测 ms/token | 等效带宽 | 占标称 307 GB/s |
|---|---|---|---|---|---|
| 7B Q4_K_M | 28 | 4464.64 MiB | 17.074 | 274.2 GB/s | 89.3% |
| 0.5B Q4_K_M | 24 | 462.96 MiB | 2.817 | 172.3 GB/s | 56.1% |
| 0.5B Q8_0 | 24 | 638.74 MiB | 3.045 | 220.0 GB/s | 71.7% |
大模型把带宽吃到了 89%,小模型只吃到 56%——将近一半的时间花在了不是搬权重的事情上。而且把小模型的权重加大(Q4→Q8),它的带宽利用率反而从 56% 涨到 72%,总时间几乎没动:典型的固定开销被摊薄。
(这台机器的带宽可达值我在本地部署看哪些参数那篇里单独测过,结论是标称的 72%~85%;这里 7B 的 89.3% 略高于那个区间,我倾向于是这次上下文更短、KV cache 读写更少,但没有做对照实验,算推测。)
于是 γ 有一条地板。 你没法靠继续缩小草稿模型把它压到参数比——缩到某个点之后,草稿模型的时间就由「它有多少层」而不是「它有多少权重」决定了。在我这台机器上,这条地板大约就是 0.165。
七、翻车记录:Qwen3.5 + M-RoPE,接受率 0.075%
上面的实验我本来是想用 Qwen3.5-4B + Qwen3.5-0.8B 这对做的——本地现成,同族,尺寸比更好看。跑出来是这样:
E init: the tokens of sequence 0 in the input batch have inconsistent sequence positions:
- the last position stored in the memory module of the context for sequence 0 is X = 210
- the tokens for sequence 0 in the input batch have a starting position of Y = 210
for M-RoPE, it is required that the position satisfies: X < Y
E decode: failed to initialize batch
n_drafted = 1337
n_accept = 1
accept = 0.075%
1337 个草稿 token,接受了 1 个。
原因是 Qwen3.5 用 M-RoPE,它要求送进来的位置编号严格大于 KV cache 里已有的最大位置。而投机解码干的事情恰恰是:草稿被拒了就把 KV cache 回退几格、用同样的位置重算。这两件事在语义上直接冲突,于是每一轮验证都在报错、每一轮草稿都被丢掉——程序不崩,只是悄悄退化成了「带着巨额额外开销的普通解码」,从 73 t/s 掉到 43 t/s。
这个坑值得单独记一笔,因为它不报致命错误。如果我只看最后那行 t/s,会得出「投机解码让我慢了 40%」的结论,然后去怪算法。真正该看的是 n_accept——它是 1,不是「有点低」,是结构性为零。
可复用的判据:跑任何投机解码方案,先确认 n_accept / n_drafted 落在 20%~70% 这个合理区间。低于 5% 基本不是模型不匹配,是某个机制(位置编码、tokenizer、采样器)在结构上不兼容,调参数是白费力气。
八、回到那四张卡片:它们都在攻同一项
现在可以给那张对比图一个定量的读法了。四种变体的差别只有一个:草稿从哪来。它们共用同一个验证箱子,而那个箱子的成本归硬件管。
flowchart LR
A["草稿从哪来?"] --> B["Two-Model<br>另跑一个小模型<br>本机实测 γ≈0.17"]
A --> C["EAGLE<br>吃目标模型 hidden state<br>的单层小头"]
A --> D["Medusa<br>同一次前向里<br>多长几个预测头"]
A --> E["LayerSkip<br>用自己的前几层<br>提前退出当草稿"]
B --> V["目标模型一次前向<br>验证 k+1 个 token<br>本机 k+1≤8 才便宜"]
C --> V
D --> V
E --> V
V --> O["接受最长正确前缀<br>再白送 1 个 bonus token"]
| 变体 | 草稿成本 γ 怎么降 | 代价 | 坏掉会怎样 |
|---|---|---|---|
| Two-Model | 不降,老老实实跑第二个模型 | 要维护两套权重、两份 KV cache、两个 tokenizer 必须一致 | γ 卡在地板上(本机 0.165),小模型上根本赚不回来 |
| EAGLE | 草稿头只有一层,吃的是目标模型算好的 hidden state | 要训练,且和目标模型绑定 | 头没训好 → α 掉,退化成「花钱买噪声」 |
| Medusa | 几个头挂在同一次前向里,不多跑任何一次前向 | 各头独立预测,彼此不知道对方猜了什么 | 独立性假设破产 → 多头组合出的候选串互相矛盾,α 崩 |
| LayerSkip | 直接用目标模型的前几层,零额外权重、零训练、共享 KV | 浅层的分布和全模型差得远 | α 太低,等于白跑一遍浅层 |
「收敛到单模型起草」这句话的量化版本是这样的:如果草稿完全免费(γ→0),把我实测的验证成本和每轮产出代进账本——
- k=3:27.23 ms ÷ 2.573 token = 10.58 ms/token → 1.64×
- k=4:29.01 ms ÷ 2.841 token = 10.21 ms/token → 1.70×
也就是说,光是把草稿成本干掉这一件事,就能把我这台机器从 1.11× 推到 1.70×。这比继续调 k、换草稿模型、调采样器的收益都大一个量级。这就是为什么四种变体里有三种都在做同一件事:把草稿方从「另一个模型」变成「目标模型身上长出来的一小块」。
需要说清楚的边界:EAGLE / Medusa / LayerSkip 我一个都没跑。上表里关于它们的机制描述来自各自的论文和那张卡片,γ→0 的那两个数字是我拿自己实测的验证成本做的反事实推演,不是它们的实测加速比——真实的 EAGLE/Medusa 还要付训练成本、头的前向成本,以及树形候选带来的更大 batch(那会把你直接推过第 9 个 token 的悬崖,账要重算)。
九、五分钟量出你自己机器上的悬崖
这一节是让你把上面的结论在自己机器上证伪或证实的最小步骤。需要:llama.cpp(brew install llama.cpp 或自行编译)、任意一个 GGUF 模型。耗时约 5 分钟,其中 4 分钟在等 benchmark。
# 1. 跑成本曲线(约 3-4 分钟)
llama-bench -m <你的模型>.gguf -p 1,2,4,6,8,9,12,16,32 -n 128 -r 5 -o md
# 2. 把输出的 t/s 换成一次前向的延迟,找那个跳变点
# (把上一步表格里的 N 和 t/s 填进来)
python3 -c "
rows=[(1,55.11),(2,95.54),(4,146.87),(6,158.46),(8,178.68),(9,113.95),(12,151.01),(16,199.73),(32,402.04)]
prev=None
for n,v in rows:
ms=1000*n/v
jump=f' <-- 跳 {ms/prev:.2f}x' if prev and ms/prev>1.4 else ''
print(f'N={n:3d} {ms:7.2f} ms{jump}'); prev=ms
"
要记录的一个数:跳变发生在哪个 N。那个 N 减 2 就是你机器上两模型投机解码的草稿长度上限(减 2 是因为要留出 bonus token 和验证时的当前 token)。
如果你在 CUDA 上跑,这个数几乎肯定不是 8——cuBLAS 的 GEMV/GEMM 切换阈值和 Metal 完全不同,而 vLLM / SGLang 这类服务端框架还会把多个请求拼进同一个 batch,届时验证 token 只是大 batch 里的几列,成本曲线的形状完全是另一回事。「悬崖在 8」是我这台机器的结论,「有一道悬崖,去把它量出来」才是可迁移的那部分。
十、那张卡片说「2-3×」,它错了吗
没错,但它省略了适用条件,而省略掉的恰恰是决定成败的那部分。一个持怀疑态度的读者这时候应该反问三件事,我一一接住:
「你的目标模型太小了。」 对,这是最关键的一条。7B 在我这台机器上单 token 前向只要 17 ms,投机解码要节省的那块饼本来就小;而草稿模型的固定开销是绝对值,不随目标模型变大而变大。换成 70B 目标模型,γ 会从 0.165 掉到 0.02 量级,账立刻就好看了。投机解码的收益随目标模型变大而单调上升——这是它在服务端成立、在笔记本上不成立的根本原因。
「你应该用服务端框架。」 对,vLLM / SGLang 会把投机 token 和别的请求拼成一个大 batch,那时候第 9 个 token 不是悬崖,只是一个已经在跑的 GEMM 里多出来的一列。但反过来说:在单请求、低并发的场景(本地助手、边缘设备、调试环境),batch 就是只有这几个 token,我量到的阶梯就是你会踩到的阶梯。
「1.16× 也是赚。」 也对。代价是多 500 MB 显存、多一套权重管理、以及 k 调错就变成 0.49× 的风险。在我这台机器上我的判断是不值,但这是个判断不是事实——如果你的场景对首 token 之后的延迟极其敏感,1.16× 可能就值。
小结
- 根本约束:投机解码之所以长成「起草 k 个 → 一次前向验证 k+1 个 → 接受前缀」这个形状,只因为一条硬件事实——一次前向的延迟在某个 batch 区间内对 token 数几乎不敏感。如果这条不成立(验证 k 个就付 k 倍时间),这个家族的期望收益恒为负,一行代码都不值得写。
- 崩点:这个区间有上界,而且是软件写死的。本机 Metal 后端的上界是 8(
ggml-metal-common.cpp:16的ne11 > 8)。草稿长度从 4 加到 11 跨过它,验证成本 27.2 → 79.5 ms,端到端从 1.11× 掉到 0.49×——不是慢一点,是慢一倍。 - 共同祖先:这是 CPU 分支预测 + 推测执行、数据库乐观并发控制的第 N 次再实例化——先赌着并行做,事后验证,冲突就回滚。三者的成败判据也一样:赌对的概率(α)× 赌注的成本(γ)要小于串行执行的代价。
- 可带走的动作:评估任何投机解码方案,除了 α、γ、k 这三个数,先量第四个数——你的硬件上,一次前向能免费吞下几个 token。前三个是算法的事,第四个是内核调度的事,而它比前三个加起来更能决定你会不会亏本。
通俗总结
把大模型生成文字想象成一个人在窄门里搬砖:每搬一趟,光是走过去走回来就要花掉绝大部分时间,手上拿一块砖和拿八块砖,来回一趟的时间差不多。投机解码就是让一个跑得快的小工先猜「接下来这八块砖大概是哪几块」,大师傅一趟把八块都验一遍,猜对的直接算数。
关键在于这扇门有个宽度。我量了自己这台电脑的门:一次能免费带过去八块,第九块开始要换一辆推车,而换车的时间比多搬的那一块贵得多。 卡片上画的四种流派,本质上都是在琢磨同一件事——怎么让那个「猜的小工」便宜到接近不要钱,因为门的宽度不归你管,只有小工的工钱归你管。
一句可以原样讲给同事听的话:投机解码不是「猜得越多越快」,是「一次能免费验几个就猜几个,多猜一个可能反而慢一倍」——而这个数你得自己在自己的机器上量,别信论文里的 5。
如果你手边有 llama.cpp,第九节那条 llama-bench 命令跑一次要四分钟,跑完你就知道自己那扇门有多宽了。我接下来想试的是把 LayerSkip 那条路在 llama.cpp 上手搓一遍——它是四种里唯一不需要训练的,理论上今晚就能跑;做出来了我更新到这篇。
参考来源
源码与工具
- llama.cpp / ggml Metal 后端:
ggml/src/ggml-metal/ggml-metal-common.cpp(ggml_metal_op_mul_mat_use_mm,本文那道悬崖的出处)与ggml/src/ggml-metal/ggml-metal-ops.cpp(小批量内核的派发条件与BS [2, ~8]注释)。行号对应我抓取时的master,会漂移,按函数名搜更稳。 - 实测环境:Apple M5 Pro / 24 GB / macOS 26.6.2,llama.cpp build
8ed274ef4(9630),ggml 0.15.1,Metal 后端。模型为 Qwen2.5-7B-Instruct GGUF 与 Qwen2.5-0.5B-Instruct GGUF。 - 本文起点的那张对比卡片:DailyDoseOfDS,“4 Speculative Decoding Variants”。
论文
- Leviathan, Kalman, Matias. 2023. Fast Inference from Transformers via Speculative Decoding. arXiv:2211.17192——两模型方案的原始形式与无损性证明
- Cai, Li, Geng, et al. 2024. Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads. arXiv:2401.10774
- Li, Wei, Zhang, Zhang. 2024. EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty. arXiv:2401.15077
- Elhoushi, Shrivastava, Liskovich, et al. 2024. LayerSkip: Enabling Early Exit Inference and Self-Speculative Decoding. arXiv:2404.16710
(上述四篇的机制描述我读的是论文本身,但它们报告的加速比我没有复现,本文也没有引用那些数字。)
站内相关
- Medusa 深读:解码慢不是算力问题,是搬运问题——这条主线的第一篇,讲 α 怎么被架构影响
- 投机解码的第二本账:长上下文把瓶颈从权重搬到了 KV Cache——第二篇,给出 α/γ/k 的加速公式。本文补的是那个公式里被当成常数的第四项
- 本地部署到底看哪些本机参数——同一台机器的带宽可达值、容量闸与算力闸
- Decode 速度上限公式——「权重字节 ÷ 带宽」这条账的完整推导
- prefill / decode 在 llama.cpp 里的调用链——本文反复用到的两阶段划分在源码里长什么样
- SGLang 内幕:RadixAttention、超售调度与期货流水线——第九节说的「服务端会把投机 token 拼进大 batch」在真实框架里怎么做