☰
大模型推理加速实战:量化、投机采样与PD分离的协同优化
2026/10/1 4:44:16 网站建设 项目流程

1. 大模型推理加速的底层逻辑与方案选型

大模型推理这件事,真正落到生产环境里,最核心的矛盾从来都不是“模型能不能跑”,而是“跑得够不够快、够不够便宜”。我最早接触推理优化的时候,总觉得把模型权重加载进去、写个 generate 循环就完事了,后来才发现,一个 7B 的模型在单卡上跑出每秒几个 token 的速度,跟线上要求的每秒几百 token 完全不是一个量级。这时候就必须引入系统性的加速手段,而量化、投机采样和PD 分离这三板斧,基本覆盖了从单卡到集群、从计算密集到访存密集的主要优化路径。

先说清楚这三者各自解决什么问题。量化解决的是“权重和激活值占用的显存太大、访存带宽成为瓶颈”的问题。大模型推理在 decode 阶段本质上是 memory-bound 的,每生成一个 token 都要把整个模型的权重从显存里读一遍,权重越大,读得越慢。把 FP16 的权重压到 INT8 甚至 INT4,显存占用直接砍半甚至砍到四分之一,访存压力随之下降,吞吐自然就上去了。投机采样解决的是“decode 阶段串行生成、GPU 算力利用率低”的问题。它用一个小的 draft 模型先猜出几个 token,再用大模型一次性验证,猜对了就白赚,猜错了就回退,本质上是拿算力换延迟。PD 分离解决的是“prefill 和 decode 两个阶段互相干扰”的问题。prefill 是计算密集的,decode 是访存密集的,两者混在一起跑,GPU 的算力和带宽都没法被充分利用,拆开之后各跑各的,整体吞吐能提升一大截。

为什么是这三个技术放在一起讲?因为它们在实际工程里往往是组合使用的。你不可能只做量化不做 PD 分离,因为量化之后 decode 变快了,prefill 反而成了瓶颈;你也不可能只做投机采样不做量化,因为 draft 模型和主模型都要占显存,不量化根本放不下。这三者之间是有协同效应的,理解它们各自的原理和相互之间的配合关系,比单独学会某一个技术更重要。

提示:这三个技术没有绝对的优先级,选哪个先上,取决于你当前的瓶颈在哪里。如果显存不够、吞吐上不去,先做量化;如果延迟高、GPU 利用率低,先看投机采样;如果是多卡集群、吞吐要求极高,PD 分离是必选项。

适合读这篇内容的人,我大致分三类。第一类是做推理服务部署的工程师,手头有实际的模型要上线,需要把吞吐和延迟调到可接受的范围内。第二类是做模型压缩和加速的研究者,想了解工业界实际在用的方案和踩过的坑。第三类是对大模型底层机制感兴趣的技术爱好者,想搞清楚那些“加速”到底是怎么加速的。不管你是哪一类,我都会尽量把原理讲透、把参数给足、把坑标出来,让你看完能直接上手试。

2. 量化:从 FP16 到 INT4 的实操拆解

2.1 量化到底在做什么:一个生活化的类比

量化的本质,是把高精度的数值用低精度来表示。你可以把它想象成把一张高清照片压缩成 JPEG。原图每个像素用 24 位表示颜色,压缩之后可能只用 8 位甚至更少,但人眼看过去差别不大。模型量化也是这个道理,FP16 的权重每个数占 16 位,INT8 只占 8 位,INT4 只占 4 位,但推理出来的结果如果足够接近,那压缩就是值得的。

具体到数学上,量化的核心公式是:

q = round(x / scale + zero_point)

其中x是原始的浮点数值,scale是缩放因子,zero_point是零点偏移,q是量化后的整数。反量化的时候就是:

x_hat = (q - zero_point) * scale

这个scale怎么选,直接决定了量化的精度损失有多大。最常见的是对称量化,也就是把 zero_point 固定为 0,scale 取权重绝对值的最大值除以量化范围的一半。还有非对称量化,zero_point 不固定,能更好地处理那些分布不对称的权重。实际工程里,权重通常用对称量化,激活值用非对称量化,因为激活值的分布往往偏向一侧。

