把一个训练好的FP32模型转成INT8部署,本想着推理速度翻上几倍,结果一测准确率直接掉了三四个点,这种体验相信不少做模型部署的工程师都遇到过。量化本质上是用更少的比特数去逼近浮点数值,误差是必然存在的,关键在于如何把这个误差控制在不影响任务指标的范围之内。目前业内主流的补救手段有两类:一类是后训练量化(PTQ)配合精心挑选的校准数据集,另一类是量化感知训练(QAT)。本文会从精度损失的根源讲起,分别展开这两条路线的原理与实践。

一、先搞清楚精度为什么会掉:量化误差的来源
量化误差主要来自两个方面:权重取整误差和激活值截断误差。权重取整误差相对好理解,把一个连续分布的浮点数映射到256个离散的INT8级别上,每个数都会有细微的偏差,单看某一个权重的误差微乎其微,但神经网络动辄几百万上千万个权重,误差逐层累积放大后,输出结果的偏移就可能变得明显。
激活值截断误差往往是被忽视的大头。激活值的分布经常存在长尾现象,比如经过某些激活函数或注意力层之后,大部分数值集中在较小的区间,却有个别离群值特别大。如果直接用最大值作为量化的缩放系数,大部分数值只能占用极小的表示范围,分辨率极低;如果为了分辨率而截断离群值,这些值的信息又会丢失。如何选取这个截断阈值,正是校准数据集要解决的核心问题。
此外,不同层对量化误差的敏感度也不一样。通常靠近输入输出的层、以及某些特殊结构(比如深度可分离卷积的逐通道计算)对精度更敏感,一刀切的量化策略很容易在这些地方翻车。理解了这些来源,后面两条优化路线的思路就好理解了:要么更聪明地确定量化参数,要么让模型自己学会适应量化误差。
二、校准数据集:后训练量化的关键杠杆
后训练量化的流程是:拿一个训练好的模型,喂入若干批具有代表性的数据,统计中间激活值的分布,再根据统计结果确定每一层的缩放系数和零点。这里喂入的数据就是校准数据集。很多精度下降的案例,追查下来发现校准集随便从训练集里抽了几百张图就完事了,甚至有人用随机噪声做校准,结果自然惨不忍睹。
构建校准数据集有几条实践原则。第一,数据分布要贴近真实推理场景,训练集里采样通常可行,但如果线上数据和训练集存在分布偏移,就应该优先使用贴近线上的数据。第二,样本量适中即可,一般几百到一两千条就够,太多并不会带来明显收益,反而拖慢校准速度。第三,覆盖面要广,如果模型有多个业务类别,校准集中各类别都应出现,否则统计出来的激活分布是有偏的。
阈值确定算法的选择同样重要。最简单的是MinMax方法,直接取最大绝对值,适合分布均匀的模型;更推荐的是KL散度(熵校准)方法,TensorRT的INT8校准默认采用这类思路,它在候选阈值中寻找一个使量化前后分布差异最小的分界点,对长尾分布更友好;此外百分位截断(比如取99.9%分位)也是一种工程上常用的折中方案。以PyTorch的torch.ao.quantization为例,一个典型的校准流程如下:
import torch
from torch.ao.quantization import get_default_qconfig, quantize_jit
from torch.utils.data import DataLoader
# 准备校准数据加载器,样本应来自真实推理分布
calib_loader = DataLoader(calib_dataset, batch_size=32, shuffle=False)
# 使用fbgemm后端,引擎选择histogram(基于分布统计的校准)
qconfig = get_default_qconfig('fbgemm')
model_prepared = copy.deepcopy(model_fp32)
model_prepared.qconfig = qconfig
# 需要先插入量化观测模块(QuantStub/DeQuantStub)并fuse部分层
from torch.ao.quantization import prepare
model_calib = prepare(model_prepared)
# 前向若干批数据,收集各层激活值统计信息
with torch.no_grad():
for data, _ in calib_loader:
model_calib(data)
# 依据统计结果转换为真正的量化模型
from torch.ao.quantization import convert
model_int8 = convert(model_calib)
校准方案的优势在于成本低,不需要重新训练,也不需要原始训练的完整数据集,通常几分钟就能完成,非常适合快速迭代验证。它的局限也很明确:当模型本身对量化特别敏感,或者目标位宽更低(比如INT4)时,仅靠校准很难把精度拉回来,这时就要考虑量化感知训练了。
三、量化感知训练:让模型学会适应误差
量化感知训练的思路是把量化操作插入训练流程中。具体做法是在前向传播时插入伪量化节点:数值先按INT8的规则量化一遍,再立刻反量化回浮点,用这个带误差的数值继续往下算。这样模型在训练时体验到的就是量化后的真实数值行为,权重会朝着对量化更鲁棒的方向调整。而在反向传播时,由于量化函数的导数几乎处处为零,通常采用直通估计器(STE)来近似,把梯度当作量化函数是恒等映射一样直接传回去。
QAT的典型工作流是:加载已训练好的FP32模型作为初始权重,插入伪量化节点,用原始训练数据(通常可以用原学习率的十分之一左右)微调若干个epoch。由于模型已经在收敛点附近,微调的目的是补偿量化误差而不是学习新知识,所以训练轮数不用太多,一般几个epoch就能看到明显的精度恢复。代码层面,PyTorch的写法与校准流程类似,只是把prepare换成prepare_qat:
from torch.ao.quantization import get_default_qat_qconfig, prepare_qat
model_train = copy.deepcopy(model_fp32)
model_train.qconfig = get_default_qat_qconfig('fbgemm')
model_qat = prepare_qat(model_train)
optimizer = torch.optim.SGD(model_qat.parameters(), lr=1e-4, momentum=0.9)
model_qat.train()
for epoch in range(3):
for data, target in train_loader:
optimizer.zero_grad()
loss = criterion(model_qat(data), target)
loss.backward()
optimizer.step()
# 训练完成后把伪量化节点转换为真实的量化算子
model_int8 = convert(model_qat.eval())
QAT的效果通常显著优于纯校准,尤其是低位宽场景。代价是它需要完整的训练数据和训练环境,耗时从几小时到几天不等,而且如果原始训练管线已经丢失,QAT基本无从谈起。另一个容易踩的坑是BN层处理:QAT期间批归一化的统计参数需要冻结并合并进卷积权重,否则伪量化的统计和真实部署时的行为会不一致,很多精度异常最终都定位到这一点上。
四、两条路线怎么选:一张表说清楚
实际项目中,两条路线并不是非此即彼的关系,更常见的做法是先校准快速摸底,精度不达标再上QAT。下表从多个维度做对比:
| 对比维度 | 校准(PTQ) | 量化感知训练(QAT) |
|---|---|---|
| 数据需求 | 几百条代表性样本即可 | 接近完整的训练数据 |
| 时间成本 | 分钟级 | 小时到天级 |
| 精度恢复能力 | 一般,适合INT8 | 强,可支撑INT4等低位宽 |
| 是否需要训练环境 | 不需要 | 需要 |
| 适用场景 | 快速部署验证、大模型难以重训 | 精度要求苛刻、端侧极端压缩 |