投机解码的四种变体,和我笔记本上那道 8 个 token 的悬崖

在 M5 Pro 上把两模型投机解码完整跑了一遍:教科书说 2-3×,实测峰值 1.16×;草稿长度从 4 加到 11,端到端反而从 1.11× 掉到 0.49×。根因是 ggml Metal 里一行 ne11 > 8,以及草稿成本 γ 的硬地板。

教科书说投机解码给你 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_Mllama.cpp 的一种 4 比特量化格式,属于「K 系量化」。本文所有模型都是这个格式
M-RoPE一种要求位置编号严格递增的旋转位置编码变体,本文第七节会因为它翻一次车

一、四张卡片背后只有一道账

投机解码的循环就三步:草稿方吐 k 个 token → 目标模型一次前向同时验证这 k 个 → 接受最长的正确前缀,再白送一个 bonus token。

所以一轮的时间是两项之和,产出是一个随机数的期望:

Tcycle=ktdraft+Tverify(k+1),E[tokens]=1αk+11αT_{\text{cycle}} = k \cdot t_{\text{draft}} + T_{\text{verify}}(k+1), \qquad \mathbb{E}[\text{tokens}] = \frac{1 - \alpha^{k+1}}{1 - \alpha}

投机解码的第二本账那篇里我算过前三个变量(α、γ、k),Medusa 深读里拆过 α 怎么被架构影响。两篇都把 Tverify(k+1)T_{\text{verify}}(k+1) 当成一个常数——「反正一次前向搬一遍权重的钱已经花了,多算几个 token 不要钱」。

这篇就是去量那个「常数」。剧透:它不是常数,而且它长得很难看。

这是全文的主线:四种变体拼命优化的是等式左边的 ktdraftk \cdot t_{\text{draft}},但等式右边的 TverifyT_{\text{verify}} 才决定了你能玩多大,而且它不归你管——它归你的 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 多花
155.1118.15 ms
295.5420.93 ms+2.79 ms
3106.7128.11 ms+9.97 ms
4146.8727.23 ms+9.09 ms
5172.3629.01 ms+10.86 ms
6158.4637.86 ms+19.72 ms
7157.5444.43 ms+26.29 ms
8178.6844.77 ms+26.63 ms
9113.9578.98 ms+60.84 ms
12151.0179.46 ms+61.32 ms
16199.7380.11 ms+61.96 ms
24303.2479.15 ms+61.00 ms
32402.0479.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 那段在真实数轴上要拉长很多,但高度是真的:那一段确实是平的。)

三个特征,每一个都会直接改写投机解码的账:

  1. N ≤ 8 的区间里,多塞 token 确实便宜:从 1 个到 8 个,延迟只从 18.15 ms 涨到 44.77 ms。多算 7 个 token 只多花 1.47 倍的时间——这就是投机解码赖以为生的那条物理规律。
  2. 第 9 个 token 是一道悬崖:44.77 → 78.98 ms,多一个 token 多花 34.21 ms,涨幅 1.76×。这个 token 一个人的代价,比前面 7 个加起来还贵。
  3. 越过悬崖之后完全平坦: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 ms78.98 ms1.76×
Qwen3.5-4B Q4_K_M(混合 SSM 架构)30.51 ms54.42 ms1.78×
Qwen3.5-0.8B Q4_K_M6.98 ms12.15 ms1.74×
Qwen2.5-0.5B Q4_K_M(24 层)5.30 ms10.29 ms1.94×

跨两代模型、跨两种架构(纯 Transformer 和混合 SSM)、跨 15 倍的参数量,悬崖的位置一模一样,倍数都在 1.74–1.94 之间。这已经不像模型的性质了,像是软件里写死的一个数

三、悬崖来自哪一行代码

是写死的。在 llama.cpp 的 Metal 后端里,选哪个矩阵乘内核是由一个布尔函数决定的(ggml/src/ggml-metal/ggml-metal-common.cpp:10-17ne11 就是 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
≥9simdgroup 矩阵乘 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/sllama.cpp 报告接受率相对基线
1266.6771.43%1.16×
2359.2261.63%1.03×
3465.2852.00%1.13×
4564.0745.65%1.11×
5650.5238.18%0.88×
7842.2629.46%0.73×
111228.2021.00%0.49×
161725.2414.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 就是轮数):

