大模型推理加速这件事,这几年说穿了就是三笔账:显存账、带宽账、并发账。量化、投机采样、PD分离,恰好是这三笔账最典型的解法。量化把权重和KV Cache的存储降下来,投机采样把解码阶段的内存带宽用得更值,PD分离则把Prefill和Decode两类性格完全不同的任务拆开,让GPU集群的调度不再互相拖后腿。我最近用nano-vllm这类教学级推理工程把这三项技术完整串了一遍,发现论坛和文档里很少把它们放在同一个框架里讲清楚,所以这一篇直接按照“原理—参数—工程—排坑”的顺序写,不做官方文档的搬运工。
1. 量化:把权重从高精度降到够用精度
1.1 量化为什么能“白拿”性能
量化本质上是一个舍入问题:把原本用FP16或BF16表示的权重映射成INT8、INT4这样的低比特整数。以7B模型为例,FP16权重一张卡大概是14GB,压到INT8是7GB,要是用INT4甚至不到4GB。省出来的这10GB显存不是放着好看的,它可以变成更大的并发batch、更长的上下文KV Cache,或者让原本需要双卡部署的模型直接塞进单卡。
很多人以为量化损失的是精度,其实精度只是表象。真正关键的是离群值(outlier)问题:大模型的权重分布集中在某个很窄的区间,但少部分异常大的权重一旦被粗暴舍入,误差会被层层放大。所以量化不是简单的四舍五入,核心是选好scale和zero_point,把浮点区间映射到整数区间。基本公式就两个:
量化:q = round(r / scale) + zero_point 反量化:r_approx = (q - zero_point) * scalescale一般用absmax或min-max方式统计出来,zero_point用于处理非对称分布。这里的门道在于,scale的选择直接决定了量化误差落在哪里。如果只用一个全局scale,离群值会把整个区间拉大,普通权重反而分到很粗糙的档位,模型就崩了。所以实际工程中很少做tensor级量化,基本都是按通道(per-channel)或者按组(per-group)分别算scale。组大小group size为128是实践中最常见的配置,再小一点精度更好但存储索引开销变大,再大一点省得更多但误差受不了。
为什么量化能提速?这就要说到自回归推理的特性了。生成阶段每一步都要把模型权重和KV Cache重新读一遍,访存量远大于计算量,属于典型的内存带宽瓶颈场景。权重从FP16降到INT8,字节数砍一半,单token生成延迟理论上就能提升到接近2倍。计算量没有减少,但因为瓶颈在搬运不在计算,所以收益非常直接。这就是为什么量化经常被称为“白拿的性能”。
1.2 部署语境下怎么选量化方案
现在主流方案可以分成几个流派,选型时不能光看精度数字,还要看你的场景是服务端还是本地,是离线还是在线。
| 方案 | 精度类型 | 关键特点 | 适合场景 | 需要注意的点 |
|---|---|---|---|---|
| GPTQ | 2~4bit离线量化 | 用二阶信息做误差补偿,按层逐层优化 | 服务端部署、追求极致压缩 | 需要校准数据集,流程较重 |
| AWQ | 4bit激活感知量化 | 观察激活分布,保护重要通道 | 中小模型、精度敏感场景 | 依赖激活统计,校准集质量影响大 |
| GGUF | 4bit为主的文件封装 | 配合llama.cpp、Ollama生态,加载即用 | 本地电脑关、边缘设备 | 服务端能力较弱,API化偏麻烦 |
| FP8 | 8bit浮点 | 精度接近FP16,硬件原生支持更好 | Hopper及以上GPU生产环境 | 显存只省一半,压缩上限有限 |
GPTQ玩的是“误差补偿”,它把量化当成一个逐层最小化的问题来解,量化完一层就把误差算出来,再传给下一层做修正;AWQ玩的是“信道保护”,先观察激活值分布,找到哪些权重通道对输出影响最大,然后给这些通道分配更高精度。两者的取舍其实不复杂:校准数据能不能贴合你的真实业务分布?能,GPTQ很稳;不能,AWQ往往更保险。
GGUF要单独说一句,它不算严格意义上的量化算法,而是一种模型封装格式,但很多初学者直接把它和量化画等号。实际在GGUF里面可以通过不同的quant line控制精度,从Q2_K到Q8_0一整套档位。本地部署用它图个方便,真要放到高并发服务里,还是建议用GPTQ或AWQ的TensorRT/autogptq生态。
顺带提一下,nano-vllm这类教学引擎通常不会完整实现GPTQ那种逐层补偿逻辑,而是用最简单的per-group RTN让你理解量化的数据流。这没什么不好,RTN虽然精度最差,但是最容易从代码层面看清scale、zero_point、反量化这些概念是怎么流转的。真到了生产环境,再替换成AWQ这类成熟方案就行。
1.3 KV Cache能不能跟着量化
权重量化只是第一步,KV Cache才是长上下文场景下的显存大户。KV Cache的显存公式是:
KV Cache字节数 = 2 × 层数 × KV头数 × head_dim × max_seq_len × batch_size × 精度字节数以7B模型28层、4个KV头、head_dim=128、FP16精度为例,单个token大约产生2×28×4×128×2=57344字节。如果batch_size=8、上下文4096,单请求就要吃约1.8GB显存。这里都还没算权重,显存账单就已经很夸张了。
KV Cache量化通常有两种做法:一种是FP8,损失非常小,硬件支持好,很多推理框架默认打开;另一种是INT8,需要对每个head或每个channel单独维护scale,实现复杂一点但省得更多。实操里我的建议是:权重可以激进一点用INT4,KV Cache保守一点用FP8。因为KV Cache里的数值是动态变化的,一旦量化误差把某个位置的注意力分布带偏,误差会随着后续生成累积起来,比权重量化更容易出问题。
还有一个小技巧值得单独记下来:在部署长文本模型的时候,优先打开KV Cache量化。它不改变模型本身的输出分布,对精度影响比权重量化小得多,但收益非常直观——原来是显存不够只能减小batch,打开之后并发数立刻涨一截。对于服务端来说,这可能是投资回报最高的一个开关。
2. 投机采样:用“猜”代替“想”来加速解码
2.1 自回归瓶颈到底卡在哪
解码阶段为什么慢?因为每一步只能生成一个token,而生成这个token时,必须把整个模型的权重和当前上下文对应的KV Cache全部读一遍再做一次前向计算。GPU的计算核心大部分时间都在等数据搬运,而不是在算。打个比方,就是每次出门买一瓶水,都要开车跑一趟超市,时间全花在路上。
投机采样(Speculative Decoding)的思路很反直觉:既然跑一趟超市这么费劲,那就一次把好几天的东西都买回来。具体到模型推理,就是先让一个小模型快速猜出一串token,然后让大模型一次性验证这批猜测,而不是一个token一个token地挤牙膏。理想情况下,小模型猜得越准,大模型的每次前向就能“带出”越多有效token,解码吞吐几倍提升不是吹出来的。
2.2 一次验证K个token的流程
整个流程可以用四个步骤说清:
- 草稿模型(draft model)先自回归生成K个候选token,K通常取4到8。这一步很快,因为模型小。
- 把原始输入和这K个候选token拼接起来,送入目标模型做一次前向,目标模型可以为所有位置同步算出logits。
- 从目标模型的logits中逐位采样并与草稿模型的预测做对比,遇到第一个不一致的token就停下来。一致的部分直接接受。
- 更新KV Cache,把拒绝位置得到的token作为新的起点,继续下一轮循环。
这里要注意一个细节:被拒绝的token也不能直接丢,标准做法是从目标模型在该位置的分布里重新采样一个token补进去。有些实现为了分布一致性还做了“修正采样”,基本原理和拒绝采样对齐,避免引入分布偏差。如果这一步处理不当,投机采样的输出分布会和原始解码不一致,这在精度评测里很容易埋雷。
用一个生活中的例子帮助理解:你让实习生先写一段草稿,自己再快速审一遍。实习生写得越好,你每次审阅能通过的段落就越长;实习生要是写得离谱,你就得从头改到某个错误的地方,可能比自己写还慢。投机采样的收益,很大程度上就取决于这个“实习生”靠不靠谱。
2.3 影响提速收益的几个参数
第一个参数是草稿长度K。K小了,一次验证带出的有效token少,收益不明显;K大了,草稿模型自身要跑更多步,而且目标模型并行验证的序列变长,计算开销也在涨。通常4到8是比较合理的区间,具体哪个好要在自己的场景里扫一遍。
第二个参数是接受率α。在简化模型下,投机采样的加速上限大约接近1/(1-α):α=0.7时,理论上限约3.3倍;α=0.5时只剩2倍左右。这个公式不用死记,但要理解背后的含义——草稿模型猜得不准,后面所有并行优化都白搭。所以选择草稿模型时,不只看它快不快,更要看它的预测分布和目标模型有多接近。同系列小模型通常是最稳的选择,比如同公司发布的0.5B/1.5B版本,因为训练数据分布天然一致。
第三个参数是采样温度。直觉上,温度越高,分布越乱,草稿模型越难猜中。所以投机采样在低温度、甚至greedy解码的场景下效果最好;如果你业务需要高随机的生成,提速比例会明显缩水。
第四个隐藏参数是令牌生成长度。如果一条请求只生成二三十个token,投机的启动开销还没回收就结束了;而输出长度几百上千的代码生成、长文创作场景才是它真正发力的地方。判断一个场景适不适合投机采样,先问自己三个问题:输出够不够长?采样温度低不低?有没有合适的草稿模型?
3. PD分离:把服务拆成两段独立流水线
3.1 Prefill和Decode是两种性格的任务
Prefill和Decode虽然都叫Transformer推理,但它们对硬件资源的需求几乎完全相反。Prefill阶段是针对整个Prompt长度的一次性计算,大量矩阵乘同时进行,计算密度大,GPU利用率高;Decode阶段则是单token串行生成,每一步都在读KV Cache和权重,内存带宽被压满,计算单元却经常闲着。
用一个粗糙的类比:Prefill像是在工厂批量生产零件,讲究的是流水线满负荷;Decode像是给每一个零件做个性化精修,每步都得重新上机器。这两种任务放在同一个引擎里,就像把批量生产线和定制工坊合并到一间厂房,调度怎么做都是互相抢资源。Decode请求把并发batch占满时,新来的Prompts要排队等Prefill,首token延迟直接被拖高;反过来,Prefill一拥而入又会挤掉Decode的GPU执行机会,影响每token生成速度。PD分离的思路很简单:既然性格不合,那就分家。
分离之后收益有多明显?在长Prompt场景下特别突出。比如一个5000token的Prompt在Prefill节点一次性算完,传输KV Cache到Decode节点,后者只需要专心做单步生成,避免了任务切换和调度器反复权衡带来的开销。集群吞吐量提升非常直观,这也是vLLM、SGLang这些框架陆续推向PD分离方向的核心原因。
3.2 分离之后KV Cache成为核心资源
PD分离之后,KV Cache不再只是显存里的临时数据,而成了需要跨节点传输的核心资源。Prefill节点算完Prompt之后,要把整段KV Cache送到Decode节点,这个传输过程会成为新的瓶颈。还是拿7B模型举例,假设把KV Cache量化到FP8,512个token的序列大约产生2×28×4×128×512×1=14MB数据;换成4096长度就是112MB。并发请求一多,传输量就很可观。
所以工程上要解决两个问题:第一,用足够快的数据通路,比如基于InfiniBand的RDMA,直接把显存里的数据搬走,而不是走一遍CPU内存;第二,想办法减少需要传输的KV Cache体积,KV Cache量化在这里又是大显身手的地方。除此之外,PD分离还意外解锁了前缀复用能力——如果一批请求的Prompt前缀相同,Prefill节点可以把这部分KV Cache缓存起来,下次请求直接命中,省掉重复计算再传输给Decode节点,这比传统单机缓存更优雅。
当然,PD分离也有它的代价。最明显的是运维复杂度:集群里有了两种异构节点,调度、扩缩容、故障恢复都要分别处理。如果你的并发请求量不大,单机加连续批处理就够用,强行上PD分离反而会因为网络开销拖累延迟。判断标准也很简单:首token延迟和吞吐哪个更敏感?并发规模多大?网络设施能不能支持高速KV传输?想清楚这三件事再动手。
3.3 调度器视角看PD分离
从调度器视角来看,PD分离最终要落实成两套队列:prefill_queue和decode_queue,各自有独立的显存预算和执行预算。调度循环里要做的事情变成三件:
- 来了新请求,先进prefill队列,凑够一定数量后打包成batch执行一次Prefill。
- Prefill完成后,把KV Cache传给对应的Decode节点,请求转入decode队列。
- Decode节点按调度策略(例如先到先服务或抢占式优先级)持续执行单token生成,直到遇见EOS或达到最大生成长度。
这个模型在nano-vllm教学代码里体现得很纯粹:调度循环只有两个队列,没有一堆复杂的优先级。实际生产的vLLM引擎还会在这个基础上叠加连续批处理,允许在Decode过程中插入新的Short Prefill。但理解核心的prefill/decode分离,再去看vLLM那些复杂策略就不会晕。
顺带记一个线上容易踩的坑:Prefill节点和Decode节点的batch策略不能共用一套参数。Prefill更看重单次计算能塞多少token,Decode更看重并发请求数量和延迟均衡。我曾经在一个部署里把两者的最大batch设成一致,结果Decode节点因为batch太大,单步延迟从30ms涨到80ms,体感极其明显。这两套参数一定要分开调。
4. 在nano-vllm工程里把三块技术串起来
4.1 模型层的量化接入点
nano-vllm这类教学级工程的代码量不算大,但胜在结构清晰,很适合做功能串联。量化接入的第一个点是模型加载模块:读取权重后不直接以FP16存储,而是把scale和zero_point算好,和量化后的权重一起保存在内存里。前向计算时再按需做反量化。
实际代码里常见的做法是这样的:
class QuantizedLinear(nn.Module): def __init__(self, weight_int8, scales, zero_points): super().__init__() self.weight = weight_int8 self.scales = scales self.zero_points = zero_points def forward(self, x): # 反量化动态计算,避免存储完整浮点权重 weight_fp = (self.weight.to(torch.float32) - self.zero_points) * self.scales return F.linear(x, weight_fp)这段代码虽然简单,但它展示了量化推理最重要的一件事:权重用低精度存储,计算时才反量化。很多初学者会写成每层forward的时候都读一遍全精度权重,那就完全失去了量化的意义。训练工程里可以重点看两个细节:一是scale计算是在on-the-fly还是预计算,二是反量化放在哪个张量算子之后能复用CUDA kernel。生产框架里这些都被封装进定制的kernel,但数据流思想是一样的。
4.2 推理循环里的投机采样
投机采样的接入点不在模型内部,而在调度循环和生成循环之间。你需要同时维护两个模型实例:一个小草稿模型和一个目标模型,然后在原本的while循环外层再套一层“草稿-验证”逻辑。伪代码可以写成这样:
def speculative_generate(model, draft_model, tokens, max_gen, k=4): while len(tokens) < max_gen: # 草稿模型生成k个候选 draft_ids = draft_model.generate(tokens, max_new_tokens=k)[0] candidate = draft_ids[len(tokens):] # 目标模型一次前向,验证所有候选 logits = model(tokens + candidate) accepted = [] for i in range(len(candidate)): next_token = sample_from_logits(logits[i]) if next_token == candidate[i]: accepted.append(next_token) else: break tokens += accepted if len(accepted) == 0: # 一个都没接受,直接从目标模型采样 logits_for_next = model(tokens) tokens.append(sample_from_logits(logits_for_next))这个伪代码省略了很多工程细节,比如EOS处理、KV Cache增量更新、批量状态管理,但核心逻辑就是这么多。nano-vllm代码里值得反复看的是KV Cache在验证过程中怎么工作:草稿生成阶段小模型要自己维护一份KV Cache,目标模型验证时也要把相关位置的增量KV Cache合并好,这一步做得不好,投机采样容易因为显存碎片或者cache不完整出各种诡异bug。
4.3 显存管理与两阶段队列
nano-vllm这类引擎在显存管理上一般会把KV Cache预先分配成块,每个逻辑序列按需领取,这叫“显存块式管理”。它和PD分离的衔接很有意思:Prefill节点给请求分配KV Cache块,传输阶段把这块数据序列化发到Decode节点,Decode节点再把它挂到自己的块管理器上。整个流程里,块分配和释放的时机成了最关键的状态机逻辑,远比比模型forward复杂。
我自己的经验是,学习推理引擎源码时,按数据流转顺序去读效率最高:先看请求怎么进来,再看到哪里做显存分配,再看调度器怎么选择执行,最后看结果怎么返回。很多框架代码看起来绕,是因为它们把调度、显存、执行混在同一个事件循环里。nano-vllm把这几个阶段剥得更开,适合作为读大源码前的起点。
5. 常见问题排查与归档
5.1 量化后输出崩了怎么办
量化模型出现乱码、重复、崩溃,八成不是量化本身的锅,而是某一步配置出了问题。我把常见的现象和排查方向整理成一张表:
| 现象 | 可能性原因 | 处理建议 |
|---|---|---|
| 输出完全乱码 | 反量化公式写错或scale/zero_point加载错误 | 先打印一层反量化后的权重,检查数值范围 |
| 短句正常、长文崩 | KV Cache量化精度不足 | KV Cache降回FP8,或改用per-head scale |
| 特定领域回答质量差 | 校准数据集与业务分布不符 | 用真实业务Prompt重新校准,或换AWQ |
| 量化后速度反而慢 | 反量化操作重复执行、没有缓存 | 检查是否每次forward都重复计算scale |
这里最容易被忽略的是校准数据。很多人用通用语料校准后放到垂直领域,发现效果崩了,第一反应是想换更大的模型,但其实重新准备一批垂直领域语料做校准就能救回来。AWQ在这个场景下通常比GPTQ更抗造,因为它显式观察激活分布,对领域漂移的容忍度更高。
5.2 投机采样反而变慢
投机采样是有开销的:草稿模型自身要占显存、要花时间跑生成,目标模型要一次性算更长的序列。如果实测变慢,按这个顺序排查:
第一,看草稿模型是不是选太大了。我用过一个接近目标模型一半参数的草稿模型,结果它自己生成K个token的时间超过了目标模型验证K个token的时间,整体反而慢20%。草稿模型应该越小越好,优先选择同系列0.5B~1B级别。
第二,看K值。K设成16的时候,草稿模型生成完16个token,目标模型的验证计算量也上来了,而且只要草稿在中间某一步猜错,后面大部分验证都白算了。把K降到4或8重新测。
第三,看采样温度。温度太高,接受率掉得厉害。如果你的业务必须高温采样,投机采样收益就会严重缩水,这时候不要硬上,考虑结合PD分离或者直接放弃投机。
第四,检查草稿和目标模型的分词器是否完全一致。两个模型词表不一致是投机采样的经典暗坑,ID对不上导致接受率直接归零,代码看起来一切正常,但提速就是没有。
5.3 PD分离场景下的传输问题
PD分离最难排的问题几乎都集中在KV Cache传输环节。症状一般是:Prefill阶段快了不少,但首token延迟反而变高,或者吞吐量上不去。排查思路就围绕传输链路的每个环节展开:
先在网络上抓包确认KV Cache是否真的走RDMA直传,还是悄悄经过CPU内存中转。CPU中转会让数据多走一次PCIe,传输带宽立刻砍半。再看是否做了KV Cache量化,不量化的话,长Prompt场景传输量会非常可观,完全吞掉分离收益。最后看调度器有没有做批量传输,一个请求传输一次KV Cache和一个批次合并传输,效率差距在2倍以上。
还有一个容易被忽视的点:Prefill节点和Decode节点如果不在同一个网络域,跨交换机传输的延迟和丢包影响会非常大。集群规划时尽量让两者在同一个高性能网络区域内,否则PD分离的综合延迟可能比单机还差。
我自己在实际操作中的体会是,这三项技术并不是“装得越多越好”的关系。量化收益最稳定,几乎任何场景都值得先做;投机采样是典型的场景技术,找准长文本、低温采样这个区间再用,效果会非常惊喜;PD分离则是架构层面的投入,适合并发规模真的上来了之后再考虑。如果你正要上手,就从nano-vllm开始,先把量化数据流和prefill/decode队列读通,再一点点加上投机采样。这个顺序走下来,推理引擎对你来说就不再是黑盒了。