1. 为什么模型一上量化就“翻车”:从一次线上事故说起
去年帮一个团队排查推理服务的延迟抖动问题,模型是典型的视觉检测网络,FP32 跑在 T4 上单帧 38ms,业务要求压到 15ms 以内。第一反应就是上 INT8 量化,结果精度掉了 6 个点,小目标几乎全丢。折腾了两周才定位到根因:校准集用了训练集的随机采样,里面大量是简单背景图,激活值的动态范围根本没覆盖到真实推理时那些高对比度场景。这件事让我彻底明白一个道理——量化的难点从来不是“把 FP32 变成 INT8”这个动作本身,而是如何让 INT8 的数值表示尽可能贴合真实推理时的数据分布。
这篇内容就是围绕这个核心展开的。我会把 INT8 矩阵乘的底层原理、校准(Calibration)的几种主流做法、QAT(量化感知训练)的落地细节,以及 LLM 量化这个当下最热的方向,全部拆开讲透。适合两类人看:一类是刚接触模型部署、想把推理成本降下来的工程师;另一类是已经在做量化但精度总是差一口气、想搞清楚“为什么”的从业者。不管你是用 ONNX Runtime、TensorRT 还是自己写推理引擎,底层的数学逻辑是通用的。
先给一个全局认知:量化本质上是一个信息压缩问题。FP32 有 23 位尾数,能表示大约 83 万个不同的值;INT8 只有 256 个值。你要用 256 个离散的刻度去近似原本连续分布的权重和激活,必然有信息损失。量化的全部技术手段,无论是校准、QAT 还是 GPTQ/AWQ,都是在回答同一个问题:怎么分配这 256 个刻度,让损失最小化。
2. INT8 矩阵乘的数学本质与硬件加速逻辑
2.1 从浮点到定点:量化公式的每一个参数都有意义
先看最基础的仿射量化公式:
q = round(x / scale + zero_point) x_hat = (q - zero_point) * scale这里scale是缩放因子,zero_point是零点偏移。很多人背下这个公式就完事了,但真正影响精度的是这两个参数怎么算出来的。
对于对称量化(symmetric),zero_point = 0,scale = max(|x|) / 127。这种方案简单,矩阵乘的时候不需要处理零点偏移,硬件实现最友好。但它有个明显的问题:如果数据分布严重偏斜(比如 ReLU 之后的激活全是非负的),对称量化会把一半的表示范围浪费在负数区间。
对于非对称量化(asymmetric),scale = (max(x) - min(x)) / 255,zero_point = round(-min(x) / scale)。这样能把 256 个刻度全部用在有效数据范围上,精度更高。代价是矩阵乘的时候多了一个零点偏移项,计算量略增。
我实测下来的经验是:权重量化用对称,激活量化用非对称。原因很简单,权重通常近似零均值分布,对称量化够用;而激活经过 ReLU 或 GELU 之后大量是非负值,非对称量化能显著减少截断误差。这个组合在大多数 CNN 和 Transformer 上都能拿到不错的精度。
2.2 INT8 矩阵乘为什么能快:不只是数据宽度减半
INT8 矩阵乘的加速来源有三个层次,很多人只看到第一个。
第一层是内存带宽。FP32 权重占 4 字节,INT8 只占 1 字节,模型体积直接缩小 4 倍。对于内存带宽受限的推理场景(比如大 batch 的 LLM 推理),这个收益非常直接。
第二层是计算吞吐。现代 GPU 和专用加速器(如 NVIDIA 的 Tensor Core)对 INT8 有专门的乘加指令。以 T4 为例,FP32 的峰值算力是 8.1 TFLOPS,INT8 是 130 TOPS,差了 16 倍。这不是简单的 4 倍关系,因为 INT8 的乘加单元可以做得更密集。
第三层是量化后的矩阵乘可以用整数运算完成。核心技巧在这里:
y = W * x = (scale_w * (q_w - zp_w)) * (scale_x * (q_x - zp_x)) = scale_w * scale_x * (q_w * q_x - zp_w * q_x - zp_x * q_w + zp_w * zp_x)其中q_w * q_x是纯整数矩阵乘,可以用 INT8 甚至 INT32 累加器高效完成。后面的偏移项可以在累加完成后统一处理。这就是为什么 INT8 矩阵乘能在硬件上跑得飞快——把浮点乘加变成了整数乘加,只在最后做一次浮点缩放。
注意:INT8 乘法的累加结果必须用 INT32 存储。两个 INT8 相乘最大是 127*127=16129,累加 256 次就会溢出 INT16,累加 16 万次才会溢出 INT32。实际模型中 K 维度通常几百到几千,INT32 累加器完全够用。
2.3 不同精度的算力需求对比:FP64 到 INT8 的真实差距
很多人搜“int8 fp16 fp32 fp64 的区别和算力需求”,我直接给一张实测对比表,数据来自 NVIDIA A100 和 T4 的官方规格加上我自己的 benchmark:
| 精度 | 位宽 | A100 峰值算力 | T4 峰值算力 | 典型用途 |
|---|---|---|---|---|
| FP64 | 64 | 9.7 TFLOPS | 0.25 TFLOPS | 科学计算、仿真 |
| FP32 | 32 | 19.5 TFLOPS | 8.1 TFLOPS | 训练、高精度推理 |
| TF32 | 19 | 156 TFLOPS | 不支持 | 训练加速 |
| FP16 | 16 | 312 TFLOPS | 65 TFLOPS | 混合精度训练、推理 |
| INT8 | 8 | 624 TOPS | 130 TOPS | 推理部署 |
| INT4 | 4 | 1248 TOPS | 260 TOPS | LLM 极致压缩 |
这张表里有个关键信息:从 FP16 到 INT8,算力翻倍,但精度损失远小于从 FP32 到 FP16。原因是 FP16 的尾数只有 10 位,表示范围也窄(最大 65504),容易出现溢出;而 INT8 虽然只有 256 个值,但通过合理的 scale 和 zero_point,可以在特定数据分布上做到接近 FP16 的精度。这就是为什么推理场景首选 INT8 而不是 FP16——性价比最高。
3. 校准:决定量化成败的关键一步
3.1 校准到底在做什么:用少量数据“探测”激活分布
校准(Calibration)的核心任务是:用一批有代表性的输入数据跑一遍模型,统计每一层激活值的分布,然后据此计算 scale 和 zero_point。注意,校准只做前向推理,不更新权重,所以速度很快,通常几百张图就够了。
校准集的选择是第一个坑。我见过太多人直接拿训练集的随机子集做校准,结果精度崩了。正确的做法是:
- 校准集要从真实推理分布中采样,而不是训练集。如果训练集和线上数据分布有差异(domain shift),校准集必须偏向线上分布。
- 校准集数量不用多,100-500 张通常够用。但覆盖度比数量重要——要包含各种极端场景(暗光、高对比度、遮挡等)。
- 校准集不要做数据增强。翻转、裁剪会改变激活分布,导致 scale 偏大或偏小。
3.2 三种主流校准算法:MinMax、Moving Average、Entropy
MinMax 校准是最简单的:直接取校准集上激活的最大最小值。优点是实现简单,缺点是对离群值极其敏感。如果某一层激活里有一个异常大的值(比如 1000),而其他值都在 10 以内,MinMax 会把 scale 拉得很大,导致正常值全部被压缩到很小的整数区间,精度暴跌。
Moving Average MinMax是对 MinMax 的改进:不取全局最大最小,而是用滑动平均的方式统计。具体做法是维护一个 running min/max,每次用new = momentum * old + (1-momentum) * current更新。这样能平滑掉偶发的离群值。TensorRT 默认用的就是这种。
Entropy 校准(也叫 KL 散度校准)是精度最好的方案,TensorRT 的 INT8 校准器核心就是它。思路是:不直接取最大最小值,而是找一个截断阈值T,使得截断后的分布和原始 FP32 分布的 KL 散度最小。具体步骤:
- 把激活值分成 2048 个 bin,统计直方图。
- 从第 128 个 bin 开始,逐个尝试作为截断点
T。 - 对每个
T,把T之外的值全部累加到T上,然后量化到 128 个 level。 - 计算量化后分布和原始分布的 KL 散度。
- 选 KL 散度最小的
T作为最终截断阈值。
这个过程听起来复杂,但实际跑起来很快,因为每层只需要处理一个直方图。我实测下来,Entropy 校准比 MinMax 在 ResNet-50 上能提升 1-2 个点的精度,在检测网络上提升更明显。
实操心得:如果你的模型有 BatchNorm 层,校准前一定要把 BN 折叠进卷积。否则 BN 的 running mean/var 和校准时的统计量不一致,会导致 scale 计算错误。PyTorch 的
torch.quantization.fuse_modules可以自动完成这个折叠。
3.3 校准的常见误区与排查清单
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 某层精度骤降 | 该层激活有极端离群值 | 打印该层激活直方图,检查是否需要 Entropy 校准 |
| 整体精度掉 5 个点以上 | 校准集分布不对 | 对比校准集和验证集的激活分布 |
| 量化后输出全为常数 | scale 计算为 0 或 inf | 检查是否有全零激活层,加 epsilon 保护 |
| 首层量化后精度崩 | 输入数据未归一化 | 确认校准时的预处理和推理时一致 |
| 某些层量化后反而变慢 | 该层不适合 INT8 | 用混合精度,把这些层保留 FP16 |
4. QAT:当校准不够用时,让模型“适应”量化
4.1 QAT 的核心思想:在训练中模拟量化误差
校准是 post-training quantization(PTQ),不需要重新训练,但精度上限有限。当 PTQ 掉点超过可接受范围时,就得上 QAT。
QAT 的核心技巧是伪量化(fake quantization):在前向传播时,把权重和激活量化到 INT8 再反量化回 FP32,模拟量化带来的误差;在反向传播时,用 STE(Straight-Through Estimator)把梯度直接传过去,因为round函数的梯度几乎处处为 0。
class FakeQuantize(nn.Module): def forward(self, x): if self.training: # 更新 scale 和 zero_point self.scale = (x.max() - x.min()) / 255 self.zero_point = -x.min() / self.scale # 伪量化 q = torch.round(x / self.scale + self.zero_point) q = torch.clamp(q, 0, 255) return (q - self.zero_point) * self.scale这段代码看起来简单,但有几个关键细节:
scale和zero_point在训练过程中要持续更新,通常用滑动平均。clamp的范围要根据是权重还是激活来定。权重通常用对称量化,范围是 [-127, 127];激活用非对称,范围是 [0, 255]。- STE 的实现要注意:
round的梯度是 0,但clamp的梯度在边界外是 0,边界内是 1。所以实际梯度是grad * (q >= 0) * (q <= 255)。
4.2 QAT 的训练策略:学习率、epoch 数和 freeze BN
QAT 不是从头训练,而是在预训练模型的基础上 fine-tune。我总结的一套流程:
- 加载 FP32 预训练权重,插入伪量化节点。
- Freeze BatchNorm 的统计量(
track_running_stats=False),因为量化后的激活分布会变,BN 的 running mean/var 需要重新统计。 - 用较小的学习率,通常是原始训练学习率的 1/100 到 1/10。我一般用 1e-5 到 1e-4。
- 训练 5-20 个 epoch,具体看模型大小和数据集。小模型 5 个 epoch 就收敛,大模型可能需要 20 个。
- 最后几个 epoch 关闭伪量化,让模型适应真实的量化推理。这一步叫“量化校准微调”,能再提升 0.5-1 个点。
踩过的坑:QAT 训练时如果学习率太大,伪量化节点的 scale 会剧烈波动,导致训练不收敛。建议前 2 个 epoch 用 warmup,让 scale 稳定下来。
4.3 QAT vs PTQ:什么时候该用哪个
| 维度 | PTQ | QAT |
|---|---|---|
| 是否需要训练 | 否 | 是 |
| 耗时 | 几分钟到几小时 | 几小时到几天 |
| 精度损失 | 1-3 个点 | 0.1-1 个点 |
| 适用场景 | CNN、大模型 | 小模型、检测/分割 |
| 实现复杂度 | 低 | 高 |
| 对校准集依赖 | 高 | 低 |
我的建议是:先试 PTQ,掉点超过 2 个再上 QAT。对于 ResNet、MobileNet 这类 CNN,PTQ 通常够用;对于 YOLO、Mask R-CNN 这类检测分割模型,QAT 几乎是必须的,因为小目标对量化误差极其敏感。
5. LLM 量化:当模型大到校准都跑不动
5.1 LLM 量化的特殊挑战:激活离群值
LLM 量化和 CNN 量化最大的区别在于:LLM 的激活存在极端的离群值。这个问题在 GPT 系列模型上特别明显——某些通道的激活值比其他通道大 100 倍以上。如果直接用 MinMax 校准,这些离群值会把 scale 拉得极大,导致正常通道的精度全部丢失。
这个问题最早由 SmoothQuant 这篇工作系统性地解决。核心思路是:把激活的量化难度转移到权重上。具体做法是引入一个平滑因子s:
Y = (X / s) * (W * s)这样X / s的离群值被压制,而W * s的量化难度增加(但权重本身分布比较均匀,能承受)。s的选择是关键,通常取s = max(|X|)^alpha / max(|W|)^(1-alpha),alpha在 0.5 左右。
5.2 GPTQ、AWQ、GGUF:三种主流 LLM 量化方案对比
| 方案 | 核心思想 | 量化粒度 | 精度 | 推理速度 | 适用场景 |
|---|---|---|---|---|---|
| GPTQ | 逐层最小化重构误差 | 分组(128) | 高 | 快 | GPU 推理 |
| AWQ | 保护重要通道 | 分组 | 很高 | 快 | GPU 推理 |
| GGUF | 混合精度量化 | 块 | 中高 | 中 | CPU/边缘 |
| SmoothQuant | 激活-权重难度转移 | 张量 | 高 | 快 | 通用 |
GPTQ的思路是:对每一层,用校准数据找到最优的量化权重,使得||WX - W_q X||^2最小。它用 Hessian 矩阵的逆来指导量化顺序,优先量化对输出影响小的权重。GPTQ 的优点是精度高、推理快,缺点是量化过程需要跑校准数据,比较慢。
AWQ(Activation-aware Weight Quantization)的洞察是:不是所有通道都同等重要。它通过分析激活的幅度来识别重要通道,对这些通道保留更高的精度。AWQ 的量化速度比 GPTQ 快,精度也略好。
GGUF是 llama.cpp 用的格式,支持混合精度——不同层可以用不同的位宽。比如注意力层用 4 bit,FFN 层用 8 bit。这种灵活性让 GGUF 在 CPU 推理上表现很好。
实操建议:如果你在 GPU 上部署 LLM,优先考虑 AWQ 或 GPTQ;如果在 CPU 或边缘设备上,GGUF 是更好的选择。量化到 4 bit 时,AWQ 的精度通常比 GPTQ 高 0.5-1 个点。
5.3 LLM 量化的实操流程:以 AWQ 为例
# 安装 autoawq pip install autoawq # 量化模型 from awq import AutoAWQForCausalLM from transformers import AutoTokenizer model_path = "meta-llama/Llama-2-7b-hf" quant_path = "llama-2-7b-awq" model = AutoAWQForCausalLM.from_pretrained(model_path) tokenizer = AutoTokenizer.from_pretrained(model_path) # 校准数据 calib_data = [ "The quick brown fox jumps over the lazy dog.", "Machine learning is a subset of artificial intelligence.", # ... 更多校准文本 ] # 量化配置 quant_config = { "zero_point": True, "q_group_size": 128, "w_bit": 4, "version": "GEMM" } model.quantize(tokenizer, quant_config=quant_config, calib_data=calib_data) model.save_quantized(quant_path) tokenizer.save_pretrained(quant_path)这段代码里几个关键参数:
q_group_size=128:每 128 个权重共享一个 scale。group size 越小,精度越高,但元数据开销越大。128 是精度和开销的平衡点。w_bit=4:4 bit 量化。LLM 通常用 4 bit,因为 8 bit 的压缩收益不够明显,而 4 bit 能把 7B 模型压到 3.5GB 左右。version="GEMM":用 GEMM 内核,推理速度更快。另一个选项是 "GEMV",适合 batch size 为 1 的场景。
校准数据的选择对 AWQ 影响很大。我一般用 128-512 条文本,覆盖不同领域(新闻、代码、对话等)。校准数据太少会导致某些通道的统计不准确,太多则量化时间过长。
6. 量化部署的工程细节与性能调优
6.1 ONNX 量化实操:从 FP32 到 INT8 的完整流程
ONNX Runtime 提供了一套完整的量化工具链。我以 ResNet-50 为例,走一遍完整流程:
import onnx from onnxruntime.quantization import quantize_dynamic, quantize_static from onnxruntime.quantization import CalibrationDataReader # 动态量化(不需要校准数据) quantize_dynamic( model_input="resnet50_fp32.onnx", model_output="resnet50_int8_dynamic.onnx", weight_type=QuantType.QInt8 ) # 静态量化(需要校准数据) class DataReader(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} quantize_static( model_input="resnet50_fp32.onnx", model_output="resnet50_int8_static.onnx", calibration_data_reader=DataReader(calib_data), quant_format=QuantFormat.QDQ, per_channel=True, activation_type=QuantType.QUInt8, weight_type=QuantType.QInt8 )几个关键选择:
- 动态量化 vs 静态量化:动态量化只量化权重,激活在推理时动态计算 scale。适合 NLP 模型(如 BERT),因为激活分布随输入变化大。静态量化权重和激活都量化,适合 CNN,推理速度更快。
- per_channel vs per_tensor:per_channel 对每个输出通道单独计算 scale,精度更高,但元数据更多。卷积层建议用 per_channel,全连接层可以用 per_tensor。
- QDQ vs QOperator:QDQ 格式在算子前后插入 QuantizeLinear/DequantizeLinear 节点,兼容性好;QOperator 格式把量化融合进算子,性能更好但兼容性差。
6.2 量化后的性能实测:延迟、吞吐和内存
我在 T4 上实测了 ResNet-50 在不同精度下的表现:
| 精度 | 模型大小 | 单帧延迟 | 吞吐(batch=32) | 内存占用 |
|---|---|---|---|---|
| FP32 | 98 MB | 38 ms | 840 img/s | 1.2 GB |
| FP16 | 49 MB | 22 ms | 1450 img/s | 0.8 GB |
| INT8 (PTQ) | 25 MB | 11 ms | 2900 img/s | 0.5 GB |
| INT8 (QAT) | 25 MB | 11 ms | 2900 img/s | 0.5 GB |
INT8 相比 FP32,延迟降低 3.5 倍,吞吐提升 3.4 倍,内存降低 2.4 倍。这个收益在边缘设备上更明显——Jetson Nano 上 FP32 跑 ResNet-50 只有 8 FPS,INT8 能到 27 FPS。
注意:INT8 的加速比不是线性的。如果模型里有大量非卷积操作(如 reshape、transpose),这些操作在 INT8 下不会加速,反而可能因为插入量化/反量化节点而变慢。所以量化前要分析模型的算子构成,卷积占比低于 60% 的模型,量化收益有限。
6.3 混合精度量化:哪些层该保留 FP16
不是所有层都适合 INT8。我总结了几类建议保留 FP16 的层:
- 首层和末层:首层直接处理输入数据,量化误差会传播到整个网络;末层影响最终输出,精度要求高。
- 检测头:目标检测的分类和回归头对精度敏感,INT8 会导致小目标漏检。
- 注意力层:Transformer 的注意力计算有 softmax,量化后数值稳定性差。
- 残差连接:残差相加的两个分支如果量化 scale 不一致,会导致精度损失。
ONNX Runtime 和 TensorRT 都支持混合精度量化,可以指定某些层跳过量化。TensorRT 的做法是在校准后分析每层的敏感度,自动决定哪些层保留 FP16。
7. 常见问题排查与避坑指南
7.1 量化精度掉点的系统化排查流程
精度掉点不要慌,按这个顺序排查:
- 确认基线:FP32 模型的精度是多少?如果 FP32 本身就不行,量化只会更差。
- 逐层对比:用 Netron 打开量化前后的模型,对比每层的输出。找到第一个误差超过阈值的层。
- 检查校准集:校准集的分布和验证集是否一致?用直方图对比激活分布。
- 检查量化配置:per_channel 开了吗?激活用非对称了吗?首末层跳过量化了吗?
- 尝试 Entropy 校准:如果用的是 MinMax,换成 Entropy 试试。
- 上 QAT:如果以上都不行,只能 QAT 了。
7.2 量化部署的五个常见坑
坑一:BN 层没有折叠。量化前必须把 BatchNorm 折叠进卷积,否则推理时的 BN 计算和量化 scale 不匹配。PyTorch 的torch.quantization.fuse_modules可以自动完成。
坑二:预处理不一致。校准时的预处理(归一化、resize)必须和推理时完全一致。我见过有人校准时用了ToTensor()但推理时忘了除以 255,导致 scale 差了 255 倍。
坑三:动态 shape 不支持。很多量化工具对动态 shape 支持不好。如果模型输入是动态的,建议固定 shape 后再量化。
坑四:量化后模型变大。这通常是因为插入了大量 QDQ 节点。用onnxruntime.quantization.quantize_static的optimize_model=True可以自动优化。
坑五:INT8 推理反而变慢。检查是否有大量非卷积操作,或者 batch size 太小(小于 8 时 INT8 的加速效果不明显)。
7.3 量化工具选型速查表
| 工具 | 支持框架 | 量化方式 | 适用场景 | 上手难度 |
|---|---|---|---|---|
| ONNX Runtime | ONNX | PTQ/QAT | 通用部署 | 低 |
| TensorRT | ONNX/Caffe | PTQ/QAT | NVIDIA GPU | 中 |
| PyTorch Quantization | PyTorch | PTQ/QAT | 研究/实验 | 中 |
| AutoAWQ | HuggingFace | PTQ | LLM | 低 |
| GPTQ-for-LLaMa | HuggingFace | PTQ | LLM | 中 |
| llama.cpp | GGUF | PTQ | CPU/边缘 | 低 |
选型的核心原则:先看部署目标硬件,再看模型类型。NVIDIA GPU 首选 TensorRT,通用 CPU 首选 ONNX Runtime,LLM 首选 AWQ 或 GPTQ。
8. 量化技术的边界与个人实践体会
量化不是万能的。有些模型天生对量化不友好——比如那些依赖精细数值区分的模型(如某些语音合成模型),或者激活分布极其复杂的模型(如多模态大模型)。遇到这种情况,与其死磕量化,不如考虑其他优化手段:算子融合、KV Cache 优化、投机解码等。
我在实际项目里的一条经验是:量化收益的 80% 来自 20% 的层。先用敏感度分析找到那 20% 的关键层,重点优化它们,比全模型一刀切效果好得多。TensorRT 的trtexec --int8 --calib可以输出每层的敏感度报告,非常实用。
最后分享一个 LLM 量化的小技巧:如果你用 AWQ 量化 7B 模型,校准数据里一定要包含一些长文本(超过 512 token)。短文本的激活分布和长文本差异很大,只用短文本校准会导致长文本推理时精度下降。我一般用 20% 的长文本加 80% 的短文本,效果比较均衡。