最近在带新人做模型部署的时候,发现很多人对“模型量化”的认知一直停在“把浮点数换成整数,速度会变快”这个层面。一追问底层原理和实操细节,基本都含糊。自己做这个也好几年了,中间被量化后的精度飘移、校准数据选错、某些算子不支持量化这类问题反复折磨过,所以想把整个模型量化技巧按我自己真正使用的路径重新梳理一遍。
这篇不会从严格的数学定义讲起,而是按实际踩坑的顺序来:先搞清楚量化到底在解决什么问题,再把量化原理里最关键的那几个概念讲明白,然后对比 PTQ、QAT、动态量化三条主流路线的适用边界,最后用一套完整的图像分类量化流程收尾,并重点说清楚量化后精度崩了该怎么排查。适合正在做模型部署、对端侧推理速度和内存占用有硬性要求、或者准备把模型塞进低功耗设备上的开发者参考。
1. 从“模型装不下”说起:量化到底解决什么问题
1.1 一次被迫换方案的部署经历
先说一个我印象特别深的项目。团队花了几周训出一个图像分类模型,就是很常规的卷积网络,在 GPU 上跑推理完全没问题,准确率也满足验收标准。结果客户要求把推理挪到一台无风扇的边缘设备上,整机内存不到 2GB,存储空间也扣得比较死。
当时模型权重是 FP32 格式,大概三百多兆,光把权重加载进来就已经占了相当可观的内存,切换成半精度之后设备 CPU 又不支持对应的指令集,推理耗时直接从原来的二十多毫秒涨到数秒级。整条链路一下子变得不可用。
后来我们做了三件事:压缩输入图像尺寸、去掉部分冗余预处理逻辑,以及引入 INT8 量化。前两件属于常规优化,真正把模型“塞”进去的关键还是量化。权重从 FP32 变成 INT8 之后,理论存储占用直接降到四分之一,更重要的是推理过程中的矩阵计算可以使用低精度指令,内存带宽压力也大幅下降。最终模型从三百多兆降到约九十兆,推理时延也回到了可接受范围。
1.2 量化、剪枝与蒸馏的分工差异
很多教程会把量化、剪枝、蒸馏混在一起讲,实际上三者的作用路径完全不同。
- 剪枝:把权重矩阵中接近零的冗余连接或者不重要的通道直接去掉,得到稀疏模型。但稀疏模型需要在支持稀疏计算的硬件或软件栈上才能发挥性能,通用 CPU 上的收益不一定理想。
- 蒸馏:用一个大的教师模型去指导一个小学生模型训练,模型结构可能完全不一样,需要重新设计、重新训练,周期较长。
- 量化:基本不改变模型结构,只是把原本用高位宽浮点数表示的张量数据映射到更低位的离散取值上。由于不涉及重新训练或者只做短周期微调,对工程团队来说是最快能看到的压缩与加速手段。
我的体会是,剪枝和蒸馏偏向“结构级”优化,量化则更像是“表示级”优化。三者并不冲突,工程上经常组合使用,但如果你只有很少的时间,量化是性价比最高的一步。
2. 先把量化原理吃透:四个概念决定你能不能用好它
量化听起来高大上,本质却像一个朴素的换算问题:如何用较少的数字去近似表示原来范围很大的数字。
2.1 从连续到离散,精度丢失到底丢在哪里
FP32 的取值范围很大,数值的连续程度也很高。INT8 只有 256 个离散级别,范围也有限。要让一个 FP32 的张量变成 INT8,本质就是找一个映射关系,把每个原始浮点数对应到 256 个整数之一。
这个映射必然带来误差。比如某个特征图的值大部分集中在 0 到 0.1 之间,但偶尔有一个 100 的大值,如果映射区间覆盖 0 到 100,那 0 到 0.1 这一段就几乎全被压成同一个整数,大量细节直接丢失。如果映射区间只覆盖 0 到 0.1,那个大值就会被截断到上限,形成异常点。
这就是量化误差的两大来源:舍入误差和截断误差。舍入误差来自连续值落到离散整数时的“取整”,本质上是不可避免的;截断误差来自实际数据范围超过量化区间,导致部分数值被夹到边界。
2.2 尺度因子 scale 与零点 zero_point
量化公式通常写成:
q = round(r / scale) + zero_point
反推回去则是:
r ≈ scale × (q - zero_point)
- scale:尺度因子,表示量化后每个整数单位对应的原始浮点数大小,是区间范围除以 256(对 INT8 而言)的结果。
- zero_point:零点,表示原始浮点数值 0 对应的量化整数值。设计它的意义在于,让真实浮点数的 0 即使在量化后也能被精确表示,这对 padding、BN 层等运算很重要。
举个例子,某个权重张量的浮点范围是 -1.0 到 1.0,scale 就是 2.0 / 255 ≈ 0.00784,zero_point 会设置在 128 附近。计算时先除以 scale,再四舍五入并加上 zero_point,最终得到一个整数张量。
这个阶段经常有人忽略一个问题:每层张量的 scale 和 zero_point 可能都不一样。量化并不是给整个模型定一个统一映射,而是按层或按张量分别计算。因为不同层、不同通道的数据分布差异极大,统一映射会对分布较窄的层造成很大浪费。
2.3 校准与数据范围:最容易被忽略的一环
想在推理阶段对激活值做量化,必须提前知道激活值的范围。运行模型时,用一批具有代表性的输入样本去前向推理,统计每一层激活张量的 min 和 max,这个过程叫“校准”。
校准数据的质量直接决定量化模型的质量。如果校准集只有几十张图,或者数据分布与实际部署场景差异很大,统计出来的 min/max 就会与真实情况严重偏离。我见过一个内部实验:用某个公开数据集校准后量化,并在另一批真实场景图片上评估,准确率直接掉到基线的一半;后来换成从真实业务数据里面随机抽了 500 张重新校准,精度又回到了接近基线水平。
所以校准不是“随便找几十张图跑一下”就完事,而是要尽量贴近线上真实数据分布,且覆盖典型阴影、强光、低对比度等边界场景。数量上通常 200 到 1000 张即可,太多反而慢,太少又不稳。
2.4 对称量化和非对称量化:什么场景用哪个
根据映射区间是否对称,量化又分为对称量化和非对称量化。
也就是如何定义尺度和零点:
- 对称量化:令 min = -max 或取其对称区间,zero_point 固定为 0,通常可以不做偏移计算,硬件实现简单。
- 非对称量化:直接覆盖 [min, max],zero_point 不一定为 0,需要引入偏移项,略微增加计算复杂度和存储占用,但表达能力更强。
从实际项目经验看,卷积层权重一般比较接近零均值这样的分布,用对称量化能减少计算复杂度;而激活值往往经过 ReLU 之后是非负分布,或者分布有偏移,用非对称量化会更合适。底层硬件或某些专用加速器对对称量化支持更好,选型时还得结合目标平台的指令集来权衡。
为了方便新人对照,我把两个方式的核心差异整理成一张表:
| 维度 | 对称量化 | 非对称量化 |
|---|---|---|
| 零点 | 固定为0 | 可为任意整数 |
| 区间范围 | 对称区间 | 完整覆盖[min, max] |
| 表达能力 | 相对受限 | 更强 |
| 软硬件实现 | 实现简单,运算少 | 需处理偏移,计算稍复杂 |
| 适用场景 | 权重分布接近零中心 | 激活值分布明显偏移时 |
3. 动手前想清楚:PTQ、QAT 与动态量化怎么选
量化不止一种做法,不同做法的精度、成本和改造难度差别很大。真正动手之前,先把路线选对,能少走很多弯路。
3.1 三条路线的适用边界
后训练量化(PTQ)
PTQ 的思路最简单:把已经训练好的 FP32 模型拿来,用校准数据跑一遍,统计每一层的量化参数,然后直接转换。整个过程不需要重新训练模型,甚至不需要标注数据,只需要一批能代表线上分布的样本。
PTQ 的优点是与现有模型训练流程解耦,做完就是完事;缺点是对激活值分布很敏感的模型,直接量化精度会掉得很明显。小型分类模型一般不敏感,但目标检测、语义分割这类对数值分布更敏感的模型,量化掉点就很常见。
量化感知训练(QAT)
QAT 是在训练阶段就把“量化误差”模拟进去。做法是在前向计算时插入伪量化节点,让浮点数据先经历“量化再反量化”的过程,使模型在前向传播时已经感受到了量化误差,然后通过反向传播调整权重参数。微调几个周期之后,模型对量化误差的鲁棒性就会强很多。
QAT 的优点是在低比特、复杂任务上精度保持明显更好;缺点是需要训练数据和训练流程,周期变长,操作比 PTQ 复杂不少,还要小心伪量化节点的加入位置。
动态量化
动态量化与前面两种不太一样:它只对权重做离线量化,激活值仍然以浮点方式计算,但推理过程中会在运行时动态计算激活值的量化范围。由于激活没有真正变成 INT8,内存在某些场景下能省,但计算加速效果不如全量化理想。
不过动态量化对很多神经网络模型非常友好,尤其是那些激活分布变化大、难以用静态校准集准确覆盖的模型。它不需要准备校准集,实施成本低,在文本类或序列类模型上经常是首选。
3.2 用一张表看明白选型依据
| 维度 | PTQ | QAT | 动态量化 |
|---|---|---|---|
| 是否需要训练数据 | 不需要,只要有校准集 | 需要 | 不需要 |
| 需要修改训练流程 | 否 | 是 | 否 |
| 量化范围 | 权重+激活 | 权重+激活 | 仅权重 |
| 精度保持能力 | 中等 | 好 | 良好 |
| 实施成本 | 低 | 高 | 低 |
| 常见适用场景 | 分类任务快速压缩 | 检测、分割、低比特量化 | Transformer 类模型、激活分布不稳定的模型 |
3.3 具体判断思路
我自己的选择逻辑通常是:
- 先用 PTQ 快速做一个量化模型,在验证集上评估精度变化。如果精度下降小于 1 个百分点,直接采用。
- 如果下降超过可接受范围,改用 QAT,保留原数据集做几个周期的量化感知微调。
- 如果任务本身对推理时延要求不高、但内存受限,而且模型激活分布非常不稳定,就退到动态量化,先保证精度能接受。
很多团队默认跳过 PTQ,直接上 QAT,其实是把简单问题复杂化。PTQ 出问题的概率并没有想象中高,尤其是主干网络经过充分训练、BN 层没有失真的情况下,PTQ 的结果往往已经足够。
4. 完整落地:对一个分类模型做量化,从准备到导出
下面用一个通用的图像分类模型来演示完整量化流程。我尽量用当前主流框架中比较通用的接口来写,具体 API 名称在版本演进中可能有调整,但核心流程和下面的顺序是一致的。
4.1 先准备一个能跑的基线
先把模型结构定义好,并保存一份训练好的权重。以一个小型卷积模型为例:
import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 16, 3, padding=1) self.bn1 = nn.BatchNorm2d(16) self.conv2 = nn.Conv2d(16, 32, 3, padding=1) self.bn2 = nn.BatchNorm2d(32) self.pool = nn.MaxPool2d(2) self.fc = nn.Linear(32 * 7 * 7, 10) def forward(self, x): x = torch.relu(self.bn1(self.conv1(x))) x = self.pool(x) x = torch.relu(self.bn2(self.conv2(x))) x = self.pool(x) x = x.flatten(1) x = self.fc(x) return x训练部分不做展开,按常规流程训练到验证集准确率稳定即可。需要特别留意的是,基线模型不要随随便便用未收敛的权重去量化。量化相当于对原模型的“搬运”,如果基线本身就还有问题,搬运之后问题会被放大。
量化之前先在算子层面做一次融合,也就是把Conv + BN + ReLU这类相邻算子合并成一个算子:
model.eval() torch.quantization.fuse_modules( model, [["conv1", "bn1"], ["conv2", "bn2"]], inplace=True, )融合的目的是减少量化边界处的累计误差,同时删除一部分冗余中间层。这一步在实际项目中比较关键,不融合的模型量化后精度通常会更差,因为每个算子单独量化和反量化会引入额外的量化误差。
4.2 后训练量化 PTQ:校准代码的完整套路
首先给模型挂载量化配置,并准备 QuantStub 和 DeQuantStub,把量化范围限制在算子内部:
from torch.quantization import QuantStub, DeQuantStub class QuantableModel(nn.Module): def __init__(self, backbone): super().__init__() self.quant = QuantStub() self.backbone = backbone self.dequant = DeQuantStub() def forward(self, x): x = self.quant(x) x = self.backbone(x) x = self.dequant(x) return x定义完模型后,执行prepare到calibrate再到convert的全流程:
model_with_quant = QuantableModel(model) model_with_quant.qconfig = torch.quantization.get_default_qconfig("fbgemm") model_with_quant.eval() model_prepared = torch.quantization.prepare(model_with_quant) with torch.no_grad(): for images, _ in calibration_loader: model_prepared(images) model_int8 = torch.quantization.convert(model_prepared, inplace=False)校准数据量我推荐 200 到 1000 张之间,前面已经说过原因。校准时不更新权重,也不计算梯度,只是让模型跑前向推理。标注信息对 PTQ 来说没有用处,所以用_直接忽略。
get_default_qconfig里面包含了对观察器和量化方式的选择,比如fbgemm适用于 x86 平台,qnnpack适用于移动端平台。不需要自己到处设置 scale 的初始化方式,框架会基于校准数据自动完成。
4.3 量化感知训练 QAT:精度不够时的升级方案
如果 PTQ 精度掉了太多,就把同样的模型切换到 QAT 模式。整个流程就是在普通训练脚本里加入量化感知训练特有的部分:
model_with_quant.train() model_with_quant.qconfig = torch.quantization.get_default_qconfig("fbgemm") torch.quantization.fuse_modules( model_with_quant.backbone, [["conv1", "bn1"], ["conv2", "bn2"]], inplace=True, ) model_qat = torch.quantization.prepare_qat(model_with_quant, inplace=False) optimizer = torch.optim.Adam(model_qat.parameters(), lr=1e-4) criterion = nn.CrossEntropyLoss() for epoch in range(20): for images, labels in train_loader: output = model_qat(images) loss = criterion(output, labels) optimizer.zero_grad() loss.backward() optimizer.step()训练结束后,照样需要convert得到真正的 INT8 模型:
model_qat.eval() model_int8 = torch.quantization.convert(model_qat, inplace=False)有一个容易忽略的点:QAT 开始前不要直接把初始学习率降得太低。伪量化节点的梯度变化比较温和,学习率太低会导致模型在量化误差的影响下无法快速恢复到原有精度。我一般用原训练学习率的五分之一到十分之一,并观察前几个 epoch 的 loss 是否明显比纯浮点训练更抖。
4.4 导出与推理:量化模型最终的保存和加载
量化模型在convert之后就已经是 INT8 权重结构,不再需要重新计算浮点权重。保存方式有两种:
第一种是保存状态字典,后面恢复模型结构后再加载。第二种是把整个量化后的模型序列化存成pt文件,部署时直接torch.load加载。
torch.save(model_int8.state_dict(), "model_int8_weights.pt") torch.save(model_int8, "model_int8_full.pt")这里要提醒一句:保存整个量化模型时,必须保证目标环境与量化时使用的算子版本一致。如果量化用的框架版本和部署环境版本差太多,加载时可能出现算子不匹配或观察器结构不一致的问题,导致推理结果错误。最稳妥的做法是先用保存的模型在部署环境中跑一遍小批量验证数据,确认输出和量化前的 FP32 模型在误差范围内一致,再接入线上服务。
5. 量化后精度崩了怎么办:我的排查顺序与修复经验
量化精度这东西,就像测试环境的 bug,平时看不见,一上真实业务数据就冒出来。不少团队第一次量化都会遇到“准确率掉了五六个点”的尴尬局面。下面按我习惯的排查顺序展开。
5.1 先分清是全局下降还是个别样本崩坏
量化后产生精度下降,第一步不是直接改参数,而是把验证集上的错误样本逐条拉出来看。如果精度是平均掉了几个点,但错误样本看起来并没有规律,通常说明是全局性的量化误差累积。如果错误样本集中在某几类、亮度极高或极低的图片、长文本样本等,大概率是校准数据没有覆盖到这类分布,或者某几层对异常值特别敏感。
我在某次量化一个检测模型时,发现所有误检都发生在夜间场景。后来才知道校准集是白天数据,夜间图像亮度分布整体偏暗,激活值范围和校准阶段统计出来的范围差异很大。把夜间图片补充到校准集里后,误检率明显回落。
这提醒我一个基本原则:校准集必须和部署场景“同源”。千万不要偷懒刮一段原始训练集就用来校准,即使总数太少,也要优先从真实业务日志里随机抽取。
5.2 校准数据分布的排查和百分位校准
如果校准数据和部署数据同源,精度还是下降,可以考虑调整校准策略本身。
默认的观察器会统计所有样本上的激活值 min/max,再用这个区间做量化。如果数据里存在少量极端值,比如某个点的激活值突然冲到 500,区间被拉得很宽,中间大部分数据反而只用到很少的量化级别,精度就会受到明显影响。
此时可以采用百分位校准,把最大最小值换成第 99.9 或 99.99 百分位,把极端值截掉。大多数框架都能自定义观察器,例如通过设置percentage参数实现:
from torch.quantization import MinMaxObserver observer = MinMaxObserver(dtype=torch.quint8, qscheme=torch.per_tensor_affine)如果框架不支持直接做百分位校准,可以自己离线统计激活分布,找出合理的截断阈值,再手动设置 scale 和 zero_point。
这里有一个很好的类比:量化就像用一张固定大小的集装箱去装货物。如果你统计范围时把几个特别大的畸形货物也算进来,集装箱容积会被浪费,普通货物只能受“挤压”。百分位校准就是把那 0.1% 的畸形货物单独放弃,保证剩下的 99.9% 有更充裕的空间。
5.3 敏感性分析:逐层量化与选择性量化
如果校准策略调整后依然不达标,我建议做逐层敏感性分析。做法简单但很费时间:逐层把该层设为量化状态,其余层保持浮点,在验证集上评估精度,从而找出“最敏感的层”。
实际操作中,我会按以下顺序进行:
- 先用全图量化,记录每个子模块的权重范围和激活范围,找出数值分布异常宽的层。
- 再把疑似敏感的那几层还原成 FP32,其他层保留 INT8,观察精度恢复情况。
- 如果确认是某几层导致崩坏,就对这些层单独设回浮点,或者改用更高位宽的量化。
这种“选择性量化”虽然会让整个模型的压缩比略有下降,但在精度要求很苛刻、且模型整体比较复杂时,往往是可接受的折中方案。
还有几个比较常见的坑要注意:
- BN 层没有融合或融合顺序不对,量化后因 BN 参数更新反而扩大了数值范围。
- 校准批次太小,尤其是包含 BN 层的模型,校准集少于 100 张时 BN 统计量不稳定。
- 训练和部署时的图像预处理不一致,例如训练用灰度均值归一化,部署时却换了别的流程,激活范围全变了。
这些坑几乎都在“数据流”上出问题,而不是量化算法本身的问题。所以排查时先把数据链路排掉,再看算法层。
6. 一些真实使用中的心得和补充技巧
最后分享几个从实际项目里沉淀下来的经验技巧。
6.1 量化后要测真实推理速度,不要只看理论压缩比
很多人量化完只看模型体积从 300MB 变成 90MB,就觉得任务完成。实际上部署时真正的瓶颈可能是内存带宽、算子调度和硬件对 INT8 的支持程度。我见过一个模型量化后体积缩小到四分之一,但实际推理速度只快了 10%,因为底层算子库对某些小矩阵的 INT8 支持并不好,反而转成了浮点计算。
所以流程上应该在量化完成后,立刻在目标设备上做一个端到端的基准测试,分别记录 FP32、动态量化、全量化三者的推理时延、内存占用和功耗。用数据说话,而不是凭感觉判断。
6.2 结合蒸馏做“先瘦身后量化”效果更好
量化本身不改变模型结构,如果原始模型太大,可以先做蒸馏得到一个小模型,再对小模型量化。小模型和量化叠加之后的体积压缩非常可观,精度也比直接对原模型暴力剪枝再量化更稳定。
具体做法是:
- 用大模型作为教师模型,训练一个适合部署的小模型。
- 对小模型执行 PTQ,记录量化后精度。
- 如果精度不足,改用 QAT 微调。
这和单纯做量化相比,多一次蒸馏训练,项目周期会长一些,但换来的是端侧体积和时延的大幅改善。资源允许的话非常值得。
6.3 不建议量化的几个信号
不是所有模型都适合量化。我自己遇到过的明显不适合量化的场景包括:
- 模型内部有大量对数值精度非常敏感的分支判断逻辑,比如某些连续判断阈值。
- 激活值的动态范围极端不稳定,同一个 Batch 里差异能达到几个数量级。
- 某些自定义算子底层实现没有经过 INT8 适配,量化之后该算子被降级回浮点计算,整体收益很低。
如果碰到这些信号,不要硬上量化,可以考虑改模型结构、降低输入分辨率,或者换一个更适配的部署框架。
6.4 每次上手前给自己列一个清单
考虑到量化涉及的点比较杂,我一般在上手前给自己列一个检查清单,这里也分享给大家:
- 基线 FP32 模型是否已经收敛且在验证集上稳定?
- 校准数据是否从真实部署数据中采样,是否包含边界场景?
- 算子层面的 Conv+BN+ReLU 融合是否已做?
- 量化配置是否匹配目标硬件平台?
- 校准后的 min/max 是否存在明显异常值?
- 量化后再部署环境的算子版本是否与量化时一致?
- 是否在真实设备上评估过端到端推理性能?
按这个清单走,基本可以绕开大部分低级错误。
模型量化这条路,入门容易,做深了全是细节。每次看到有人一上来就追求“能不能保住全部精度”,我都想说,量化的目标从来不是在真空中维持精度,而是在给定硬件约束下让模型真正跑起来。只要数据链路正确、校准集靠谱、路线选得合理,INT8 量化在绝大多数场景下都能交出不错的答案。
最后补充一个小经验:量化模型上线之后,最好在线上持续监控一段时间的输入分布变化。真实世界的数据分布是会漂移的,半年前校准出来的 scale 可能半年后就不适配了。定期重新校准,也是模型迭代的一部分。