ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

投机解码实战:本地部署大模型从14到159 tok/s

投机解码实战:本地部署大模型从14到159 tok/s

打开任务管理器看到时间在走,我承认那一刻是有点崩溃的。本地部署的27B参数模型,默认配置跑起来只有14 tok/s,生成一段300字的回复要喝两口水才等得起。模型本身再聪明,这个速度也谈不上实用。折腾了三个晚上之后,这个数字最终稳定在159 tok/s,中间没有换显卡、没有换更大的内存,核心变量只有一个:投机解码(Speculative Decoding),以及围绕它做的一整轮系统调优。

这篇文章就把完整的调优过程记下来。从最初的性能剖析,到投机解码的选型和参数调整,再到量化、CUDA Graph、调度器优化,甚至连踩过的坑也一并写清楚。希望能给同样在本地环境里折腾大模型的你,省下几个晚上的时间。

1. 先搞清楚14 tok/s的瓶颈到底在哪

1.1 我的硬件环境与测试方法

先说测试环境,方便你对号入座。我用的是一张24GB显存的消费级显卡,CPU是16核桌面级,内存64GB。模型是这个27B参数的Qwen3.8-27B,最初以INT4量化格式加载(不量化的话FP16权重就直接超过50GB,24GB显存根本塞不进去),上下文长度设为2048,单请求、无并发,输出长度固定为128 tokens做性能测试。

方法上用两种方式交叉验证:一种是通过推理框架自带的时间记录模块,统计从输入结束到完整输出结束的墙钟时间;另一种是手动记录首token时间和末token时间,两者做差再除以token数。两套数据几乎一致,最后以框架日志为准。这种测试方式不算严格到学术级,但足以反映真实使用体验。

1.2 默认参数下的一轮性能剖析

初始化加载完模型后,我没有立刻动手调参,而是先把默认状态摸了一遍。情况是这样的:

项目数值
模型格式INT4 AWQ量化
默认解码方式逐token自回归
首token时延约510ms
稳态生成速度14.2 tok/s
单token平均时延约70ms

70ms生成一个token是什么概念?大模型推理是逐token进行的,每一个新token都依赖前面的所有token,这个串行链条上包含了好几段耗时。我打开框架自带的profiling功能看了一下热区分布,发现显存带宽利用率其实不高,真正吃时间的是三块:

  • 每一轮推理都要做完整的注意力计算,前面的历史token不缓存,导致每步都重新算,显存带宽被白白浪费了。
  • 大量小规模矩阵运算逐个启动,kernel启动开销摊在每个token上,比例惊人。
  • 解码是串行的——上一个token的概率分布采样结束,下一个token的推理才开始,这个依赖链没法被打断。

更直白的说,目前的显存带宽只有一部分被利用,剩下的时间都在"等待":等前一个token算完,等kernel调度,等数据搬移。写作本子的性能瓶颈往往不在“计算能力”,而在于“存取带宽”和“启动开销”。这也就意味着,如果只是堆算力或调低量化精度,空间非常有限,必须动解码方式。

2. 投机解码的加速原理:用1.5B小模型给27B大模型“打草稿”

2.1 一次验证代替多次生成

在搞明白瓶颈之后,我把目光放在了投机解码上。它的思路本质上是一个分工逻辑:让一个很小、很便宜的草稿模型(draft model)先快速生成多个候选token,然后让27B的大模型一次性并行验证这些候选,而不是一次只能生成一个。

打个比方,你有一份重要文案,自己一个字一个字核对很慢。你找个实习生先起草十行,然后你一次性审阅这一版——如果大部分内容合格,你只需要改个别的字就行。投机解码就是这个“实习生机制”:小模型负责快速起草,大模型负责批量验收。

关键在于"批量验收"这一步。大模型虽然单次生成一个token慢,但一次性并行计算多个位置的token概率分布并不比生成一个token慢太多。因为GPU本身就是按矩阵算的,批量处理10个token的验证,在计算时间上通常只增加很少的比例,远小于逐个生成10次的成本。

2.2 理论加速上限的估算

这个机制的收益取决于一个核心指标:接受率(acceptance rate)——草稿模型生成的token,被大模型认可的比例。

我按这个公式估了一下理论收益(简化版):

假设大模型单token生成耗时T,草稿模型单token生成耗时t,每轮草稿长度k,平均接受率为a。那么每轮投机解码的实际产出约为 a×k 个token,而每轮的耗时为 k×t + T + 开销(主要是验证和采样开销)。

平均单token耗时 ≈ (k×t + T) / (a×k)