ms/token=ktdraft+Tverify(k+1)tokens/cycles\text{ms/token} = \frac{k \cdot t_{\text{draft}} + T_{\text{verify}}(k+1)}{\text{tokens} / \text{cycles}}

其中 tdraftt_{\text{draft}} = 2.817 ms(0.5B 草稿模型的实测 decode,354.95 t/s)。全部代进去:

k每轮产出 token草稿成本验证成本账本预测 t/s实测 t/s预测偏高
11.722.82 ms20.93 ms72.666.7+8.8%
32.578.45 ms27.23 ms72.165.3+10.5%
52.9214.09 ms37.86 ms56.350.5+11.4%
73.0819.72 ms44.77 ms47.742.3+13.0%
113.3330.99 ms79.46 ms30.128.2+6.8%
163.4145.08 ms81.56 ms27.025.2+6.9%

账本稳定地比实测乐观 7–13%(差额是 KV cache 回滚、采样、草稿侧 prefill 这些账本没算的开销),但形状完全吻合:从 k=5 开始的塌方,账本提前就算出来了。

再做一个交叉检查。用 k=7 那轮的实测(197 个 token / 64 轮 = 每轮 3.078 个),反解几何分布里的 α:

1α81α=3.078    α=0.692\frac{1-\alpha^{8}}{1-\alpha} = 3.078 \implies \alpha = 0.692

拿这个 α 回头预测 llama.cpp 该报告多少接受率:(3.0781)/7=29.69%(3.078-1)/7 = 29.69\%实测报告 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_M284464.64 MiB17.074274.2 GB/s89.3%
0.5B Q4_K_M24462.96 MiB2.817172.3 GB/s56.1%
0.5B Q8_024638.74 MiB3.045220.0 GB/s71.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:16ne11 > 8)。草稿长度从 4 加到 11 跨过它,验证成本 27.2 → 79.5 ms,端到端从 1.11× 掉到 0.49×——不是慢一点,是慢一倍。
  • 共同祖先:这是 CPU 分支预测 + 推测执行、数据库乐观并发控制的第 N 次再实例化——先赌着并行做,事后验证,冲突就回滚。三者的成败判据也一样:赌对的概率(α)× 赌注的成本(γ)要小于串行执行的代价。
  • 可带走的动作:评估任何投机解码方案,除了 α、γ、k 这三个数,先量第四个数——你的硬件上,一次前向能免费吞下几个 token。前三个是算法的事,第四个是内核调度的事,而它比前三个加起来更能决定你会不会亏本。

通俗总结

把大模型生成文字想象成一个人在窄门里搬砖:每搬一趟,光是走过去走回来就要花掉绝大部分时间,手上拿一块砖和拿八块砖,来回一趟的时间差不多。投机解码就是让一个跑得快的小工先猜「接下来这八块砖大概是哪几块」,大师傅一趟把八块都验一遍,猜对的直接算数。

关键在于这扇门有个宽度。我量了自己这台电脑的门:一次能免费带过去八块,第九块开始要换一辆推车,而换车的时间比多搬的那一块贵得多。 卡片上画的四种流派,本质上都是在琢磨同一件事——怎么让那个「猜的小工」便宜到接近不要钱,因为门的宽度不归你管,只有小工的工钱归你管。

一句可以原样讲给同事听的话:投机解码不是「猜得越多越快」,是「一次能免费验几个就猜几个,多猜一个可能反而慢一倍」——而这个数你得自己在自己的机器上量,别信论文里的 5。

如果你手边有 llama.cpp,第九节那条 llama-bench 命令跑一次要四分钟,跑完你就知道自己那扇门有多宽了。我接下来想试的是把 LayerSkip 那条路在 llama.cpp 上手搓一遍——它是四种里唯一不需要训练的,理论上今晚就能跑;做出来了我更新到这篇。

参考来源

源码与工具

论文

  • 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

(上述四篇的机制描述我读的是论文本身,但它们报告的加速比我没有复现,本文也没有引用那些数字。)

站内相关