返回博客

2026.10.09

猜中率 100% 也只有 0.97 倍:我给自研推理引擎加了投机解码,为什么越猜越慢?

在 M1 Max 上给自己写的推理引擎接投机解码,输出逐位一致,速度却从 92.0 掉到 65.26 tok/s。同一台机器上另一个引擎猜中率只有 0.38,跑到 133.4。这篇给一个除法,动手之前就能算出投机解码在你的引擎上是赚是亏;再给三条判据,用来拆只报接受率的跑分。

推理引擎投机解码性能Apple Silicon方法论

我给自己写的推理引擎接上了投机解码。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. 动手接投机解码之前

  1. 量整图的 T_decode,用 1000 ÷ 实测 tok/s,别用逐块计时加总。
  2. 量一次「同时算 2 个位置」的前向,除以 T_decode。结果 ≥ 2,停手,只猜一个词的方案不可能赚。
  3. 用你自己的采样方式、自己的内容、至少 128 步,测猜中率 p。
  4. 代入 T_verify ≤ (1 + p) × T_decode − T_draft,算出回本线。
  5. 回本线比现在的验证低一大截,先去写批量验证的 kernel,草稿模型放后面换。
  6. 别人的跑分只报接受率,当没看见。