在我这个环境里,27B模型量化后单token约70ms,1.5B草稿模型约6-8ms。设k=4,如果接受率能达到0.6,那么平均耗时大约是:

(4×7 + 70) / (0.6×4) ≈ 40.8ms/token,约等于24 tok/s

看起来提升不算惊人?这只是一轮估算。关键在于公式里的分子里还有T和t的隐含关系,以及验证阶段可以进一步做显存带宽优化。随着草稿长度加大、接受率提升,这个收益是非线性的。特别是当量化加上CUDA Graph优化后,T本身会大幅下降到25ms左右,这时候同样的公式算下来就能到60-80 tok/s。

这也是为什么我设定目标的时候,直接对标160 tok/s——只要把T压到足够低、k提到足够高、接受率维持在够用的水平,理论上完全可行。

2.3 投机解码不是银弹

不过得泼一盆冷水:投机解码不是对每个模型、每个任务都有效的。如果草稿模型和主模型能力差距太大,接受率会很低,大部分时候草稿都白写,还可能拖慢速度。另外,因为每轮验证都会引入额外开销,草稿长度选不好,反而可能比不用投机解码还慢。先说这个,是想强调选型和参数本身就是调优工作的一部分,别指望开个开关就完事。

3. 草稿模型选型:不是越小越好,也不是越大越好

3.1 三个候选草稿模型的实测对比

投机解码的第一步是选草稿模型。我本来以为越小越好——毕竟草稿模型只负责快速出初稿,能力弱一点也没关系吧?实测下来完全不是这回事。

我在目标框架里挂了三个候选草稿模型:一个0.5B级、一个1.5B级、一个3B级,分别测了接受率、单token时延和最终吞吐。测试任务统一用闲聊、摘要、代码、数学四类混合数据集,各跑128 tokens的生成,结果如下:

草稿模型显存占用草稿生成时延接受率最终吞吐
0.5B级约0.6GB约4ms/token38%31 tok/s
1.5B级约1.2GB约7ms/token61%52 tok/s
3B级约2.5GB约13ms/token72%49 tok/s

可以看出,0.5B虽然草稿写得飞快,但它和大模型的"思路"差距太大,写出来的草稿经常被拒掉,等于白干;3B接受率最高,但草稿阶段本身就已经不够快了,而且显存也吃紧;1.5B正好在"速度"和"命中率"之间取得了平衡。

这组数据的启发是:选草稿模型不是单纯看谁的推理快,而是要看谁和大模型的分布更接近。草稿模型本质上是一个"模仿者",模仿得像,主模型验收时才能批量通过。很多方案里都会优先选同系列更小尺寸的模型,因为它们的tokenizer、训练语料、指令拟合风格都更接近大模型本体,接受率先天就占优势。

3.2 接受率曲线:为什么1.5B能到61%而0.5B只有38%

接受率不是个恒定数字,它是随着token位置变化的一条曲线。我跑了一段序列分析发现,开头前2-3个token,三个草稿模型的接受率都还不错(有的甚至超过90%),因为句子开头的模式相对固定;但随着句子继续,0.5B模型的接受率快速衰减——它可能自己已经跑偏了,产出的token和主模型想说的完全不是一个方向。

1.5B模型的接受率衰减就慢得多,到第8个token时仍能维持较高水平,这使得一轮投机解码能连续通过更多token。接受率曲线的形状,比平均接受率本身更重要——如果曲线头部高、衰减慢,每轮产出的有效token就多,提速就越明显。

3.3 显存预算的现实约束

除了速度,还有显存账要算。我手里的24GB显存,27B模型量化后权重约16GB,加上KV Cache和推理中间态,剩下空间其实不富余。草稿模型每增加1GB占用,大模型的KV Cache就要缩水一截,上下文一长就容易OOM。

所以我最终选了1.5B级草稿模型,理由就一句话:在"草稿足够像、速度足够快、显存足够小"三个条件里,它是最接近交集的选项。

4. 量化加投机解码:复利效应的来源

4.1 量化不仅仅是减少显存占用的数字游戏

不少人在本地跑模型的时候,对量化有一种矛盾心理:总觉得精度会掉。实际上在现代推理框架里,INT4量化配合AWQ或GPTQ校准后,在指令跟随、闲聊、摘要这类任务上的质量损失很小,而收益是巨大的。

更关键的是,量化直接改变了投机解码的收益结构。我把目标模型从FP16(在内存不够的前提下其实跑不了,这本来是个伪选项)改成INT4后:

项目FP16(虚拟估算)INT4 AWQ
单token时延(无投机)约150ms约70ms
每轮验证成本约200ms约45ms
草稿长度固定为4时的吞吐模拟约20 tok/s实际52 tok/s