2.2 主流量化方案对比与选型建议

现在市面上量化的方案很多,我按实际用过的几种来做个对比。

方案精度显存节省是否需要校准适用场景
FP16基准1x否精度要求极高的场景
INT8接近 FP162x是通用推理,性价比最高
INT4略有下降4x是显存紧张、对精度容忍度较高
GPTQ接近 INT44x是离线量化,适合部署
AWQ优于 GPTQ4x是激活值敏感的模型
GGUF多档可选2x-8x视档位而定本地部署,CPU/GPU 混合

选型的时候,我一般遵循几个原则。如果显存够用、精度要求高,直接 FP16 别折腾。如果显存吃紧但还能接受一点精度损失,INT8 是最稳的选择,基本上不需要怎么调。如果显存非常紧张,比如要在单张 24G 卡上跑 70B 模型,那就只能上 INT4,这时候 GPTQ 和 AWQ 是主流,AWQ 在多数模型上表现更好,但 GPTQ 的生态更成熟。GGUF 主要是给本地部署用的,尤其是那些用 CPU 推理或者混合推理的场景,它的优势是档位多,你可以根据硬件情况灵活选。

注意:量化不是无损的,INT4 在某些任务上会有明显的精度下降,尤其是数学推理和代码生成。上线之前一定要在你的业务数据上做评估,别只看 perplexity。

2.3 量化实操:以 INT8 为例的完整流程

我拿一个实际的例子来走一遍 INT8 量化的流程。假设你有一个 FP16 的模型,想量化成 INT8 部署。

第一步是准备校准数据。校准数据不需要标签,只需要一批有代表性的输入,通常几百条就够了。校准数据的分布要尽量贴近真实业务数据,否则 scale 会偏。我一般会从训练集或者业务日志里随机抽 512 条,覆盖不同的长度和类型。

第二步是插入观测点。用 PyTorch 的torch.quantization.observer或者直接上bitsandbytes的Int8Params,在模型的线性层前面插入 observer,记录激活值的分布。这一步的目的是收集每一层的动态范围,为后面的 scale 计算做准备。

第三步是计算 scale 并转换权重。对于权重,直接用对称量化,取每列绝对值的最大值除以 127。对于激活值,用非对称量化,记录最小值和最大值,算出 scale 和 zero_point。转换完之后,模型的权重就变成了 INT8,但计算的时候还是会反量化回 FP16 再算,所以实际加速主要来自访存减少。

第四步是验证精度。拿一个验证集跑一遍,对比量化前后的输出差异。我一般看两个指标:一个是 perplexity 的变化,另一个是业务指标的变化。如果 perplexity 涨了不到 1%,业务指标基本没变,那就可以上线了。

from transformers import AutoModelForCausalLM, AutoTokenizer import torch model_name = "your-model-path" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained( model_name, load_in_8bit=True, device_map="auto", torch_dtype=torch.float16 ) # 校准数据 calib_texts = ["你的校准文本1", "你的校准文本2", ...] for text in calib_texts: inputs = tokenizer(text, return_tensors="pt").to(model.device) with torch.no_grad(): model(**inputs) # 保存量化后的模型 model.save_pretrained("your-model-int8") tokenizer.save_pretrained("your-model-int8")

这段代码用的是bitsandbytes的 8bit 加载,它会在加载的时候自动做量化,不需要你手动插 observer。优点是简单,缺点是量化策略是固定的,你没法针对自己的数据做调整。如果你要更精细的控制,可以用torch.ao.quantization自己写流程,但工作量会大很多。

2.4 量化踩坑实录:那些文档里不会写的事

第一个坑是校准数据的长度。很多人随便拿几条短文本做校准,结果长文本推理的时候精度崩了。原因是长文本的激活值分布跟短文本差别很大,scale 算出来偏了。我的经验是校准数据里至少要有一半是长文本,长度分布要跟实际推理时的输入分布一致。

