上个月我们在内部跑一组投机解码(Speculative Decoding)的压测,负责同学拿着报告跟我说:小模型草稿采样加验证,吞吐提升到了原来的三点几倍,这个数字不错吧。我当时扫了一眼延迟拆分布,直接给他泼了盆冷水:你只看加速比,没看瓶颈份额,这三点几倍有一半以上是虚的,换个负载场景可能连两倍都保不住。
这不是抬杠。投机解码这两年确实火,各种论文和框架里动不动就写“2x-3x latency speedup”,看起来像白捡的性能。但只要你真正上手做推理优化,把pipeline一拆、延迟一分,就会发现一个很扎心的事实:投机解码的上限从来不由“看起来多快”决定,而是由那个无法被跳过、无法被并行、无法被压缩的“瓶颈份额”决定。换句话说,加速比数字再好看,也只是把非瓶颈部分的延迟压了压;瓶颈份额纹丝不动,你的总收益就封死在原地。
这篇文章我不打算堆论文公式,而是想把它当成一次工程复盘,把投机解码的收益边界、瓶颈定位方法、以及我在实际部署中总结的取舍经验讲清楚。给正在评估这项技术、或者已经被“加速比”忽悠过的人一个更冷静的视角。
1. 为什么“2倍加速”和“2倍吞吐”是两套叙事
先从一个最常见的误区讲起。很多人在讨论投机解码性能时,会把“加速比”当成一个单一数字,仿佛模型加了个草稿器,前向计算就整体变快了。但真实情况完全不是这样。
我习惯把投机解码的收益拆成两个指标来看:单请求延迟加速比和吞吐加速比。这俩指标在投机解码场景下经常严重背离。
单请求延迟加速比衡量的是“一个用户从发出请求到收到完整响应,时间缩了多少”。投机解码对这一点很友好,因为草稿模型等于提前替你“预测”了后面几个token的路径,大模型一次验证就能确认多个token,串行解码步数变少,首尾延迟确实能降下来。
吞吐加速比衡量的是“单位时间能处理多少个请求”。这里就复杂了,因为投机解码的草稿模型虽然小,但它也是计算资源;验证阶段虽然一次性并行验证多个token,但显存带宽和计算量并没有凭空消失。当系统已经在高并发、长序列、大批次的负载下满载运行,草稿模型的额外占用很可能反过来侵蚀吞吐。
我在评估一个投机解码方案时,会强制团队在报告里同时列出这两列,并且标注测试负载特性:
| 指标 | 短序列小批次 | 长序列大批次 | 混合真实负载 |
|---|---|---|---|
| 单请求延迟加速比 | 往往最高(1.8x-2.5x) | 次之 | 波动大 |
| 系统吞吐加速比 | 可能虚高(受注意力计算占比影响) | 容易失控甚至负优化 | 取决于瓶颈份额 |
这还没完。别忘了“投机解码”这个名字背后其实有不同流派,常见的是草稿模型(draft model)+目标模型(target model)的瀑布式验证,也有后来出现的自投机(self-speculative)和树状投机采样。它们的收益曲线完全不同。瀑布式最容易理解,但它的“单模型串行草稿+大模型并行验证”结构本身就是瓶颈份额的主要来源。后面我们会反复回到这个点上。
说白了,加速比只是一个结果表象,你要回答的真正问题是:你的系统里,哪一段延迟在投机解码过程中依然不可压缩?那一段就是瓶颈份额。
2. 把解码延迟拆成两份账:能被投机压缩的与不能的
要理解瓶颈份额,就得先把一次投机解码的延迟结构彻底拆开。
假设目标模型解码一个token的延迟是 T,草稿模型解码一个token的延迟是 t,草稿窗口长度是 k。一个典型的投机解码循环长这样:草稿模型快速自回归生成 k 个候选token,目标模型对这 k 个候选进行一次前向验证(因为做一次forward可以同时处理k个位置),然后根据验证结果决定接受哪几个token,再把不匹配的修正回来。
听起来很美好:目标模型的串行解码从 k 步变成 1 步,节省了大约 ( (k-1) \times (T - t) ) 的时间。但理想收益只在接受率(acceptance rate)等于100%时成立。现实里的接受率通常在50%-80%之间徘徊。
2.1 一次解码循环里,时间到底花在哪了
我把一次投机解码循环的固定开销拆成四块:
- 草稿模型串行生成 k 个token,耗时约 ( k \times t )。这是无法绕开的,因为草稿模型也是自回归,得一个个来。
- 目标模型单次前向验证 k 个token,这部分耗时取决于实现方式;如果采用并行forward,相对单个token解码的增长不是线性的,但显存带宽压力会显著上升。
- 前缀/上下文计算。目标模型验证时,之前的历史token对应的Key-Value cache还是要处理的,这部分和投机解码完全无关,是纯串行瓶颈。
- 投机窗口大小和验证拒绝后的重算开销。如果目标模型拒绝了若干个草稿token,那这段被拒绝的位置就得用真实token重新进入下一步,这部分属于“失败成本”。
这里头第1、第3、第4项,都属于不可压缩或无法完全压缩的部分。我称之为“瓶颈份额”。
2.2 “瓶颈份额”的严格定义:从Amdahl定律说起
很多人听到“瓶颈份额”这个词第一反应是Amdahl定律:加速比上限是 ( 1/(1 - P) ),其中 P 是能被并行化的部分占比。投机解码其实正是Amdahl定律的完美样本。
我定义一个“可投机压缩份额” ( s ):
[ s = \frac{\text{投机解码能省掉的目标模型串行解码时间}}{\text{整个循环的总延迟}} ]
那加速比上限就是:
[ \text{上限} = \frac{1}{1 - s} ]
听着熟悉吧?这就是Amdahl定律的变形。这里的 ( 1-s ) 就是瓶颈份额,它由上面说的草稿生成时间、前缀KV计算、无法跳过的采样开销、拒绝重算构成。
举个直观数字:假设你测出投机解码“看起来”每秒能接受2.2个token,目标模型单个token解码要10ms,草稿模型单个要1ms。单看局部,你省了大约 ( 2.2 \times 10 - 2.2 \times 1 = 19.8ms ) 的解码时间,而原来生成2.2个token需要22ms,表面上加速快2倍。但别忘了,一次投机循环里,草稿模型生成2.2个token本来就花了2.2ms;目标模型验证那一刻,显存带宽被KV cache占掉一大截,性能直接打折扣;如果请求前缀特别长,前缀KV加载就是一笔高额纯固定成本。把这些都算进总延迟里,s 可能连0.4都不到,加速比上限无限逼近1.6x,而不是2.2x。
这里我要给一个非常重要的工程忠告:永远不要接受只给你看“每token输出时间”对比的基准报告,你必须要求对方给出端到端延迟拆分。只要拆分里没有“草稿时间”“验证时间”“前缀KV时间”三行以上,那就说明对方没做过合格的性能分析。
3. 现实世界的三类瓶颈:带宽、验证开销、分布偏移
前面说的是理论框架,现在落到工程环境里去看瓶颈份额到底由什么决定。
3.1 内存带宽瓶颈:KV cache从来不是免费午餐
现代GPU推理,尤其是到了7B、13B这个级别,绝大多数场景下都是内存带宽受限,而不是算力受限。目标模型解码每个token要读取全量权重和整个序列的KV cache;投机解码验证k个token时,虽然计算量只做了一次forward,但KV cache的读取量并不会因为投机而减少多少,甚至可能因为多token验证改变了访问模式,带来更差的缓存局部性。
我实测过一个13B模型,在批量大小为4、上下文长度2048时,投机解码验证阶段的显存带宽占用几乎和普通解码batch=4时持平,省下的只是少量算子启动开销。这说明什么?说明带宽瓶颈份额极高,你把计算步数从3步降成1步,但带宽开销几乎没降,加速比自然上不去。
优化带宽瓶颈的首要思路是减少KV cache的访问量,比如引入更激进的KV压缩、采用窗口注意力,或者把草稿阶段对KV的依赖简化。我后面会展开讲。
3.2 验证开销:草稿模型是串行计算,躲不掉的
很多人以为草稿模型足够小(1B以内),它的生成时间可以忽略不计。这种想法在CPU上可能沾边,在GPU上是完全错误的。
GPU上的小模型推理有个特点:即使模型很小,算子启动、kernel调度、显存搬运这些固定开销一分都不会少。我做过一个极端实验:用一个0.5B草稿模型在同样一张GPU上跑,目标模型是7B。草稿模型生成一个token的延迟是0.9ms,而目标模型解码一个token是11ms。看起来草稿确实快10倍以上,但一旦进入投机循环,草稿生成k个token是串行的,k=4时,草稿阶段就要3.6ms,这还没算草稿模型的采样、候选序列重组、显存同步。算下来,草稿时间占了整个投机循环的很大一块。你越是把k调大想赌更高的接受率,草稿串行时间就越长,瓶颈份额呈线性上升,最后加速比反而掉头向下。
所以在工程实现上,我一直把草稿模型视作“和验证阶段同一个管道的串行设备”,不能把它当成免费的午餐。这也是为什么树状投机采样近来很受欢迎:它用一棵树替代一条链,用并行探索多条候选路径来稀释草稿串行时间。
3.3 分布偏移:接受率不是一个固定数字
接受率是投机解码里最容易误用的指标。很多人测一次平均接受率70%,就以为每种输入下都是70%。
事实上,接受率极度依赖草稿模型与目标模型采样的分布对齐程度。如果草稿模型是从目标模型蒸馏出来的,且蒸馏得足够好,那么它在目标模型高置信度的token上接受率很高;但在一段逻辑性很强的代码生成、数学推导、长尾知识问答场景中,草稿模型几乎等于瞎猜,接受率可能跌到20%以下。
分布偏移直接导致瓶颈份额动态变化。你在benchmark上测出的加速比,覆盖的是一个混合了高接受率、中接受率、低接受率样本的平均值。一旦真实流量里低接受率片段变多,整体收益就会迅速恶化。
我自己踩过的一个坑是:在一个代码生成服务上部署投机解码,最先跑的标准代码生成benchmark上平均接受率有72%,测出延迟加速比2.1x。上线后切到真实代码补全流量,接受率掉到55%以下,端到端加速比直接缩水到1.3x。原因很简单:benchmark里的代码重复度高、模式固定,草稿模型容易猜中;真实补全里上下文复杂、涉及业务命名和最新库调用,草稿模型的知识边界根本覆盖不到。
所以定位瓶颈时,我把分布偏移当做一个重要的变量,而不是一个静态的“接受率”。后面会讲怎么针对它做优化。
4. 用实测数据定位你自己的瓶颈份额
理论说完了,现在上实操。你手头有一堆积压的推理日志、几个候选模型,怎么用最小的实验成本判断该不该上投机解码、上限在哪里?
4.1 一套可复现的延迟拆解实验方案
我的做法是写一个带插桩的推理脚本,不依赖任何现成框架的“假加速比”统计,而是手动测量每个阶段:
- 记录完整请求的端到端时间。
- 记录目标模型单独解码阶段的平均单token延迟 T。
- 记录草稿模型生成阶段平均单token延迟 t(如果在同一张卡上测试,注意不要和验证阶段并发)。
- 记录投机循环中验证阶段的耗时 V,注意这一步要算上前缀KV load。
- 记录每个请求的接受token数和拒绝token数,计算实际接受率。
- 手动计算“如果没有投机解码,同样的请求需要多少时间”,得到真实加速比。
这六步看起来简单,但很多人做实验时偷懒,直接调框架自带的benchmark脚本,完全不区分前缀长度、批量大小、请求复杂度。我建议测试至少做三组:短上下文(128-512)、中上下文(2048-4096)、长上下文(8000+),每组各跑200个真实请求样本,取P50和P95。
4.2 三组典型配置下的瓶颈占比
我在自家测试集上,用同源蒸馏草稿模型跑过一组拆分数据,拿来做例子再合适不过:
| 场景 | 目标模型 | 草稿模型 | 草稿时间占比 | 验证时间占比 | 前缀KV时间占比 | 理想加速比 | 实测加速比 |
|---|---|---|---|---|---|---|---|
| 短文本 | 7B | 0.5B蒸馏 | 18% | 42% | 12% | 2.8x | 1.7x |
| 中文本 | 7B | 0.5B蒸馏 | 12% | 37% | 30% | 2.5x | 1.5x |
| 长文本 | 7B | 0.5B蒸馏 | 8% | 25% | 48% | 2.2x | 1.2x |
注意看,随着上下文长度增加,前缀KV的计算和读取时间占比从12%涨到48%,而草稿时间占比在下降。这时瓶颈已经从“草稿串行成本”转移到了“前缀KV处理成本”,加速比上限被严重锁死。长文本场景下,投机解码几乎没有存在的意义,因为大头根本不在解码token本身,而在每个token都要处理的KV读取上。
这个表也说明另一个问题:同一套方案,换个负载,结论完全不同。光盯着加速比是没有意义的,必须把瓶颈份额的变化趋势画出来。
4.3 什么情况下一眼就能判定投机解码无效
根据我的经验,出现下面任何一个信号,就可以直接放弃投机解码方案了:
- 草稿模型和目标模型不是同源,接受率长期低于40%,端到端加速比会在1.0x附近震荡,加上草稿开销后甚至可能负优化。
- 系统已经在长上下文、大批次下跑到显存带宽95%以上,此时任何额外的草稿计算都会挤占主模型的前向,加速比大概率是负的。
- 推理任务以非自回归任务为主,或者要求绝对确定性的输出,投机解码的采样随机性会带来不必要的复杂度。
- 目标模型已经用上了极致的量化(如4bit),单个token解码本身很快,草稿模型相对优势缩小,投机解码的黄金窗口变小。
我见过太多团队花几周时间调投机解码,最后因为需求场景压根不适合,白忙一场。判断适配性的第一步,永远是拿真实负载做延迟拆分。
5. 工程优化:把份额抢回来的具体打法
如果拆分完,发现瓶颈份额确实存在,但总体仍有盈利空间,那就到了发挥工程能力的阶段。下面几个优化方向都是我在实践中验证过的,按性价比排序。
5.1 草稿模型的选型和压缩:别贪大
草稿模型并非越大越好。草稿模型的核心不是“聪明”,而是“快”。只要它能在很低的延迟下生成候选token,哪怕接受率略低一点,整体平衡可能更好。我通常推荐的配置是:草稿模型参数量是目标模型的5%-10%,并且尽量和目标模型共享分词器或嵌入层,这样能减少一次嵌入映射开销。
此外,草稿模型一定要做近源训练。最优办法是从目标模型的checkpoint做蒸馏,或者直接裁剪目标模型中间层来初始化草稿。差异过大的草稿(比如拿个通用开源小模型)在分布上天然吃亏,接受率到时候会让你怀疑人生。
5.2 采样策略对了,接受率能涨一截
很多人不知道,投机解码的接受率不仅取决于模型,还取决于采样策略。目标模型在验证阶段通常使用softmax分布,而草稿模型如果用了太高的温度(temperature),候选token的随机性过大,接受率自然稀碎。反过来,如果目标模型采样温度被调低到接近0,输出近确定性,接受率则会升高。
我常用的做法是给草稿模型的logits做温度缩放(temperature scaling),让它在高置信度位置上更加尖锐,减少低概率token的乱猜。实测中,这招能让平均接受率提升5-8个百分点,代价几乎为零。另一个可以尝试的是把草稿采样限制在“top-p不太激进”的区间,防止草稿输出跑偏。
5.3 树状投机采样:用并行替代串行,稀释瓶颈
如果你发现草稿模型的串行生成时间成了瓶颈,树状投机采样(树状解码)是一个值得研究的方向。它的思路很简单:草稿模型一次生成的不再是一条链,而是一棵树上的多个分支;目标模型验证时一次性验证整棵树上的所有候选节点,最终选择一条最优路径。由于目标模型验证一个树和验证一条链在计算上的差距远小于树带来的覆盖率提升,这等于用一次验证的代价换取了多条路径的搜索,实质上是把串行草稿时间拉低。
当然,树状投机采样对实现要求更高,GPU kernel需要支持可变形状的候选token批量验证,调度也更复杂。我的建议是:先做线性投机解码,测出瓶颈到底在哪;如果草稿串行时间占比超过25%,再考虑换树状方案。
5.4 长上下文场景的抢夺:从KV cache下手
长上下文场景下,瓶颈份额主要在前缀KV读取。这里能打的牌有:
- 对草稿模型的输入做KV压缩,让草稿阶段不需要读取完整的KV cache,只依赖近似表示。
- 用分层注意力或窗口注意力,把KV cache限制在局部窗口,减少读取量。
- 探索“验证时并行处理多个KV分片”等自定义kernel,让带宽利用率更平滑。
这些优化难度递进。如果你只是快速落地,我更推荐先用“投机窗口动态调整”:在长上下文的稳定段落使用大窗口赌接受率,在内容多变、KV读取量大的段落自动降为小窗口甚至禁用投机,把瓶颈份额高点绕过去。
5.5 别忘了把草稿模型也塞进Cuda Graph
最后一个常被忽略的点:草稿模型虽然小,但不用Cuda Graph的话,每次kernel launch的开销照样伤。我在0.5B草稿模型上试过,不用Cuda Graph时草稿生成一个token要1.4ms,用了之后降到0.7ms,直接腰斩。很多框架默认优化重心放在目标模型上,草稿模型的调度开销完全没被管,这一块反而是抢回瓶颈份额最容易到手的地方。
6. 回到标题:为什么说上限写在瓶颈份额里
现在把整篇文章串起来,回到“投机解码的上限写在瓶颈份额里,不在加速比里”这句话。
加速比是一个输出指标,它描述的是“优化后的结果”。但结果不会告诉你为什么是这么多,也不会告诉你还有多少空间。瓶颈份额是“不可压缩的延迟占总延迟的比值”,它直接划定了加速比的理论天花板。你可以无限优化草稿模型、无限增大投机窗口、无限提高接受率,但只要瓶颈份额不小,最终加速比就会无限逼近那个低得令人失望的天花板。
我实际做过一组对比:方案A接受了率很高,平均0.8,但草稿时间占比大、前缀开销高,最终加速比1.6x;方案B接受率只有0.65,但草稿模型极快、对KV cache依赖低,瓶颈份额小,最终加速比反而有2.3x。这个案例比我讲一百句理论都直观:你在加速比上看到的,是瓶颈份额允许你看到的;你看不到的,才是真正限制你的。
所以我的建议是,不管你做不做投机解码,先把你的推理服务完整延迟拆分指标立起来。草稿耗时、验证耗时、KV读取耗时、采样耗时、调度耗时,每一项都数字化,再谈优化。只要这套指标在手,你不仅能用好投机解码,还能顺手发现别的优化点,比如算子融合、显存搬运、调度开销,这些都是同一层级的收益。
最后分享一个工作习惯:每个性能优化项目结束后,我会在方案文档末尾附一张“瓶颈份额”变化图,标明优化前和优化后瓶颈分别在哪一段。下次有人拿着加速比数字来问我,我就请他先在这张图里找到那根最粗的柱子——那才是所有故事的起点。