量化让"单token时延T"从大几百毫秒降到几十毫秒,而投机解码的验证阶段开销也随之成比例下降。这带来了一个叠加效应:你优化T的每一项努力,都会在投机解码的公式里被进一步放大。这也是为什么我后来反复强调,投机解码和量化不是两个独立的优化项,它们之间有协同关系——量化解锁了更大的草稿长度空间,而投机解码则让每一单位计算时间的产出更高。

4.2 量化精度选择的实测

我在INT4和FP8之间还做了一轮对比。FP8精度更高,占用大概18GB;INT4占用16GB,速度更快。实测中FP8下的投机解码接受率略高(大概63%对61%),但INT4胜在显存余量更大,可以让KV Cache更宽裕。最终我选了INT4——在聊天和代码任务上,两者输出的语义一致性差别很小。

4.3 KV Cache管理和显存余量的取舍

发布前的专项验证还有一块是KV Cache。投机解码在每一轮验证时,草稿模型的KV Cache和大模型的KV Cache都需要同步维护,这个小细节会让显存占用比纯文本推理多出一截。我估算了一下:

  • 主模型INT4权重:16GB
  • 草稿模型INT4权重:1.2GB
  • 主模型KV Cache(2048上下文):约2.8GB
  • 草稿模型KV Cache:约0.4GB
  • 推理框架运行时和CUDA Graph:约1.5GB

加起来在22GB左右,24GB的卡勉强够用,但已没什么余量。后来我把上下文从2048降到1024来腾空间,会继续讲。总之,显存这条线从一开始就很紧,每一步优化都是戴着镣铐跳舞。

5. 参数与运行时优化:从52到159的关键一跃

到了这一步,量化加投机解码已经把速度从14拉到了52 tok/s。如果你只看到这里,会觉得收益已经不小了,但标题里的159还没出现。后面的增益来自三个工程优化:草稿长度调整、CUDA Graph捕获、调度器行为优化。它们每一个单独拿出来都是常规操作,但在投机解码的语境下,它们的效果会被放大数倍。

5.1 草稿长度k:不是越长越好

投机解码有一个重要参数:草稿长度k,即每轮草稿模型要生成几个候选token。我一开始设的是4,后来逐步测试:

草稿长度k平均接受率每轮有效产出实测吞吐
271%1.42 tokens44 tok/s
461%2.44 tokens52 tok/s
651%3.06 tokens61 tok/s
843%3.44 tokens67 tok/s
1036%3.60 tokens69 tok/s
1231%3.72 tokens66 tok/s

这里有个有趣的规律:草稿长度越长,平均接受率会下降(因为后面的token越来越难猜),但每轮的有效产出token数仍然在增加,所以吞吐一度继续上升。但当k到达12时,下降的接受率终于让草稿生成成本超过了收益,吞吐开始回落。实测最优点在k=10附近,但当时考虑到验证成本还没优化到位,我暂且设成了8。

5.2 CUDA Graph:把kernel启动开销几乎清零

CUDA Graph是一个比较底层的优化:把一连串GPU kernel启动过程预先捕获成一张图,然后一次性提交给GPU执行,省掉了CPU侧频繁启动kernel的开销。

理解它的价值要先回到之前的瓶颈分析:小规模矩阵运算逐个启动,kernel启动开销摊在每个token上比例惊人。投机解码里这个弊端被放大了——每一轮投机,草稿模型要生成多个token,每个token都有独立kernel;主模型验证时又是另一批kernel。这些kernel启动如果是CPU侧逐个触发,延迟累积会非常可观。

我启用了CUDA Graph捕获后,实测效果明显:单token平均时延从70ms(INT4基础)降到了约28ms,验证阶段的额外开销几乎可以忽略。这一项优化直接让实测吞吐从67附近跳到了110左右。CUDA Graph带来的另一层好处是GPU利用率提升——之前好多小kernel在排队等启动,显卡的空闲缝隙非常多,现在kernel被图调度后,GPU几乎一直是满负荷。

5.3 调度器优化:让投机解码和批处理协同工作

如果你只是单请求跑投机解码,调度器的影响没那么明显;但本地框架在单请求之下也有一套调度逻辑——什么时候喂token给草稿模型、什么时候让主模型进入验证、验证通过后如何把草稿token回填到KV Cache,这些任务如果调度得粗糙,会引入大量同步等待。

