模型跑起来只是第一步,能不能跑得快、跑得省、跑得稳,才是真正拉开差距的地方。我做了几年推理优化,见过太多团队把 FP32 模型直接扔到线上,结果单次推理延迟高得离谱,GPU 利用率却上不去,最后只能靠堆卡来扛流量。其实大部分场景下,把模型从 FP32 压到 INT8,精度损失可以控制在 1% 以内,而推理吞吐能翻 2 到 4 倍,显存占用直接砍掉四分之三。这篇就围绕INT8 矩阵乘、校准、QAT 和 LLM 量化这几件事,把量化从原理到落地的完整链路拆开讲清楚,不管你是刚接触量化的新手,还是已经在做推理优化的老手,都能从中找到可以直接复用的方法和踩坑经验。
1. 量化到底在做什么:从浮点到定点的本质转换
1.1 为什么 FP32 是"奢侈"的
先算一笔账。一个 FP32 参数占 4 字节,一个 7B 参数的模型光权重就要 28GB 显存,这还没算激活值和 KV Cache。而 INT8 每个参数只占 1 字节,同样的模型权重降到 7GB,一张消费级显卡就能装下。除了存储,计算开销的差异更关键:FP32 的矩阵乘需要浮点运算单元,而 INT8 矩阵乘可以用整数运算单元,后者在同样芯片面积下能塞进更多的计算核心,单位时间能处理的乘加操作(MAC)数量高出一个数量级。
但量化不是简单地把小数点后面的数扔掉。浮点数的优势在于动态范围大,能表示 1e-38 到 1e38 之间的数,而 INT8 只能表示 -128 到 127 这 256 个整数值。量化的核心任务就是找到一个映射关系,把这 256 个整数合理地"铺"在原始浮点数的取值范围内,让信息损失最小。
1.2 仿射量化的数学表达
最常用的量化方式是仿射量化,公式很简洁:
q = round(x / scale + zero_point) x_hat = (q - zero_point) * scale其中x是原始浮点值,q是量化后的整数,scale是缩放因子(浮点数),zero_point是零点偏移(整数)。scale决定了量化的粒度,zero_point保证了浮点零能精确映射到某个整数上——这一点对卷积和矩阵乘里的 padding 操作特别重要,因为 padding 用的就是零。
反量化就是把整数还原回浮点,但注意,这个过程是有损的。x_hat和x之间的差距就是量化误差,误差大小取决于scale。scale越大,相邻整数之间的间隔越大,误差越大;scale越小,能表示的动态范围越窄,超出范围的值会被截断(clamp)。
1.3 对称量化与非对称量化的取舍
对称量化强制zero_point = 0,量化范围是[-127, 127]或[-128, 127],正负对称。它的好处是计算简单,反量化时不需要减零点,矩阵乘的累加过程也更干净。权重通常用对称量化,因为权重分布一般以零为中心,对称量化不会浪费表示能力。
非对称量化的zero_point可以是任意整数,量化范围是[0, 255]或[-128, 127]。它适合激活值,因为 ReLU 之后的激活值全是非负的,用非对称量化能把整个范围都利用起来,精度更高。实际部署中,权重用对称 INT8,激活用非对称 INT8,这是最常见的组合。
提示:如果你的硬件只支持对称量化(部分推理加速器就是这样),激活值也需要强制对称,这时候建议在 ReLU 之前做量化,或者用带符号的激活函数来缓解精度损失。
2. INT8 矩阵乘:量化推理的性能核心
2.1 为什么矩阵乘是量化的主战场
Transformer 和 CNN 里超过 90% 的计算量都集中在矩阵乘和卷积上,而卷积在 im2col 之后本质上也是矩阵乘。所以量化能不能带来加速,关键看矩阵乘能不能用上 INT8 指令。以 NVIDIA 的 GPU 为例,Turing 架构之后的 Tensor Core 都支持 INT8 矩阵乘,理论算力是 FP16 的 2 倍、FP32 的 4 倍甚至更多。CPU 这边,Intel 的 VNNI 指令集和 ARM 的 dotprod 指令也都是为 INT8 矩阵乘设计的。
但这里有个容易被忽略的点:INT8 矩阵乘的累加器通常是 INT32。为什么?因为两个 INT8 相乘,结果范围是[-128*127, 127*127],大约是[-16256, 16129],而 K 维累加之后,结果可能超出 INT16 的范围。用 INT32 累加可以保证在 K 不超过 2^15 左右时不溢出,这对绝大多数模型层都够用。
2.2 量化矩阵乘的完整计算流程
假设我们要计算C = A * B,其中 A 和 B 都已经量化成 INT8:
A_q = round(A / s_a) + z_a B_q = round(B / s_b) + z_b C_q = A_q * B_q (INT8 乘 INT8,INT32 累加) C = (C_q - z_a * sum(B_q) - z_b * sum(A_q) + K * z_a * z_b) * s_a * s_b最后那一步反量化看起来复杂,但实际推理时,z_a、z_b、s_a、s_b都是提前算好的常量,sum(B_q)和sum(A_q)也可以在预处理阶段算出来。真正在推理时做的,就是一次 INT8 矩阵乘加一次带常量的仿射变换,开销很小。
如果是对称量化,z_a = z_b = 0,公式直接简化成C = C_q * s_a * s_b,干净利落。这也是为什么很多推理框架在权重侧坚持用对称量化的原因——省掉了零点修正的计算。
2.3 实测中的性能表现与瓶颈
我在一张 T4 上做过对比测试,ResNet-50 的单个卷积层,FP32 推理耗时约 2.3ms,INT8 量化后降到 0.7ms,加速比约 3.3 倍。但并不是所有层都能拿到这个加速比。深度可分离卷积(MobileNet 系列大量使用)的加速比通常只有 1.5 到 2 倍,因为它的计算密度低,瓶颈在内存带宽而不是计算单元,量化带来的计算优势被内存访问开销吃掉了。
另一个瓶颈是反量化操作。如果每一层做完 INT8 矩阵乘都立刻反量化回 FP32,再传给下一层重新量化,那来回转换的开销会抵消掉不少收益。好的推理引擎会把连续的量化层"融合"在一起,中间保持 INT8 状态,只在必要的时候才反量化。TensorRT 和 ONNX Runtime 都做了这种图优化。
| 层类型 | FP32 耗时 | INT8 耗时 | 加速比 | 瓶颈 |
|---|---|---|---|---|
| 标准卷积 3x3 | 2.3ms | 0.7ms | 3.3x | 计算 |
| 深度可分离卷积 | 1.1ms | 0.6ms | 1.8x | 内存带宽 |
| 全连接层 | 0.9ms | 0.3ms | 3.0x | 计算 |
| LayerNorm | 0.4ms | 0.4ms | 1.0x | 不适合量化 |
注意:LayerNorm、Softmax 这类对数值精度敏感的算子,通常保持 FP16 或 FP32,强行量化会导致精度断崖式下跌。量化方案里要明确哪些层"跳过"。
3. 校准:决定量化精度的关键一步
3.1 校准在解决什么问题
训练好的模型权重是已知的,可以直接统计出 min/max 来确定 scale。但激活值是推理时动态产生的,训练阶段拿不到真实的推理输入分布。校准(Calibration)就是用一批有代表性的样本跑一遍前向传播,统计每一层激活值的分布,从而确定激活的 scale 和 zero_point。
校准样本的选择直接决定量化精度。我见过有人随便拿几十张图做校准,结果量化后精度掉了 5 个点,换成从验证集里分层采样的 500 张图,精度损失降到 0.8%。校准集不需要标注,但必须和真实推理数据的分布一致。如果你的线上数据有季节性变化,校准集也要覆盖这些变化。
3.2 三种主流校准算法对比
Min-Max 校准是最简单的方式,直接取激活值的全局最小值和最大值作为量化范围。它的优点是实现简单、无超参,缺点是容易被离群值(outlier)带偏。如果某一层激活里有个别极大的值,整个量化范围会被拉大,导致大部分正常值挤在很小的整数区间里,精度严重损失。
Moving Average Min-Max在 Min-Max 基础上做了滑动平均,用多批数据的统计量平滑极值,对离群值有一定鲁棒性。TensorRT 默认用的就是这种。
KL 散度校准(也叫 Entropy 校准)是精度最好的方案。它的思路是:不直接用 min/max,而是找一个截断阈值,让截断后的分布和原始分布的 KL 散度最小。换句话说,它主动把那些极端的离群值"砍掉",用牺牲少量极端值的方式换取整体分布的量化精度。NVIDIA 的 TensorRT 和很多量化工具都支持这种校准。
| 校准算法 | 精度 | 实现复杂度 | 对离群值鲁棒性 | 适用场景 |
|---|---|---|---|---|
| Min-Max | 低 | 简单 | 差 | 分布均匀、无离群值 |
| Moving Average | 中 | 中等 | 中 | 通用场景 |
| KL 散度 | 高 | 复杂 | 好 | 精度要求高的场景 |
3.3 校准实操:从数据准备到参数导出
以 ONNX Runtime 的静态量化为例,完整流程是这样的:
from onnxruntime.quantization import quantize_static, CalibrationDataReader import numpy as np class MyCalibrationReader(CalibrationDataReader): def __init__(self, calibration_data): self.data = calibration_data self.index = 0 def get_next(self): if self.index >= len(self.data): return None batch = self.data[self.index] self.index += 1 return {"input": batch} # 准备 200-500 个校准样本 calib_data = [np.random.randn(1, 3, 224, 224).astype(np.float32) for _ in range(300)] reader = MyCalibrationReader(calib_data) quantize_static( model_input="model.onnx", model_output="model_int8.onnx", calibration_data_reader=reader, quant_format=QuantFormat.QDQ, per_channel=True, activation_type=QuantType.QUInt8, weight_type=QuantType.QInt8 )几个关键参数值得说明。per_channel=True表示权重按通道分别量化,每个输出通道有独立的 scale,比全局量化精度高不少,代价是多了少量存储。activation_type=QUInt8用非对称量化激活,weight_type=QInt8用对称量化权重,这是最稳的组合。quant_format=QDQ会在模型里插入 QuantizeLinear/DequantizeLinear 节点,兼容性好,方便调试。
校准样本数量上,我的经验是 200 到 500 个就够,再多收益递减。但样本的多样性比数量更重要,要覆盖不同的输入模式。校准完之后,一定要在验证集上跑一遍精度对比,如果掉点超过 2%,就得回头检查校准集或者换校准算法。
4. QAT:把量化误差"训练"掉
4.1 PTQ 的天花板在哪里
训练后量化(PTQ)不用重新训练,流程简单,是大多数场景的首选。但它有个天花板:当模型对量化误差特别敏感时(比如检测模型的小目标分支、分割模型的边界区域),PTQ 怎么调都救不回来。我做过一个语义分割模型,PTQ 之后 mIoU 掉了 4 个点,换了几种校准算法都只能压到 3 个点,最后上 QAT 才把损失控制在 0.5 个点以内。
PTQ 精度不够的根因在于:量化误差是推理时才引入的,训练阶段模型完全不知道量化的存在,所以学到的权重对量化噪声没有鲁棒性。QAT 的思路就是在训练阶段模拟量化过程,让模型"提前适应"量化误差。
4.2 QAT 的核心机制:伪量化节点
QAT 的关键是伪量化(Fake Quantization)。它在训练的前向传播里插入量化-反量化的模拟操作:
x_fake_quant = dequantize(quantize(x))注意,quantize里有round操作,而round的梯度几乎处处为零,直接反向传播会导致梯度消失。解决办法是用直通估计器(STE,Straight-Through Estimator):前向传播时正常做 round,反向传播时把梯度直接"透传"过去,假装 round 不存在。这样权重就能在量化约束下继续更新。
QAT 的完整流程分三步:
- 加载预训练模型,在模型里插入伪量化节点,通常放在权重和激活之后。
- 微调训练,用较小的学习率(通常是原始学习率的 1/100 到 1/10)训练几个 epoch,让模型适应量化噪声。
- 导出量化模型,训练完成后把伪量化节点替换成真正的量化算子,生成 INT8 模型。
4.3 QAT 实操中的坑与调参经验
QAT 听起来美好,实操里有几个坑必须提前知道。
第一个坑是学习率。学习率太大,模型会在量化约束下震荡,精度反而比 PTQ 还差;学习率太小,模型来不及适应量化噪声。我的经验是从原始学习率的 1/100 开始,训 5 到 10 个 epoch,观察验证集精度曲线,如果还在上升就继续训,如果震荡就降学习率。
第二个坑是 BatchNorm 的统计量。QAT 微调时,BatchNorm 的 running mean 和 variance 会随着量化后的激活分布变化,如果还用训练前的统计量,推理时会出问题。正确做法是在 QAT 训练时让 BatchNorm 继续更新统计量,或者在训练结束后用一批数据重新校准 BN 统计量。
第三个坑是伪量化节点的位置。不是所有层都适合插伪量化。LayerNorm、Softmax、残差连接的加法操作,这些地方插了反而伤精度。主流框架(PyTorch 的torch.quantization、TensorFlow 的tf.quantization)都有默认的量化配置,会跳过这些敏感层,不要自己乱改。
import torch.quantization as tq model.qconfig = tq.get_default_qat_qconfig('fbgemm') model_fused = tq.fuse_modules(model, [['conv1', 'bn1', 'relu']]) model_prepared = tq.prepare_qat(model_fused, inplace=False) # 微调训练 for epoch in range(10): train_one_epoch(model_prepared, train_loader, optimizer) # 转换为量化模型 model_prepared.eval() model_int8 = tq.convert(model_prepared, inplace=False)提示:QAT 训练时一定要用
model.train()模式,让伪量化节点生效;转换前必须切到model.eval(),否则 BatchNorm 会用 batch 统计量,导致推理结果不稳定。
5. LLM 量化:大模型时代的特殊挑战
5.1 为什么 LLM 量化比 CNN 难
LLM 量化和传统 CNN 量化有本质区别。CNN 的激活值经过 ReLU 之后分布相对规整,而 LLM 的激活值里存在大量离群值(outlier)。这些离群值可能比正常值大几十倍甚至上百倍,用 Min-Max 校准的话,整个量化范围被这几个离群值撑大,其他 99% 的值全挤在几个整数格子里,精度直接崩掉。
更麻烦的是,LLM 的离群值不是随机分布的,而是集中在某些特定的通道(channel)上。研究发现,Transformer 的某些注意力头和 FFN 的特定维度会系统性地产生大激活值,这跟模型的训练动态有关。所以 LLM 量化不能简单套用 CNN 的方案。
5.2 权重量化:GPTQ 与 AWQ 的路线差异
LLM 量化的第一波浪潮是权重量化,也就是只量化权重,激活保持 FP16。这样虽然拿不到 INT8 矩阵乘的全部加速,但显存占用能砍掉一半以上,对 LLM 这种显存瓶颈明显的场景收益巨大。
GPTQ的思路是逐层做量化,用二阶信息(Hessian 矩阵)来指导权重的舍入方向。它不是简单地把权重 round 到最近的整数,而是考虑量化误差对输出的影响,把误差"补偿"到其他权重上。GPTQ 能把 7B 模型量化到 4bit 而精度损失很小,是早期最流行的方案。
AWQ(Activation-aware Weight Quantization)的洞察是:不是所有