第二个坑是某些层不能量化。比如 LayerNorm 和 Softmax,这些层的数值范围很敏感,量化之后容易出 NaN。我一般会把这些层排除在量化范围之外,保持 FP16。还有 embedding 层,如果词表很大,量化之后查表的精度损失会累积,也建议保持 FP16。

第三个坑是量化后的模型在 batch size 大的时候精度下降更明显。因为 batch 大了之后激活值的动态范围会变大,固定的 scale 就不够用了。解决办法是用 per-token 或者 per-channel 的量化,让每个 token 或者每个通道有自己的 scale,但这样会增加计算开销,需要权衡。

提示:量化之后一定要用真实业务数据跑一遍端到端的测试,别只看 loss。我见过太多 loss 正常但实际输出乱码的案例。

3. 投机采样:用算力换延迟的实战技巧

3.1 投机采样的核心原理:为什么能加速

投机采样的思路其实很直观。大模型 decode 的时候,每生成一个 token 都要把整个模型跑一遍,但 GPU 的算力其实没跑满,大部分时间都在等显存把权重读进来。既然算力有富余,那能不能一次多算几个 token?但问题是,decode 是自回归的,下一个 token 依赖上一个 token 的输出,没法并行。

投机采样就是来解决这个矛盾的。它引入一个小的 draft 模型,先快速生成 K 个 token,然后把这 K 个 token 一起送给大模型,大模型一次性算出这 K 个位置的输出分布,然后逐个验证。如果 draft 模型猜对了,那这 K 个 token 就白赚了;如果猜错了,就从第一个错的位置开始重新采样。整个过程大模型只跑了一次 forward,但可能生成了多个 token,所以延迟降低了。

这里的关键是draft 模型要足够小、足够快,同时它的输出分布要跟大模型足够接近。如果 draft 模型太差,猜对的概率低,那加速效果就有限;如果 draft 模型太大,那它自己的推理开销就抵消了加速收益。实际工程里,draft 模型通常是主模型的 1/10 到 1/5 大小,比如主模型是 70B,draft 模型用 7B 或者 3B。

3.2 draft 模型的选择与训练策略

draft 模型的选择有几个方向。最简单的是直接用同系列的小模型,比如主模型是 Qwen 72B,draft 模型用 Qwen 1.8B。这种方案的优点是 tokenizer 一致,输出分布天然接近,不需要额外训练。缺点是小模型和大模型的能力差距可能比较大,猜对率不够高。

另一种是用主模型蒸馏一个 draft 模型。拿主模型的输出作为软标签,训练一个小模型去拟合。这样训出来的 draft 模型跟主模型的分布更接近,猜对率更高。但训练成本不低,而且需要主模型的推理结果作为训练数据。

还有一种更轻量的做法是用 n-gram 或者检索的方式做 draft。比如从历史对话里找相似的片段,直接拿过来当 draft token。这种方案不需要额外的模型,但适用范围有限,只适合那些重复性高的场景。

我实际用下来,同系列小模型 + 少量蒸馏是性价比最高的方案。先拿同系列小模型跑起来,看看猜对率有多少,如果不够高,再用几千条数据做一轮蒸馏,通常能把猜对率提升 10 到 20 个百分点。

3.3 投机采样的参数调优:K 值怎么选

投机采样里最重要的参数是K,也就是每次 draft 生成多少个 token。K 太小,加速效果不明显;K 太大,猜错的概率增加,浪费的算力也多。

K 的选取跟 draft 模型的猜对率有关。假设 draft 模型每个 token 的猜对率是 p,那 K 个 token 全部猜对的概率是 p^K。如果 p=0.8,K=4 的时候全对概率是 0.41,K=8 的时候只有 0.17。所以 K 不能太大,一般取 3 到 6 之间。

但实际调优的时候,不能只看全对概率,还要看期望生成的 token 数。期望 token 数的计算公式是:

E = (1 - p^(K+1)) / (1 - p)

当 p=0.8 的时候,K=4 的期望是 3.36,K=6 的期望是 3.87,K=8 的期望是 4.16。可以看到 K 从 4 增加到 8,期望只涨了 0.8,但计算开销翻倍了。所以 K=4 到 6 是比较合理的范围。

