我给自己写的推理引擎接上了投机解码。18 条测试 case,输出和不开投机时逐位一致,正确性满分。
速度从 92.0 tok/s 掉到 65.26,慢了 29%。
同一台 M1 Max、同一个模型,旁边一个开源引擎也开着投机解码。它的猜中率只有 0.38,比我的 0.51 还低,跑出来 133.4,比我不开投机还快 1.45 倍。
猜得比我差,跑得比我快。这事说明猜中率根本不是胜负手。
更难受的是,这个结局七月就被一个除法预言过。我当时先算出「小赚 6.6%」,晚上发现分母用错了,改完变成「怎么猜都亏」。十月真跑了一遍端到端,除法赢了。
想直接拿那个除法看第 2 章;想知道我七月怎么把亏算成赚的,看第 3 章。
1. 投机解码到底在赌什么
一句话:让一个便宜的小模型先猜下一个词,大模型拿到猜测后验一下。猜对了,这一轮出两个词;猜错了,出一个,不亏正确性。
我用的这个模型自带一个小预测头(MTP,多 token 预测),专门干「猜下一个」这件事,猜一次大概 2.6 毫秒。
赌注藏在「验一下」里。大模型要在两个位置上都算一遍:一个确认当前词,一个检查猜的那个对不对。
decode 阶段(一个字一个字往外吐的阶段)几乎全在从内存搬权重,算力大部分时间闲着。所以理想的验证是:权重只搬一遍,顺手把两个位置一起算掉,耗时跟单步 decode 差不多。这样猜对一次就是白赚一个词。
如果做不到一起算,就只能老老实实跑两遍前向。那就是另一回事了,往下看。
2. 那个除法
一轮出多少个词,除以一轮花多少时间,再跟不开投机的速度比:
加速比 = (1 + p) × T_decode ÷ (T_verify + T_draft)
p 是猜中率,T_decode 是不开投机时一个 token 的时间,T_verify 是一轮验证,T_draft 是小头猜一次。这就是一个除法,没有经验系数。
代我的数。不开投机 92.0 tok/s,所以 T_decode = 1000 ÷ 92.0 = 10.87 ms。验证我最后压到墙钟 21.0 ms,草稿约 2.6 ms,p = 0.51:
(1 + 0.51) ÷ (21.0 + 2.6) ms = 1.51 ÷ 23.6 ≈ 0.064 token/ms ≈ 64 tok/s
实测 65.26
对上了,差 2%。
2.1 一个能直接判死刑的推论
假如验证就是老老实实前向两次,T_verify = 2 × T_decode。代进去:
加速比 = (1 + p) × T_decode ÷ (2 × T_decode + T_draft)
< (1 + p) ÷ 2
≤ 1
p 最大就是 1。所以只猜一个词的投机解码,只要验证得跑两遍前向,猜得再准都是亏的。七月那份账里 p = 1 的结果是 0.971 倍,就是这个上界再扣掉草稿那 0.643 ms。
我的验证是 GPU 时间 20.4 ms,约 decode 的 1.92 倍,还在「两遍」的区间里。逐类拆开看,每一类算子都是 2.0 倍,没有哪一类被两个位置摊薄。说白了,dense 权重真的被读了两次。
2.2 回本线
把加速比 = 1 反解出来:
T_verify ≤ (1 + p) × T_decode − T_draft
p = 0.51 时:1.51 × 10.87 − 2.6 ≈ 13.8 ms,约 1.27 倍 decode。
也就是说,验证两个位置只能比单步 decode 贵 27%,才刚刚打平。我现在贵 92%。这个差距靠调参数是调不回来的,得换 kernel 结构。
3. 七月我是怎么把亏算成赚的
七月第一次算这笔账时,我拿的单 token 时间是 8.128 ms。这个数来自逐块计时加总:
线性注意力 4.356 + 全注意力 1.424 + MoE 1.266 + 输出头 0.902 + 其余 0.18 ≈ 8.128 ms
而整图实测的速度是 91.4 tok/s,也就是 10.941 ms 一个 token。
我用 8.128 算验证成本,用 91.4 当基线。同一个猜中率 0.6457:
错的:1.6457 ÷ (2 × 8.128 + 0.643) = 1.6457 ÷ 16.899 ms ≈ 97.4 tok/s → 对 91.4 是 1.066 倍
对的:1.6457 ÷ (2 × 10.941 + 0.643) = 1.6457 ÷ 22.525 ms ≈ 73.1 tok/s → 0.799 倍
逐块加总漏掉了 2.813 ms,相当于加总值本身的 34.6%,全是块与块之间的东西:调度开销、encoder 切换、barrier、CPU 侧编码。
34.6% 的误差,刚好够把亏 20% 翻成赚 6.6%。分子和分母用了两把尺子,结论方向就反了。
当时的补救计划是做「一次读权重、算两个位置」的 kernel。我先在输出头上试,前提是成立的:在输出头那么大的矩阵上,跑两遍的耗时是跑一遍的 1.990 倍,权重真的读了两次,理论上有一倍带宽可以省。
结果三个实现版本,速度只有「直接跑两遍」的 0.441 到 0.656 倍。寄存器溢出、两路输入流打架、撞算力墙,三个假说我都做了对照,全被自己的实验推翻了。根因到今天也不知道。这就是为什么十月的验证还是 1.92 倍。
4. 怎么拆一个只报接受率的投机解码跑分
4.1 先问验证一轮是 decode 的几倍
回到开头那个 133.4 的引擎。它每轮让草稿模型猜 7 个词,主模型一次验 8 个位置,一整轮 30 到 33 ms,草稿和验证都算在里面。猜中率 0.38,每轮平均出 3.1 到 4.9 个词。
同一个除法:大约 4 个词 ÷ 31 ms ≈ 129 tok/s,跟实测 133.4 一个量级。
对照我这边:只验 8 个位置这一步就要 51 ms,还没算草稿。它一整轮约等于我 2.9 步 decode,摊到 8 个位置,每个位置只要 0.37 步。
所以它赢在验证便宜,跟猜得准不准关系不大。p 是分子里的一项,T_verify 是分母,分母差几倍,分子怎么补都补不回来。
4.2 再问接受率是什么口径
模型卡上官方写的接受率是 88.58%。我拿它当目标追了好几周,后来才发现那是温度采样下的数,而我跑的是贪心解码。
两个口径没法比。温度采样要两边随机撞上才算命中,贪心两边都取最大值。我自己实测贪心口径是 64.6%。拿 88.58% 当靶子,本身就是口径错误。
4.3 最后问测了多长、测的什么内容
同一份代码,我测 48 步时接受率 53.2%,拉到 128 步变成 34.7%。短序列的置信区间宽到 ±14.3%,53.2% 那次纯属运气好。
内容也会把数字拉得很开。我试的第二种草稿方法(DFlash2,一次猜一长串),在代码类 prompt 上平均每轮接受 4.59 个,中文只有 0.5 到 1.12 个。一个只在代码题上跑出来的接受长度,对中文聊天基本没有参考价值。那条线最后每 token 约 19.5 ms,约 51 tok/s,比不开投机慢 44%,直接判死。
看任何投机解码的跑分,按这个顺序问:验证一轮是 decode 的几倍;接受率是什么采样口径、多长、什么内容;报的是端到端实测还是推算。三句里答不上一句,那个数就先别信。
5. 我这些数哪里不能信
- 一台机器、一个模型。 M1 Max,一个 35B 的 MoE 模型,4bit。它 40 层里有 30 层是线性注意力,每个位置要接着上一个位置的状态往下算,这部分(占该层 53.2% 时间)读的是状态而不是权重,天生没法两个位置共享。换成纯 dense 模型,验证能便宜多少我没测。
- 测试集小。 18 条 case,中英文和代码各 6 条,prompt 33 个 token,每条生成 128 个,测前降温到 65°C,取后 12 条的中位数。长对话下的表现没测。
- 跟那个 133.4 只能比速度。 它用的是自己的量化,输出跟我逐字一致的前缀长度只有 3 到 128 个 token,没法比「谁更对」。
- 0.51 和 0.646 对不上。 端到端猜中率 0.51,七月 CPU 参考实现测的是 0.646。第一版只有约 0.11,是草稿头的 KV 没吃 prompt,修掉后到 0.51。剩下这段差距原因还没查清。
- 第 2 章的 64 是量级对齐。 验证从 24.2 ms 一路压到 21.0 ms,吞吐也从 45.2 一路涨到 65.26,我没有逐版本把两者一一配对,只是最终版对上了。整套账本对端到端实测的误差在 ±5% 以内。
- 「真正批量验证能到 1.38 倍」是推算,没有实测。
- 两个位置一起算的 kernel 为什么慢,根因未知。 继续查需要 GPU 硬件计数器,我现在的测量工具拿不到。
6. 动手接投机解码之前
- 量整图的 T_decode,用 1000 ÷ 实测 tok/s,别用逐块计时加总。
- 量一次「同时算 2 个位置」的前向,除以 T_decode。结果 ≥ 2,停手,只猜一个词的方案不可能赚。
- 用你自己的采样方式、自己的内容、至少 128 步,测猜中率 p。
- 代入 T_verify ≤ (1 + p) × T_decode − T_draft,算出回本线。
- 回本线比现在的验证低一大截,先去写批量验证的 kernel,草稿模型放后面换。
- 别人的跑分只报接受率,当没看见。