1. 为什么模型上线绕不开量化这道坎
做过模型部署的人大概都有类似的经历:训练阶段一切顺利,指标也好看,可一旦要把模型塞进实际的生产环境,推理延迟、显存占用、吞吐量这三座大山就压过来了。我最早接触量化是在一个视觉检测项目上,当时一个 ResNet 变体在服务器上跑得好好的,移植到边缘设备后单帧推理要 200 多毫秒,产线节拍根本跟不上。后来把权重和激活都压到 INT8,速度直接翻了将近三倍,精度只掉了不到一个百分点。从那以后,量化就成了我部署流程里的标配环节。
这篇内容想聊的就是模型部署与推理优化里的量化这件事,核心围绕INT8 矩阵乘、校准、QAT 以及 LLM 量化这几个关键词展开。它解决的核心问题是:在尽量不损失精度的前提下,把模型的存储和计算从高比特浮点压缩到低比特整数,从而降低显存占用、提升推理速度、减少功耗。适合谁看?如果你正在做模型部署、推理加速,或者手上有大模型想跑在有限的硬件资源上,那这篇基本能覆盖你从原理到落地的完整链路。我会尽量把每个环节的“为什么”讲清楚,而不是只丢一堆 API 让你照抄。
先说清楚一个前提:量化不是万能的银弹,它本质上是一场精度与效率的权衡。你得先搞清楚自己的瓶颈在哪——是显存不够、是延迟太高、还是功耗受限,然后再决定用哪种量化方案。盲目上 INT8 有时候反而会因为反量化开销导致速度不升反降,这个坑我后面会细讲。
2. 量化到底在做什么:从浮点到整数的本质
2.1 数值类型的基本盘:FP32、FP16、BF16、INT8 的区别
要理解量化,得先把这几种数值类型摆在一起看。它们最核心的差异在于表示范围和精度,而这直接决定了算力需求和硬件支持情况。
| 类型 | 位宽 | 指数位 | 尾数位 | 大致范围 | 典型用途 |
|---|---|---|---|---|---|
| FP32 | 32 | 8 | 23 | ±3.4e38 | 训练、高精度推理 |
| FP16 | 16 | 5 | 10 | ±65504 | 混合精度训练、GPU 推理 |
| BF16 | 16 | 8 | 7 | ±3.4e38 | 训练、大模型推理 |
| INT8 | 8 | - | - | -128~127 | 量化推理 |
FP16 的尾数有 10 位,精度不错,但范围窄,容易溢出;BF16 牺牲了尾数精度换来了和 FP32 一样的指数范围,所以在训练大模型时特别受欢迎,因为不容易出现梯度溢出。INT8 则是纯整数,只有 256 个离散取值,它没法直接表示小数,所以量化的关键就在于建立一个浮点到整数的映射关系。
这里有个很多人忽略的点:算力需求不只是看位宽。INT8 的矩阵乘在支持它的硬件上(比如带 DP4A 指令的 GPU、或者专门的 NPU)能跑到 FP16 的好几倍吞吐,因为整数乘加可以打包处理。但如果硬件不支持 INT8 加速,那量化带来的可能只是存储上的好处,计算上未必快。所以选型前一定要确认目标硬件的指令集支持情况。
2.2 量化的数学本质:仿射映射
量化的核心公式其实很朴素,就是一个仿射变换:
q = round(x / scale) + zero_point x_float ≈ (q - zero_point) * scale其中scale是缩放因子,zero_point是零点偏移。为什么要零点?因为浮点的 0 在量化后不一定对应整数的 0,为了让浮点零能精确表示(这对 ReLU 之后的激活、padding 都很重要),需要引入零点偏移。
scale的计算方式决定了量化的粒度:
- per-tensor:整个张量共用一个 scale,简单但精度损失大
- per-channel:每个通道一个 scale,常用于权重量化,精度明显更好
- per-group:每若干元素一组,LLM 量化里常用,比如 group_size=128
我个人的经验是,权重量化基本都用 per-channel,激活量化用 per-tensor 就够了,因为激活的动态范围在推理时相对稳定。这个选择背后是有道理的:权重在不同通道间的分布差异很大,共用一个 scale 会让某些通道的量化误差特别大;而激活经过归一化层之后,各通道分布相对接近。
2.3 对称量化与非对称量化怎么选
对称量化强制 zero_point=0,映射区间关于原点对称;非对称量化则允许零点偏移。两者的取舍很实际:
- 对称量化:实现简单,整数运算时不用额外处理零点,速度略快。适合权重这种大致对称分布的张量。
- 非对称量化:能更好地拟合非对称分布,比如 ReLU 之后的激活全是非负的,用非对称量化能充分利用整数范围。
实测下来,权重用对称、激活用非对称是个比较稳的组合。不过现在很多推理框架会统一处理,你只需要在配置里指定就行。需要注意的是,非对称量化在计算时要处理零点,某些硬件上会引入额外开销,如果追求极致速度,可以试试全对称方案,看精度能不能接受。
3. INT8 矩阵乘:量化真正提速的关键环节
3.1 为什么矩阵乘是量化的主战场
模型推理里绝大部分计算量都集中在矩阵乘和卷积上,而卷积在实现层面往往也会转成矩阵乘(im2col)。所以只要把矩阵乘做成 INT8,整个模型的推理速度就能有质的提升。这也是为什么各家硬件厂商都在拼命优化 INT8 矩阵乘的原因。
INT8 矩阵乘的基本思路是:把两个 INT8 矩阵相乘,累加结果用 INT32 存储(因为 INT8 乘 INT8 最大是 127*127≈16129,累加几百次就会溢出 INT16,所以必须用 INT32 累加器),最后再统一反量化回浮点。这个设计很关键——累加过程保持整数,只在最后做一次反量化,这样既保证了精度,又避免了中间过程的浮点开销。
3.2 反量化时机的选择与性能影响
这里有个容易被忽视的性能陷阱:反量化的位置。有两种常见做法:
- 先累加再反量化:INT8 矩阵乘得到 INT32 结果,然后乘以 scale 得到浮点。这是标准做法,效率最高。
- 边累加边反量化:每累加一部分就转回浮点。这种做法会频繁触发类型转换,性能极差。
我踩过的坑就是早期用某个框架时没注意配置,结果它默认走了第二种路径,速度比 FP32 还慢。后来查了 profiling 才发现瓶颈全在类型转换上。所以你在做性能调优时,一定要用 profiler 确认反量化发生在哪一步。
另外,INT8 矩阵乘对内存布局很敏感。很多硬件要求权重预先做重排(比如从 NCHW 转成 NHWC 或者特定的分块布局),这样能提升缓存命中率。ONNX Runtime 和 TensorRT 在构建引擎时都会自动做这个优化,但如果你自己写 kernel,就得手动处理。
3.3 硬件指令集对 INT8 的支持差异
不同硬件对 INT8 的支持程度差别很大,这直接决定了量化的收益:
- NVIDIA GPU:从 Turing 架构开始支持 DP4A 指令,Ampere 之后有更完整的 INT8 Tensor Core,吞吐是 FP16 的两倍。
- 移动端 NPU:基本都原生支持 INT8,甚至有些只支持 INT8,这时候量化不是可选项而是必选项。
- CPU:x86 上有 VNNI 指令集能加速 INT8,ARM 上有 dotprod 指令。老 CPU 没有这些指令的话,INT8 可能跑不过 FP32。
所以量化前先查清楚目标硬件的指令集支持,这一步能帮你省下大量无用功。我见过有人在一台老服务器上折腾半天 INT8,结果发现 CPU 不支持 VNNI,速度毫无变化。
4. 校准:训练后量化精度的命门
4.1 校准在做什么,为什么不能省
训练后量化(PTQ)最大的挑战是:权重的分布你是知道的(训练完就固定了),但激活的分布取决于输入数据,你没法提前知道。校准就是用一批有代表性的数据跑一遍模型,统计每一层激活的取值范围,从而确定 scale 和 zero_point。
校准数据的质量和数量直接决定量化精度。我的经验是:
- 数量上,100 到 500 个样本通常够用,太少统计不准,太多收益递减。
- 质量上,校准数据必须和实际推理数据的分布一致。如果你用猫狗图片校准,却拿去推理工业缺陷图,精度崩了别怪量化。
有个真实的教训:我之前做一个文本分类模型,图省事用了训练集的前 100 条做校准,结果上线后发现某些长文本的精度特别差。后来换成按长度分层采样,问题就解决了。校准集的代表性比数量重要得多。
4.2 主流校准算法对比:MinMax、MSE、熵校准
校准的核心是选一个合适的截断范围(clipping range)。因为激活里往往有少数极端值,如果直接用 min/max,这些离群点会把 scale 拉得很大,导致大部分正常值被压缩到很少的整数格子里,精度损失严重。
| 校准方法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| MinMax | 直接用最大最小值 | 简单、无偏 | 对离群值敏感 |
| MSE | 最小化量化前后均方误差 | 精度较好 | 计算稍慢 |
| 熵校准 | 最小化信息熵差异 | 精度最好 | 计算最慢 |
| Percentile | 取百分位截断 | 抗离群值 | 百分位需调参 |
熵校准(也叫 KL 散度校准)是 TensorRT 的默认方法,效果通常最好,但需要更多的校准样本和计算时间。MSE 是个不错的折中。如果追求速度,MinMax 配合百分位截断也能用。
我一般会先用熵校准跑一版看精度,如果达标就用;如果时间紧,用 MSE 也基本够。Percentile 的截断比例是个超参,常见取 99.9% 或 99.99%,具体得试。
4.3 校准实操:以 ONNX 量化为例
ONNX Runtime 提供了比较成熟的 PTQ 流程,我以它为例走一遍。首先准备校准数据读取器:
import numpy as np from onnxruntime.quantization import CalibrationDataReader class MyCalibReader(CalibrationDataReader): def __init__(self, calib_data): self.data = calib_data self.idx = 0 def get_next(self): if self.idx >= len(self.data): return None batch = self.data[self.idx] self.idx += 1 return {"input_name": batch} def rewind(self): self.idx = 0然后调用量化接口:
from onnxruntime.quantization import quantize_static, QuantType, QuantFormat quantize_static( model_input="model.onnx", model_output="model_int8.onnx", calibration_data_reader=MyCalibReader(calib_samples), quant_format=QuantFormat.QDQ, activation_type=QuantType.QInt8, weight_type=QuantType.QInt8, per_channel=True, )这里QuantFormat.QDQ表示用 Quantize-Dequantize 格式插入量化节点,兼容性好;per_channel=True开启逐通道权重量化。跑完之后一定要用验证集对比量化前后的精度,别直接上线。
注意:校准数据读取器返回的字典 key 必须和模型输入名完全一致,否则会静默失败或者报奇怪的错。我第一次用的时候 key 写错了,结果校准根本没生效,量化后精度惨不忍睹。
5. QAT:当 PTQ 精度不够时的终极武器
5.1 QAT 的核心思想:让模型提前适应量化误差
PTQ 简单快捷,但有些模型(尤其是小模型或者对精度敏感的检测、分割模型)量化后精度掉得厉害。这时候就得上量化感知训练(QAT)。QAT 的思路很巧妙:在训练阶段就模拟量化的误差,让模型在训练过程中学会补偿这些误差。
具体做法是在前向传播里插入伪量化节点(fake quantize),它做的是“量化再反量化”的操作,数值上引入了量化误差,但梯度还能正常回传(用 STE,直通估计器)。这样训练出来的模型,权重和激活的分布会自然地向量化友好的方向靠拢。
QAT 通常能比 PTQ 多挽回一到两个百分点的精度,代价是需要额外的训练时间和标注数据。我的判断标准是:如果 PTQ 后精度下降在 1% 以内,直接用 PTQ;超过 1% 且业务无法接受,再考虑 QAT。
5.2 伪量化节点的插入位置与 STE 原理
伪量化节点的插入位置很讲究。标准做法是:
- 权重:在卷积/全连接层之前插入
- 激活:在激活函数之后插入
这样能覆盖所有需要量化的张量。STE(Straight-Through Estimator)是 QAT 能训练的关键——量化函数本身不可导(round 操作梯度为 0),STE 直接让梯度“穿过”量化节点,近似认为量化是恒等映射。这个近似虽然粗糙,但实践中效果出奇地好。
用 PyTorch 的话,可以用torch.quantization或者torch.ao.quantization模块:
import torch.ao.quantization as tq model.qconfig = tq.get_default_qat_qconfig('fbgemm') model_prepared = tq.prepare_qat(model, inplace=False) # 正常训练若干 epoch for epoch in range(num_epochs): train_one_epoch(model_prepared) # 训练完转成量化模型 model_prepared.eval() model_int8 = tq.convert(model_prepared, inplace=False)fbgemm是 x86 后端的配置,ARM 上用qnnpack。选错后端会导致量化方案不匹配,转换时报错。
5.3 QAT 训练的几个关键技巧
QAT 训练和普通训练有些不一样的地方,我总结几条实战经验:
- 学习率要调小:QAT 是在预训练模型基础上微调,学习率通常设为原训练的 1/10 到 1/100,否则容易把预训练学到的特征破坏掉。
- 先冻结再解冻:可以先用较小的学习率只训练量化参数(scale、zero_point),稳定后再解冻所有权重一起训。
- BN 层的处理:BatchNorm 在 QAT 里要特别小心,通常建议在 QAT 后期冻结 BN 的统计量,避免它和量化参数互相干扰。
- 训练轮数不用多:一般几个 epoch 就够了,太多反而过拟合。
我做过一个对比实验,同一个检测模型,PTQ 后 mAP 掉了 2.3 个点,QAT 训练 5 个 epoch 后只掉了 0.4 个点,效果还是很明显的。但 QAT 的工程复杂度确实高不少,需要把训练流程重新搭一遍。
6. LLM 量化:大模型时代的特殊挑战
6.1 为什么 LLM 量化不能照搬 CNN 那套
大语言模型的量化和传统 CNN 有本质区别。CNN 的激活分布相对稳定,PTQ 加校准基本能搞定。但 LLM 有几个特点让量化变得棘手:
- 激活里存在极端的离群值:某些通道的激活值能比其他通道大几十倍,这是 LLM 的固有特性。如果按常规 per-tensor 量化,这些离群值会把 scale 撑爆,导致其他值全被压成 0。
- 参数规模巨大:70B 的模型光权重就 140GB(FP16),量化到 INT8 能省一半,到 INT4 能省更多,这对显存受限的场景是刚需。
- 对精度敏感:LLM 的输出是逐 token 生成的,误差会累积,量化不当会导致生成质量明显下降甚至胡言乱语。
所以 LLM 量化发展出了一套专门的技术路线,核心就是如何处理激活离群值。
6.2 主流的 LLM 量化方案对比
目前业界比较成熟的几套方案,我按自己的理解梳理一下:
| 方案 | 核心思路 | 权重量化 | 激活量化 | 特点 |
|---|---|---|---|---|
| GPTQ | 逐层误差补偿 | INT4/INT8 | 不量化 | 权重离线量化,推理快 |
| AWQ | 保护重要通道 | INT4 | 不量化 | 精度好,适合小模型 |
| SmoothQuant | 把激活难度迁移到权重 | INT8 | INT8 | 实现 W8A8,速度提升明显 |
| LLM.int8() | 离群值单独用 FP16 处理 | INT8 | INT8 | 混合精度,精度稳 |
SmoothQuant 的思路我觉得特别巧妙:既然激活有离群值难量化,而权重相对好量化,那就通过一个数学等价的变换,把激活的量化难度“迁移”一部分到权重上。具体是引入一个 per-channel 的缩放因子 s,把激活除以 s、权重乘以 s,保持矩阵乘结果不变,但让两者的动态范围都变得更友好。
LLM.int8() 则是另一条路:它发现离群值主要集中在少数几个特征维度上,于是把这些维度单独拎出来用 FP16 算,剩下的用 INT8 算。这种混合精度方案精度很稳,但实现复杂,速度提升不如纯 INT8。
6.3 分组量化与离群值处理实操
现在主流的 LLM 量化基本都用分组量化(group-wise quantization)。就是把权重按每 128 个元素分一组,每组独立算 scale。这样每组内部的数值范围更集中,量化误差更小。代价是需要存储更多的 scale,但相比精度收益,这点开销值得。
以 GPTQ 为例,它的核心是逐层做量化,并用 Hessian 矩阵指导权重的舍入方向,让量化误差在输出层面最小化。用现成的库跑起来其实不复杂:
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig quantize_config = BaseQuantizeConfig( bits=4, group_size=128, desc_act=False, ) model = AutoGPTQForCausalLM.from_pretrained( "model_path", quantize_config=quantize_config, ) model.quantize(calib_dataset) model.save_quantized("output_path")group_size=128是经验值,越小精度越好但压缩率越低。desc_act控制是否按激活重要性排序,开启后精度略好但推理稍慢。
注意:LLM 量化的校准集选择比 CNN 更关键。建议用和目标场景接近的文本,比如你要做代码生成,就用代码数据校准;做通用对话,就用多样化的对话数据。用错了校准集,量化后的模型可能在你关心的任务上表现很差。
7. 常见问题与排查技巧实录
7.1 量化后精度暴跌怎么排查
精度暴跌是最常见也最头疼的问题。我一般按这个顺序排查:
- 确认校准数据是否有代表性:先换一批更贴近实际分布的校准数据试试,这是最高频的原因。
- 检查是否有层不适合量化:某些层(比如第一层卷积、最后的分类头、LayerNorm)对量化特别敏感,可以尝试把这些层排除在量化之外,保持 FP16。
- 看激活的离群值情况:打印每层激活的最大最小值,如果某层动态范围异常大,考虑用 per-channel 或者混合精度处理。
- 对比逐层误差:用工具逐层对比量化前后的输出差异,定位到具体是哪一层出的问题。
我遇到过一次精度暴跌,最后发现是某个自定义算子的量化实现有 bug,导致输出全错。所以如果用的是非标准算子,一定要重点检查。
7.2 量化后速度没提升甚至变慢的原因
这个问题的排查思路和精度问题完全不同:
- 硬件不支持 INT8 加速:前面提过,先查指令集。
- 反量化开销过大:如果模型里量化层和浮点层频繁交替,每次都要做类型转换,开销会吃掉收益。尽量让量化区域连续。
- 算子融合没做好:Conv+BN+ReLU 这种组合如果能融合成一个量化算子,效率会高很多。检查框架是否开启了融合。
- batch size 太小:INT8 的优势在大 batch 下更明显,小 batch 时可能被固定开销拖累。
7.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 精度掉超过 3% | 校准数据不匹配 | 换校准集,检查分布 |
| 精度掉 1-3% | 离群值影响 | 用 per-channel 或混合精度 |
| 速度无提升 | 硬件不支持 | 查指令集,换硬件 |
| 速度变慢 | 反量化频繁 | 检查量化区域连续性 |
| 输出全为 0 或 NaN | scale 计算错误 | 检查校准是否生效 |
| 部分层报错 | 算子不支持量化 | 排除该层或换实现 |
7.4 几条压箱底的实操心得
最后分享几条我踩坑总结出来的经验,都是文档里不会写的:
- 量化前先备份 FP32 模型:量化过程有时会修改原模型,别问我怎么知道的。
- 小模型慎用激进量化:参数量本来就少,量化误差占比大,INT4 在小模型上经常翻车。
- 端到端测延迟,别只看单算子:单算子快不代表整体快,内存搬运、调度开销都要算进去。
- 保留一个 FP16 兜底版本:线上出问题时能快速回滚,这个太重要了。
- 量化不是一次性的:模型更新、数据分布变化后,校准和量化都要重做。
量化这件事,原理不难,难的是工程细节和场景适配。同一个方案在不同模型、不同硬件上的表现可能天差地别,所以一定要在自己的实际场景里验证,别迷信任何一篇教程的结论,包括我这篇。多动手测,多对比数据,慢慢就能摸出适合自己业务的量化配方了。