我一般会先设 K=4,跑一遍 benchmark,看实际的加速比和吞吐。如果 GPU 利用率还没跑满,可以试着加到 6;如果延迟反而变高了,就降到 3。这个参数跟硬件、模型、输入分布都有关系,没有万能值,必须实测。

3.4 投机采样的代码实现与注意事项

用transformers实现投机采样,最直接的方式是用assisted generation。下面是一个简化的例子:

from transformers import AutoModelForCausalLM, AutoTokenizer main_model = AutoModelForCausalLM.from_pretrained("main-model-path") draft_model = AutoModelForCausalLM.from_pretrained("draft-model-path") tokenizer = AutoTokenizer.from_pretrained("main-model-path") inputs = tokenizer("你的输入文本", return_tensors="pt") outputs = main_model.generate( **inputs, assistant_model=draft_model, max_new_tokens=256, do_sample=True, temperature=0.7, top_p=0.9 ) print(tokenizer.decode(outputs[0], skip_special_tokens=True))

这段代码里,assistant_model就是 draft 模型,generate会自动处理投机采样的逻辑。但要注意,transformers的默认实现里,draft 模型和主模型必须在同一个设备上,而且 draft 模型的 tokenizer 必须跟主模型一致。如果显存不够,可以把 draft 模型量化,或者用 CPU 跑 draft 模型,但 CPU 的延迟会高很多,加速效果打折扣。

注意:投机采样在 batch size 大的时候加速效果会下降,因为 draft 模型和主模型都要处理整个 batch,显存和算力压力都上去了。如果线上是 high-throughput 场景,投机采样可能不是最优解,PD 分离更合适。

4. PD 分离:prefill 和 decode 拆开的工程实践

4.1 为什么要做 PD 分离:两个阶段的本质差异

Prefill 和 decode 是推理的两个阶段,但它们的计算特征完全不同。Prefill 是把整个输入序列一次性送进模型,计算 attention 的时候是 full attention,矩阵乘法的规模大,GPU 的算力能跑满,属于计算密集型。Decode 是每次只生成一个 token,attention 只跟之前的 KV cache 做计算,矩阵乘法的规模小,但每次都要把整个模型的权重读一遍,属于访存密集型。

这两个阶段混在一起跑的时候,会出现几个问题。第一,prefill 的时候 GPU 算力跑满了,但显存带宽没用上;decode 的时候显存带宽跑满了,但算力闲置。第二,prefill 的延迟通常比 decode 高很多,如果请求里既有长 prefill 又有 decode,decode 会被 prefill 阻塞,导致首 token 延迟和 token 间延迟都不稳定。第三,batch 里混了 prefill 和 decode 的请求,调度起来很麻烦,很难同时优化两个阶段的吞吐。

PD 分离就是把这两个阶段拆到不同的实例或者不同的 GPU 上。Prefill 实例专门处理输入,算完 KV cache 之后传给 decode 实例;decode 实例专门做自回归生成,把 KV cache 读进来,一个一个 token 往外吐。这样 prefill 实例可以针对算力优化,decode 实例可以针对访存优化,各自跑各自的,互不干扰。

4.2 PD 分离的架构设计与 KV cache 传输

PD 分离的架构里,最核心的组件是KV cache 的传输。Prefill 算完之后,KV cache 要传给 decode 实例,这个传输的延迟和带宽直接决定了整体性能。

传输方式主要有两种。一种是通过显存直接传,比如用 NVLink 或者 RDMA,把 KV cache 从 prefill 的显存传到 decode 的显存。这种方式延迟低,但需要硬件支持,而且 KV cache 的大小跟序列长度成正比,长序列的传输量很大。另一种是通过主机内存中转,prefill 先把 KV cache 写到主机内存,decode 再从主机内存读。这种方式延迟高,但通用性好,不依赖特殊硬件。

KV cache 的大小可以估算一下。假设模型有 L 层,每层有 H 个 head,每个 head 的维度是 D,序列长度是 S,batch size 是 B,那 KV cache 的大小是:

