1. 什么是 PTQ
训练后量化(Post-Training Quantization,PTQ)是一种无需重新训练的量化方法:拿到一个已训练好的 FP32 模型,借助少量无标签校准数据,在几分钟内将其转换为 INT8 推理模型。
PTQ 的工作流:
预训练 FP32 模型 → 插入量化观察器 → 校准数据集前向传播 → 统计每一层的值域 → 计算量化参数 → 转换为 INT8 推理图
PTQ 的核心假设是:校准数据的统计分布能代表真实推理时的数据分布。这也是 PTQ 精度能否达标的关键------校准数据的代表性决定了量化的天花板。
2. 权重量化校准
权重在推理时是静态的,因此可以在离线阶段精确量化。常见的校准方法有三种:
2.1 Min-Max 量化
最简单的方式:直接取权重的 min 和 max 作为量化范围。
import torch
def minmax_quantize(weight, n_bits=8):
qmin, qmax = -2**(n_bits-1), 2**(n_bits-1) - 1
w_min, w_max = weight.min(), weight.max()
scale = (w_max - w_min) / (qmax - qmin)
zero_point = qmin - torch.round(w_min / scale)
q_weight = torch.clamp(
torch.round(weight / scale) + zero_point, qmin, qmax
)
return q_weight.type(torch.int8), scale, zero_point
Min-Max 的缺陷是极端值会撑大 scale,导致内部密集区域的量化精度不足。权重的长尾分布越严重,这个问题越突出。
2.2 MSE 校准
在 w_min, w_max 上搜索最优截断阈值,以最小化量化前后的均方误差。对所有权重值排序后,按百分位逐步收缩边界,选取 MSE 最小的点。
def mse_calibrate(weight, n_bits=8):
qmin, qmax = -2**(n_bits-1), 2**(n_bits-1) - 1
best_mse, best_scale = float("inf"), None
# 在 99.0% 到 99.999% 之间搜索最优截断边界
for pct in [99.0, 99.5, 99.9, 99.99, 99.999]:
v = torch.abs(weight).flatten().sort().values
threshold = v[int(len(v) * pct / 100)]
scale = threshold / qmax
q = torch.clamp(torch.round(weight / scale), qmin, qmax)
dq = q * scale
mse = torch.mean((weight - dq) ** 2)
if mse < best_mse:
best_mse, best_scale = mse, scale
return best_scale
MSE 校准在 ImageNet 分类模型上通常比 min-max 提升 0.5--1% Top-1 精度。
2.3 KL 散度校准
把原始分布和量化后分布看作两个概率分布,最小化它们之间的 KL 散度。将数据分箱(如 2048 bins),在直方图上搜索最优截断点,使截断后量化分布的 KL 散度最小。这是 NVIDIA TensorRT 默认的 INT8 校准算法。
3. 激活值校准
激活值随输入变化,无法像权重一样离线精确量化。PTQ 的做法是:跑一批校准数据,统计每层激活值的 min/max 或分布直方图,据此计算 scale 和 zero_point。
3.1 移动平均统计
逐 batch 更新 min 和 max 的滑动平均:
class MovingAverageObserver:
def __init__(self):
self.min_val = float("inf")
self.max_val = float("-inf")
def update(self, x):
self.min_val = min(self.min_val, x.min().item())
self.max_val = max(self.max_val, x.max().item())
PyTorch 的 MovingAverageMinMaxObserver 在此基础上加入了动量衰减,使早期 batch 的极端值不会主导最终统计量。
3.2 百分位截断
不取激活值的绝对 min/max,而是按百分位截断------例如取 0.1%, 99.9% 区间内的值作为量化范围。这样可以剔除个别极端激活值对 scale 的破坏。
def percentile_range(x, lower=0.1, upper=99.9):
flat = x.flatten().sort().values
low = flat[int(len(flat) * lower / 100)]
high = flat[int(len(flat) * upper / 100)]
return low, high
3.3 直方图观测器
PyTorch 的 HistogramObserver 将激活值分箱(如 2048 bins),在校准结束后按 KL 散度或 MSE 最小化选择截断点。这是精度最高的方式,但需要在校准期间存储直方图,内存开销略大。
4. 离群值问题
一个权重张量中 99.9% 的值落在 -0.5, 0.5,但个别值高达 ±5。若取全量 min/max:
-
scale = 5/127 ≈ 0.039
-
量化精度(最小可区分差值)= 0.039
-
原本 -0.5, 0.5 内只有约 26 个离散刻度可用
实际上内部 99.9% 的值被压缩到了极少的离散点上,信息几乎完全丢失。
解决方案:
-
对权重使用 per-channel 量化,缩小每个 channel 内部的值域差异
-
对激活值使用百分位截断,牺牲极端值保核心区域精度
-
对于 LLM 中的异常输入通道(outlier features),混合精度保留 FP16
5. PyTorch FX PTQ 实战
import torch
from torch.ao.quantization import get_default_qconfig_mapping
from torch.ao.quantization.quantize_fx import prepare_fx, convert_fx
# 1. 加载预训练模型
model = torch.hub.load("pytorch/vision", "resnet50", pretrained=True).eval()
# 2. 配置量化参数(per-channel 权重量化 + per-tensor 激活值量化)
qconfig_mapping = get_default_qconfig_mapping("x86")
# 3. 准备:在图中插入 FakeQuantize 观察器节点
example_input = torch.randn(1, 3, 224, 224)
model_prepared = prepare_fx(model, qconfig_mapping, example_input)
# 4. 校准:跑若干张校准图片
with torch.no_grad():
for image, _ in calib_loader:
model_prepared(image)
# 5. 转换:FakeQuantize 变为量化-反量化节点
model_quantized = convert_fx(model_prepared)
PTQ 全流程通常在 5--10 分钟内完成,校准数据 100--500 张即可。
6. PTQ 的适用边界
PTQ 适合的场景:
-
卷积网络(ResNet、MobileNet、EfficientNet):INT8 精度损失通常 < 1%
-
校准数据容易获取(从训练集或生产日志采样即可)
-
部署周期紧张,无法承担重新训练成本
PTQ 不适合的场景:
-
极低位宽(≤ 4-bit):误差累积太大,必须 QAT
-
模型层数极深或结构特殊(如某些 NLP 模型):误差在深层放大严重
-
校准数据与真实推理数据分布差异大:统计估计失效
7. 小结
PTQ 是量化工程化的第一选择------成本低、速度快、在大多数视觉模型上精度可接受。当 PTQ 精度不达标时,下一步是引入训练信号来补偿量化误差,即下一篇要讨论的量化感知训练(QAT)。