去年我接的一个部署需求,遇到一个特别典型的问题:模型精度不错,但单张推理时间太慢,在只允许用 CPU 推理的服务器上根本扛不住并发。一开始我试着优化预处理、减少后处理逻辑、换更轻的骨干网络,效果始终有限。后来把模型的 FP32 权重压缩成 INT8,单张延迟直接降了将近一半,内存占用也砍到原来的四分之一左右,这时候我才真正意识到 PyTorch 量化 API 的价值。
这篇基础向的量化入门,主要围绕 PyTorch 的 Python API 展开,目标是把“量化”这个听起来很学术的概念,讲成一套能直接落地的工具。我会先从量化的收益和适用场景说起,再拆解量化背后的核心原理,然后对比 PyTorch 2.x 里 Eager 模式和 FX 图模式这两套 API 的差异,接着走一遍静态量化的完整流程,最后聊聊精度不够时怎么转 QAT,以及我踩过的一些坑。如果你已经在用 PyTorch 训练模型,正处于“模型跑通但部署吃力”的阶段,这篇文章应该能帮上忙。
1. 量化的收益与边界:先判断你的模型该不该动
很多人一听到量化,第一反应是“精度会掉吧”,然后就绕着走。其实量化不是玄学,它本质上是在做一件事:把模型里的浮点数计算换成低比特整数计算,从而换来更小的体积、更少的内存占用和更快的推理速度。要不要做量化,首先得看清收益和代价。
1.1 量化到底省了什么
深度学习模型默认用 FP32 表示权重和激活值,一个 float 占 4 字节。如果换成 INT8,一个数只占 1 字节,单单权重这一项,体积就能降到原来的四分之一。比如一个 100MB 的 FP32 模型,量化后大约 25MB。这对磁盘占用、加载时间、内存带宽都是实打实的压缩。
更关键的是推理速度。现在的 CPU 对 INT8 指令做了很多硬件级加速,比如常见的 AVX512 和 VNNI 指令集,一条指令就能完成多组 INT8 乘加运算。相比之下,FP32 的乘加要慢不少。我在实际项目里测下来,卷积和全连接结构占比较高的模型,量化后 CPU 推理速度往往能提升 2 到 3 倍,某些结构简单的模型能接近 4 倍。延迟降下来之后,服务器的吞吐量自然就上去了。
1.2 什么场景不适合量化
量化不是万能的。如果模型主要由逐元素操作、动态形状或者大量自定义算子组成,比如强依赖向量检索、复杂控制流的模型,量化带来的加速可能很有限,甚至因为频繁的反量化开销而变慢。另外,模型的精度余量也很关键。如果你的模型在验证集上本来就只有 80% 出头的准确率,稍有波动就跌到可用线以下,那直接做量化就有点冒险。
我自己的判断标准一般是三步:先看模型推理是不是 CPU,以及有没有硬性延迟要求;再看模型里卷积、矩阵乘这类算子的占比高不高;最后用一小批校验数据跑一次量化,对比精度掉点是否在可接受范围内。只要这三个条件基本满足,量化就值得试。反之,如果模型本身就很小,推理已经很快,量化带来的速度收益可能不显著,但增加的复杂度却是实打实的。
2. 量化核心机制:缩放因子、零点与校准
很多教程一上来就教你怎么调用 API,但遇到精度问题就不知道怎么调。我建议先花十几分钟理解量化背后的数学映射,这样后面调参、排查问题才会有方向感。
2.1 从 FP32 到 INT8 的映射公式
量化本质上是在浮点实数和有限范围的整数之间找一个映射关系。以最常见的线性量化为例,假设原始浮点值是 r,量化后的整数值是 q,那么映射关系可以写成:
q = round(r / S + Z)
反推回来则是:
r ≈ S × (q - Z)
这里的 S 是一个浮点数,表示每个量化步长;Z 是零点,用来对齐浮点范围里的某个值和整数里的某个值,比如要保证浮点 0 在量化后能被精确表示。S 和 Z 怎么来?取决于你在校准阶段观察到的浮点数值范围。假设观察到的最小值和最大值分别是 rmin 和 rmax,量化范围是 qmin 和 qmax,常用的计算方式是:
S = (rmax - rmin) / (qmax - qmin)
比如 FP32 数值范围是 -1.0 到 1.0,量化到 INT8(范围 -128 到 127),S 大约就是 (1.0 - (-1.0)) / (127 - (-128)) ≈ 0.0078。原始值 0.5 量化后约等于 round(0.5 / 0.0078 + Z),中间会有舍入误差,这个误差就是量化误差的根源。
2.2 对称量化、非对称量化与 per-channel 选择
映射方式有两种常见变体:对称量化和非对称量化。对称量化把零点固定为 0,浮点范围关于原点对称,实现简单,但遇到数值整体偏正或偏负的情况,比如 ReLU 之后的激活值都是非负的,对称量化会浪费一半的整数表示范围。非对称量化允许零点非 0,可以充分利用整个 INT8 范围,精度表现通常更好,代价是实现稍微复杂一些。
还有一个维度是统计粒度。per-tensor 意味着整层权重或激活共用一个 S 和 Z;per-channel 则是对每个输出通道单独统计。权重分布在不同通道之间可能差异很大,尤其在小模型里,per-channel 量化带来的精度提升非常明显,而计算量增加却很小。我在实践中基本上对权重都选 per-channel,激活通常还是 per-tensor,因为激活的通道统计在部署时实现成本更高。
2.3 校准:observer 如何收集浮点范围
校准这个步骤是决定量化精度的核心。校准的目的,是在量化之前收集一批有代表性的浮点数据,估算每一层激活值和权重的真实范围。PyTorch 里负责这项工作的是 observer,常见的几种:
- MinMaxObserver:直接记录观察到的全局最小值和最大值,简单但容易被离群点带偏。
- MovingAverageMinMaxObserver:对每次前向的数据做滑动平均,更稳定,但需要调 averaging_constant。
- HistogramObserver:统计数值分布直方图,可以配合百分位截断,适合对付长尾分布。
校准用的数据不需要带标签,但必须能代表真实推理时的输入分布。我一般从验证集里均匀抽个几百到一千张图,类别覆盖尽量均衡,跑几遍前向,让 observer 把范围估准。这里有个很容易犯的错误:校准集不要从训练集里抽。训练集分布和真实场景经常有偏差,用训练集校准出的范围,一上验证集就会露馅。
3. PyTorch 量化 API 的演进与选型:从 Eager 到 FX
PyTorch 的量化 API 在 2.x 时代有一个明显的变化趋势:老的torch.quantization命名空间逐渐向torch.ao.quantization迁移,而推荐的使用方式也从手工侵入式的 Eager 模式转向基于图重写的 FX Graph Mode。选错 API 会平白多踩很多坑,所以这个选择值得单独说。
3.1 为什么老的 Eager 模式这么麻烦
Eager 模式是我最早接触 PyTorch 量化时用的方式。它的思路很直接:在模型定义里手动插入QuantStub和DeQuantStub,指定每一层用的 qconfig,然后调用prepare和convert。好处是灵活,每一层的类型都可以精细控制,坏处是手动工作量大,而且模型代码需要为量化改结构。
比如一个带残差的结构,Eager 模式下你要自己把握量化和反量化插在哪些边上,插错了,精度损失会莫名放大。我试过一次在某个分类模型里漏掉了残差分支上的 observer,结果量化后掉点超过 10%,查了很久才发现是哪里没插对。这种问题在 FX 模式下基本能自动避免。
3.2 FX Graph Mode 的核心优势
FX 模式利用torch.fx对模型做符号化追踪,把模型转换成一个图结构,然后由框架自动分析哪些算子应该量化、哪里需要插入 fake quant 或量化思想的节点。对使用者来说,你不需要改模型主体,只需要提供一个example_inputs让 FX 完成追踪,剩下的插入工作交给prepare_fx和convert_fx。
它还有一个很实用的特性:自动跳过不适合量化的算子,或者把它们保留为 FP32。像某些对精度异常敏感的层,框架可以通过配置让它们走浮点计算,而其余部分仍然保持 INT8。这种“混合精度”的粒度比手工 Eager 模式自动得多。
当然 FX 也有边界。模型里如果有动态控制流、依赖数据值的循环,或者过于复杂的自定义 autograd Function,symbolic_trace可能会失败或者追踪出来的语义不对。遇到这种情况,要么把模型重构到可以被追踪的程度,要么退回 Eager 模式处理局部量化。
3.3 后端的差异与 qconfig 选择
PyTorch 量化的后端配置,一句话总结就是:CPU 推理和移动端推理的默认量化配置不一样。在 PyTorch 2.x 里,我常用x86作为 CPU 后端的 qconfig,它对应的是面向服务器级 CPU 的算子实现;移动端场景则更多使用qnnpack相关配置。不同后端支持的算子集合和性能特性都有差异,跨后端直接迁移 qconfig 可能效果不好。
实际使用中,我建议直接通过get_default_qconfig拿框架推荐的配置,而不是自己从头拼一个。除非你清楚知道默认配置哪里不满足需求,否则自定义 qconfig 只会引入不确定性。比如下面这段就是我在静态量化准备阶段用的标准操作:
from torch.ao.quantization import get_default_qconfig qconfig_x86 = get_default_qconfig("x86")拿到这个 qconfig 之后,FX 模式会把它传给prepare_fx,自动应用到模型里合适的算子上。
4. 端到端静态量化实操:校准、转换与精度评估
这一节我会用一个可运行的简化例子,带你走完静态量化的完整流程。为了方便演示,我构造一个很小的 CNN 分类模型,实际项目中你直接替换成自己的模型即可。
4.1 构造模型与 FX 追踪
先看模型定义:
import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, stride=2), nn.ReLU(), nn.Conv2d(32, 64, kernel_size=3, stride=2), nn.ReLU(), nn.AdaptiveAvgPool2d(1), ) self.classifier = nn.Linear(64, num_classes) def forward(self, x): x = self.features(x) return self.classifier(x.flatten(1))接着用torch.fx.symbolic_trace追踪模型。要注意的是,FX 模式要求模型在给定示例输入的情况下可以被静态追踪,所以forward里尽量不要写依赖输入数据值的逻辑。对这个例子,追踪没问题:
model = SimpleCNN().eval() fx_model = torch.fx.symbolic_trace(model, concrete_args=None)model必须是eval()模式,这一点非常关键。如果留在训练模式,批量归一化和 dropout 的行为会在校准阶段捣乱,导致量化后的统计信息错乱。
4.2 prepare_fx 与校准
接下来指定 qconfig,并调用prepare_fx。这一步的作用是在图里插入 observer 节点,但此时权重还是 FP32,只是它们会按照量化配置被“模拟”量化。
from torch.ao.quantization import get_default_qconfig from torch.ao.quantization.quantize_fx import prepare_fx, convert_fx qconfig = get_default_qconfig("x86") prepared_model = prepare_fx( fx_model, {"": qconfig}, example_inputs=torch.randn(1, 3, 32, 32), )然后跑校准数据。校准阶段不要更新梯度,直接用no_grad,把几百张代表性图片喂进去。我通常写一个简单的循环:
def calibrate(model, loader): model.eval() with torch.no_grad(): for images, _ in loader: model(images)校准数据多少合适?我个人的经验是,类别越少数据可以越少,但一般不要少于 200 张。更关键的是覆盖度,尽量把真实场景里可能出现的高频特征、光照变化、不同角度都包含了,否则 observer 统计出来的范围会偏。
4.3 convert 得到真正的 INT8 模型
校准完成后,调用convert_fx,框架会把 observer 替换成实际的量化、反量化节点,并按训练好的缩放因子真正把权重转成 INT8 表示。这一步之后,模型里的权重很多就不再是 FP32 了,直接用来做 CPU 推理:
quantized_model = convert_fx(prepared_model)这里有个很容易被忽略的细节:convert_fx之后,模型的输入输出接口通常仍然期待 FP32 张量,框架会在模型入口自动量化、出口自动反量化。所以你不用在调用侧额外做类型转换,直接quantized_model(images)就行。
4.4 精度评估:量化前后对比
量化做得好不好,最终看的是精度对比。我在每个量化项目里都会维护一个统一评估函数,量化前后用完全相同的数据流跑一遍:
def evaluate(model, loader): model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in loader: preds = model(images).argmax(dim=1) correct += (preds == labels).sum().item() total += labels.numel() return correct / total在我手头一个几十类的图像分类任务上,量化前准确率约 92%,静态量化后约 90.5%,掉了 1.5 个百分点,这个结果对 CPU 部署完全可接受。如果你发现掉点超过 3 个点,先别急着优化模型,回头检查两件事:校准数据是否有代表性,以及有没有把某些精度敏感层强制保留成 FP32。
5. 量化感知训练(QAT)适用场景与关键细节
静态量化很多时候能直接过关,但总有一些模型对量化误差特别敏感,常见于目标检测、分割这类对回归输出和边界信息要求高的任务。这时候就要上量化感知训练,也就是 QAT。
5.1 QAT 的基本思路
QAT 的核心思想很简单:在训练(或微调)过程中,前向计算时就模拟量化的舍入误差,让模型权重“提前适应”量化误差。PyTorch 里有专门的 fake quant 算子,它们会前向做一次量化再反量化回浮点,计算的梯度则近似认为反向传播是可导的,这样模型就能在训练中学会补偿量化带来的损失。
从实现上看,QAT 的 API 和静态量化非常像,只是把prepare_fx换成prepare_qat_fx:
from torch.ao.quantization.quantize_fx import prepare_qat_fx qat_model = prepare_qat_fx(fx_model, {"": qconfig}, example_inputs=torch.randn(1, 3, 32, 32))这个阶段模型处于训练模式,可以正常跑优化器更新参数。注意qconfig要相应地选择带 fake quant 的配置,而不是纯推理用的静态配置,好在默认配置下prepare_qat_fx会自动处理好。
5.2 QAT 的训练策略
不是每个项目都适合从零开始做 QAT,绝大多数情况是在已有模型权重基础上做几轮微调。我通常这样设置:加载训练好的 FP32 权重,用较小的初始学习率,大概是原训练学习率的十分之一到百分之一,跑几个 epoch 就够。
更稳的做法是先冻结 backbones,只微调靠近输出的分类层,让模型先适应量化的统计分布;后面几个 epoch 再解冻全部层小幅更新。整个过程要把验证精度盯紧,一旦发现验证集下降明显,就回退到上一个 checkpoint。因为 QAT 本质上还是训练流程,网络结构、数据增强、优化器等配置都会影响最终结果,所以每家的参数都得自己试。
训练结束后,照常调用convert_fx把 fake quant 节点替换成真正的量化节点。这里一定要记住,convert_fx前必须切到eval()模式,否则 batch norm 等层的统计更新还会继续,产出的推理模型状态不稳定。
5.3 什么时候该上 QAT
我给自己的判断标准很简单:静态量化掉点在 2 个点以内,不加戏;掉点在 3 到 5 个点之间,先尝试调整校准数据和 observer 配置,再决定是否上 QAT;掉点超过 5 个点且校准确认无误,直接上 QAT 比反复调参更省时间。
QAT 的代价是训练时间和资源。如果你只有一两个 GPU,一个模型微调几轮也要几个小时。不过从最终部署效果来看,这些投入通常能换来比静态量化更稳的精度,尤其对检测、分割这类模型来说,非常划算。
6. 实际项目里的坑与调优心得
这部分是我最想写的。网上关于 PyTorch 量化的正规文档其实不少,但真正让人原地爆炸的往往是那些文档里没细写的边角问题。我把几个高频坑按排查顺序整理出来。
6.1 坑一:校准数据的代表性比数量更重要
我见过一个项目,校准用了五千张训练集图片,量化后验证集掉点严重。原因是训练集和真实场景存在明显的分布偏差,模型在真实场景里见到的光照、噪声模式和训练集差别很大,observer 统计出的范围自然不准。后来我换成验证集里随机抽的五百张图,同样的量化配置,掉点肉眼可见地变小了。校准数据的第一原则不是“越多越好”,而是“越贴近真实推理分布越好”。
6.2 坑二:MinMax 对离群点过度敏感
默认的 MinMaxObserver 实现很直接:记下看到的最小值和最大值。如果某一层在少数样本上出现了极端值,整个缩放因子会被拉大,导致正常数值的量化步长变粗,精度就受损。我在训练一个带噪声输入的任务时遇到过类似问题,换成 HistogramObserver 并设置百分位截断后,问题才缓解。你可以这样配置:
from torch.ao.quantization.observer import HistogramObserver from torch.ao.quantization import QConfig qconfig_calibrated = QConfig( activation=HistogramObserver.with_args(bins=2048, dtype=torch.quint8, qscheme=torch.per_tensor_affine), weight=torch.ao.quantization.default_per_channel_weight_observer, )百分比截断的本质,是允许少量极端值被“剪”掉,换取绝大多数数值范围更精确。这个思想在统计建模里很常见,量化里同样适用。
6.3 坑三:第一层和最后一层的精度敏感问题
输入层接收的原始像素范围通常比较固定,输出层直接决定预测结果,它们在量化时往往贡献了不成比例的误差。PyTorch 的默认配置里其实已经考虑了这一点,很多情况下会保留输入输出附近的 FP32 算子。如果你的模型是自定义结构,量化后掉点明显,优先检查这几层有没有被量化,必要的时候手动把它们排除在量化范围之外。
我一般会在 FX 的 qconfig_map 参数里单独给这些层指定None,从而让它们保持浮点运算:
prepared_model = prepare_fx( fx_model, {"": qconfig, "classifier": None}, example_inputs=torch.randn(1, 3, 32, 32), )这里的“classifier”是模型子模块名,实际使用时换成你自己的层名。这种局部保留 FP32 的混合方案,经常能以很小的速度代价换回可观的精度回升。
6.4 坑四:BatchNorm 与量化模式切换的顺序
BatchNorm 在训练模式和eval()模式下行为不同:训练模式用当前 batch 的统计量更新 running_mean 和 running_var,eval()模式则使用已经累积好的 running 统计量。量化校准和 convert 过程必须在eval()模式下做,否则 BatchNorm 参数会被污染,量化模型的实际表现和预估差异巨大。
更隐蔽的是 QAT 过程。训练阶段需要 BatchNorm 正常工作,但转换前必须切eval(),并且要在切完模式后再跑一两个校准 batch,确保 running 统计量稳定。如果切eval()后直接 convert,有些情况下也能用,但精度并不总是稳定,多跑一遍校准数据没有坏处。
6.5 坑五:性能验证要分清算法和硬件
最后提醒一个容易误判的点:量化模型在 CPU 上的加速效果,和核心数、指令集、线程数强相关。有时候在笔记本上测发现几乎没有加速,换到服务器 CPU 上却快了 2 倍多。原因很简单,INT8 指令集和内存带宽在不同硬件上的表现不一样。我的建议是,量化模型的性能验证直接用目标部署机器的配置跑,并且测延迟时把预热和多次平均都做上,只看一次推理时间很容易被冷启动噪声影响。
如果哪天量化掉点怎么都压不回来,记得回来看一眼校准数据和最后一层,这两处往往才是真正的“祸根”。把静态量化流程跑顺,再根据精度反馈决定要不要上 QAT,这一套组合下来,基本能覆盖绝大多数 CPU 部署场景。希望这份从原理到实操的入门梳理,能帮你少踩几个我踩过的坑。