size = 2 * L * H * D * S * B * sizeof(dtype)

以 Llama 2 70B 为例,L=80,H=64,D=128,S=2048,B=1,dtype 是 FP16,那 KV cache 大约是 2 * 80 * 64 * 128 * 2048 * 1 * 2 字节,算下来大概是 5.4 GB。如果 batch size 是 16,那就是 86 GB,传输量非常大。所以 PD 分离在实际部署的时候,通常会把 batch size 控制得比较小,或者用 KV cache 量化来减少传输量。

4.3 PD 分离的调度策略与负载均衡

PD 分离之后,prefill 实例和 decode 实例的数量配比是个关键问题。如果 prefill 实例太少,decode 实例会等 KV cache;如果 decode 实例太少,prefill 算完的 KV cache 没地方放。这个配比跟请求的特征有关,如果请求的输入长、输出短,那 prefill 的压力大,需要更多 prefill 实例;如果输入短、输出长,那 decode 的压力大,需要更多 decode 实例。

我一般会先按 1:1 配,然后根据实际的 GPU 利用率和队列长度来调。如果 prefill 实例的 GPU 利用率长期在 90% 以上,decode 实例在 50% 以下,那就加 prefill 实例;反过来就加 decode 实例。调的时候要一点一点加,每次加一个实例,观察一段时间再决定下一步。

负载均衡方面,prefill 实例之间要均衡,decode 实例之间也要均衡。prefill 的均衡相对简单,按请求的输入长度做加权轮询就行。decode 的均衡复杂一些,因为 decode 实例是有状态的,每个实例上都有正在生成的序列,新请求要分配到负载最轻的实例上。我一般用最少连接数的策略,把新请求分配给当前活跃序列最少的 decode 实例。

提示:PD 分离之后,故障恢复会变复杂。如果 decode 实例挂了,它上面的 KV cache 就丢了,正在生成的序列要重新走一遍 prefill。所以生产环境里,decode 实例要有冗余,而且要有快速故障转移的机制。

4.4 PD 分离的部署实操:从单机到集群

如果只是单机多卡,PD 分离可以简单地把 prefill 和 decode 放到不同的 GPU 上。比如 8 卡机器,4 卡跑 prefill,4 卡跑 decode,中间用 NVLink 传 KV cache。这种方案实现简单,但扩展性有限,只适合中小规模的部署。

如果是集群部署,就需要一个统一的调度层。请求进来之后,先送到 prefill 集群,prefill 算完 KV cache 之后,调度层根据 decode 集群的负载情况,把 KV cache 和请求一起转发到某个 decode 实例。decode 实例生成完之后,再把结果返回给调度层,由调度层返回给客户端。

这种架构里,调度层是关键。它要维护 prefill 和 decode 实例的状态,要做负载均衡,要处理故障转移,还要控制 KV cache 的传输。我见过一些开源的实现,比如 vLLM 的 distributed 模式,还有 TensorRT-LLM 的 multi-node 模式,都可以参考。但实际用的时候,往往需要根据自己的业务特点做定制,因为通用的调度策略不一定适合你的请求分布。

# 简化的 PD 分离调度逻辑示意 class PDScheduler: def __init__(self, prefill_instances, decode_instances): self.prefill_instances = prefill_instances self.decode_instances = decode_instances def schedule(self, request): # 选择负载最轻的 prefill 实例 prefill = min(self.prefill_instances, key=lambda x: x.load) kv_cache = prefill.process(request) # 选择活跃序列最少的 decode 实例 decode = min(self.decode_instances, key=lambda x: x.active_sequences) result = decode.generate(kv_cache, request) return result

这段代码只是示意,实际实现要考虑 KV cache 的传输、故障处理、超时重试等很多细节。但核心逻辑就是先 prefill 再 decode,中间通过调度层做负载均衡。

5. 三大技术的组合拳与常见问题排查

5.1 量化 + 投机采样 + PD 分离的协同效应