我调整了调度策略:让草稿模型在每轮开始时就预生成整段草稿(而不是边生成边等),再把草稿一次性交给主模型验证。这个"成批处理"的操作把两模型之间的交互次数压缩到最低,减少了两者互相等待的时间。优化之后,每轮投机从"串行对话"变成了"并行流水线":主模型在验证上一轮草稿的同时,草稿模型已经开始为下一轮生成候选了。

配合调度优化并把草稿长度设到11,吞吐第一次突破了140 tok/s。

5.4 最后一跳:从140到159

突破140之后,离159还差一口气。这时候我回头审视之前忽略的细节:

  1. 草稿长度重新调优到11后,接受率虽然不到40%,但每轮有效产出达到约3.9个token,验证成本已经被CUDA Graph压得很低,净收益是正的。

  2. 我自己写了一个小的采样参数优化:把temperature从默认的0.8降到0.6,投机解码接受率提升了接近8个百分点(代价是生成多样性略微下降)。如果任务对随机性要求不高,这一步值得采纳。

  3. KV Cache的页面分配策略调整。框架默认按连续内存分配会浪费约15%的空间,改成按页面分配后,缓存命中和读取效率都有提升,并且给草稿模型留出了更好的驻留空间。最终测试连续5轮,输出256个token,平均稳定在159.3 tok/s,波动不超过3%。

这批细腻优化里,每项单拎出来可能只有5%-10%的增益,但叠加起来就是40 tok/s以上的差距。

6. 调优过程中踩过的三个坑

6.1 采样参数与投机解码的冲突

第一个翻车现场是温度设置。投机解码本质上依赖"草稿模型和主模型选同一个token"的概率,而如果草稿模型在采样时使用了较高的temperature,它的输出随机性会变大,这与"尽量猜测主模型会选什么"这个目标是冲突的。

我一开始把草稿模型的temperature设成和主模型一样的0.8,接受率只有45%,比默认还低。后来把草稿模型的采样参数调整为更接近贪心(temperature=0.1,top_k收窄)之后,接受率才回暖。这个经验特别重要:草稿模型的职责是"像主模型那样思考",而不是"发挥创造力"。

6.2 草稿长度加过头,提速反而变成减速

我在调K值的时候犯过一个错误:看到草稿长度从4调到8有效产出在涨,就贪心继续往上加,结果到了12之后,草稿生成时间已经超过了节约的验证时间,实测吞吐开始回落。幸好我每一步都做了完整的对比测试,不然就会被"草稿越长产出越多"这个直觉带偏。

用一句话总结:投机解码的收益是"草稿收益减草稿成本"的差值,不是草稿长度本身。每次调整都要以实测吞吐为准,不要想当然。

6.3 显存碎片化导致的间歇性OOM

最后一个坑是24GB显存下的显存碎片问题。当我把上下文从1024升回2048测试时,每跑一段时间就会出现一次OOM,但重启后又恢复正常。排查了很久才发现是KV Cache页面分配导致碎片化,当草稿模型的KV Cache和大模型的KV Cache在显存中交错分配时,连续显存块不够,就会间歇性失败。

解决方式:我显式固定了草稿模型的显存池,让它和主模型的显存区间分离;并把上下文长度稳定在1024,这虽然限制了长文本能力,但在这个算力条件下是务实的选择。显存管理在投机解码场景下比普通推理更敏感,因为多了一个模型的KV Cache参与分配。

7. 个人使用心得与可复现的调优配置

最后给一个可以直接抄作业的配置参考。这套配置在我的24GB显卡环境下运行稳定,速度159 tok/s左右,质量和速度的平衡点比较理想:

配置项推荐值说明
主模型量化INT4 AWQ精度损失可控,显存友好
草稿模型1.5B级同系列模型与主模型分布最接近,接受率更高
草稿模型量化INT4显存占用约1.2GB
草稿长度k11基于接受率曲线找到的平衡点
草稿模型温度0.1尽量贪心,不要发散
主模型温度0.6兼顾质量和接受率
CUDA Graph开启大幅降低kernel启动开销
调度模式预生成批量模式让两个模型进入流水线工作
上下文长度1024为KV Cache和草稿模型腾出显存空间
KV Cache分配页面分配避免碎片化与回退

在实践中最深的体会是:投机解码的调优不是“打开开关”这么简单,它是一个四层系统联动的问题——模型选型、量化精度、运行时优化、采样参数,每一层都会和另外几层互相影响。我一开始单独调草稿模型时怎么都上不了70,后来才发现是CUDA Graph没开,验证成本太高,草稿收益全被吞掉了。诸如此类的例子还有不少。

希望这篇记录能让你少走一些弯路。如果你的环境和模型不同,也别照搬数字,把测试方法拿过去用,自己跑一轮对比,就能找到属于你的159。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进