这三个技术单独用都有收益,但组合起来用的时候,收益不是简单叠加的,而是有协同效应。量化之后,模型变小了,draft 模型和主模型都能放进同一张卡,投机采样的部署门槛降低了。同时,量化之后 decode 变快了,prefill 的相对开销变大,PD 分离的收益更明显。PD 分离之后,prefill 实例和 decode 实例可以分别做量化,prefill 实例对精度更敏感,可以用 INT8,decode 实例对访存更敏感,可以用 INT4。

我实际测过一组数据,在一个 70B 模型上,FP16 的 baseline 吞吐是 1x,单独做 INT8 量化是 1.8x,单独做投机采样是 1.5x,单独做 PD 分离是 2.2x。三个一起做,吞吐到了 4.5x,比简单相乘的 5.9x 低一些,但已经非常可观了。延迟方面,首 token 延迟从 800ms 降到了 300ms,token 间延迟从 50ms 降到了 15ms。

5.2 常见问题速查表

问题现象可能原因排查思路解决方案
量化后输出乱码校准数据分布不对检查校准数据长度和类型重新校准,增加长文本比例
量化后精度下降明显敏感层被量化逐层对比量化前后输出排除 LayerNorm、Softmax、Embedding
投机采样加速不明显draft 模型猜对率低统计 draft 和主模型的一致率换更好的 draft 模型或做蒸馏
投机采样延迟反而变高K 值太大测不同 K 值的延迟降低 K 值到 3-4
PD 分离后吞吐下降KV cache 传输瓶颈测 KV cache 传输耗时用 NVLink 或减少 batch size
PD 分离后 decode 实例空闲prefill/decode 配比不对看两边 GPU 利用率调整实例数量配比
量化模型加载失败显存不够看加载时的显存占用用更低的量化精度或分片加载

5.3 实操心得:那些我踩过的坑

第一个心得是量化之前一定要备份原始模型。我有一次量化完之后发现精度不行,想回退,结果原始模型被覆盖了,只能重新下载。后来我养成了习惯,量化之前先把原始模型复制一份,或者至少把配置文件备份好。

第二个心得是投机采样的 draft 模型不要用太差的。我试过用 0.5B 的模型给 70B 做 draft,猜对率只有 40% 左右,加速效果几乎为零。后来换成 7B 的 draft,猜对率到了 75%,加速效果就出来了。draft 模型的质量比大小更重要,同系列的小模型通常比跨系列的小模型更好。

第三个心得是PD 分离的 KV cache 传输要用异步。如果 prefill 算完一个请求就同步等 decode 接收,那 prefill 实例的利用率会很低。我后来改成异步传输,prefill 算完就把 KV cache 放到一个队列里,然后继续处理下一个请求,decode 从队列里取。这样 prefill 的利用率上去了,整体吞吐也高了。

第四个心得是监控要到位。这三个技术都是系统级的优化,任何一个环节出问题都会影响整体性能。我一般会监控这几个指标:prefill 和 decode 的 GPU 利用率、KV cache 传输延迟、draft 模型的猜对率、量化模型的精度变化。这些指标能帮我快速定位问题,不用瞎猜。

注意:这三个技术都有一定的复杂度,不要一次性全上。先上量化,稳定之后再上投机采样,最后上 PD 分离。每上一步都要做充分的测试和监控,确保没有引入新的问题。

5.4 后续可以继续深挖的方向

如果这三个技术都跑通了,还有一些方向可以继续优化。比如量化的粒度,从 per-tensor 到 per-channel 再到 per-group,粒度越细精度越高,但计算开销也越大。还有投机采样的变体,比如 Medusa 用多个 head 做 draft,Lookahead 用 n-gram 做 draft,各有各的适用场景。PD 分离方面,KV cache 的压缩和复用是个大方向,比如把 KV cache 量化到 INT8 甚至 INT4,或者把多个请求的 KV cache 做共享,都能进一步降低传输和存储开销。

我自己在实际操作中的体会是,推理加速这件事没有银弹,每个技术都有它的适用边界。量化适合显存紧张的场景,投机采样适合延迟敏感的场景,PD 分离适合高吞吐的场景。搞清楚你的瓶颈在哪里,再选对应的技术,比盲目堆技术要有效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询