开局先交代一个大家可能都遇到过的情况:模型跑通了,准确率也没得说,但一上生产环境就露怯——单张推理耗时动辄几十毫秒,GPU利用率上不去,面对线上流量根本扛不住。这时候,很多人第一反应是换更强的卡,或者上多卡并行。但实际上,模型本身还有相当可观的“压缩空间”,而 TensorRT 量化就是其中最直接、最成熟的一条路。
这篇文章要聊的,就是 TensorRT 量化的完整落地路径:从最省事的 PTQ(训练后量化)入手,再到效果更稳的 QAT(量化感知训练),把每个环节的原理、操作步骤、坑点全都过一遍。内容基于我实际跑过的几个项目——有检测模型、有分割模型,也有纯分类小网络,覆盖的目标平台包括边缘盒子和数据中心 GPU。不管你是刚上手推理优化的新手,还是已经被量化精度问题折磨过的老手,这篇文章应该都能给你一些能直接抄作业的东西。
1. 量化为什么能让推理速度“飞升”
先别急着写代码,得先想明白一个问题:量化到底在做什么,它凭什么能提速。
1.1 量化的本质:用更少的位数表达相似的数值
神经网络里的权重和激活值,在训练时通常用 FP32 存储,也就是每个数占 32 位。量化要做的事情,就是把这些 32 位浮点数映射到更低位宽的表示,最常见的就是 INT8,每个数只占 8 位。这样一来,模型体积直接缩小到原来的四分之一,内存带宽占用也同步下降。
但速度提升的真正来源,不光是因为数据变小了,更关键的是硬件对低精度运算的特殊优化。拿 GPU 来说,现在的架构里 Tensor Core 对 INT8 矩阵乘法的吞吐通常是 FP32 的数倍甚至十几倍。这意味着同样的计算量,用 INT8 跑一遍,理论耗时可以显著缩短。
有人可能会问,那直接用 FP16 不就行了吗?FP16 确实也能用 Tensor Core,提速也明显,而且精度损失极小。但很多部署场景对延迟要求极其苛刻,FP16 的加速幅度不够看。而 INT8 才能让推理延迟再下降一个台阶。
拿我实测过的一个语义分割模型来说,输入 512x512,在边缘设备上 FP16 engine 的延迟是 18 毫秒,INT8 直接压到 9 毫秒出头,而且精度只掉了 0.3 个百分点。这个“买卖”太划算了。
1.2 量化的数学原理:对称量化和非对称量化
量化不是简单地“截断取整”,它背后有一套数学映射关系。我们常用的两种映射方式:对称量化和非对称量化。
对称量化用公式表示是:
x_int = round(x_float / scale)其中 scale = max(|x|) / 127。这个方案简单,INT8 的 0 能精确对应浮点 0,硬件实现起来很高效,TensorRT 默认走的就是这套。代价是如果数据的分布偏向一侧(比如激活值全是非负的),有一半的量化区间是浪费的,精度会受影响。
非对称量化则引入一个零点偏移(zero point),公式变成:
x_int = round(x_float / scale) + zero_point它能更精细地利用 [-128, 127] 的完整区间,但实现复杂度高一些。TensorRT 在部分场景下也会根据情况使用非对称方案。
理解这一点很重要,因为它直接关系到后面 PTQ 校准算法的选择和效果判断。如果某一层的激活值分布严重不均匀,还偏要用对称量化,那掉点几乎是必然的。
1.3 量化粒度:per-tensor 和 per-channel
除了位宽,量化粒度也是决定精度的关键因素。per-tensor 是指整个张量共用一个 scale,实现简单,但遇到权重通道间数值差异很大的情况,精度就受限于“最极端的那个通道”。per-channel 则是对每个通道单独算 scale,明显更精细,是目前的主流做法。
TensorRT 对权重默认就是 per-channel 量化,激活值则策略性地选择 per-tensor。这也是为什么同样是 INT8,TensorRT 的效果就是比某些自己写的简易量化工具好一截——细节都在这些看不见的地方。
这里要特别注意,量化粒度和校准数据的选择是一对“连体婴”:per-channel 能兜住权重分布差异,但激活值的动态范围完全取决于校准数据。校准集不够有代表性,per-channel 也救不回来。
1.4 TensorRT 量化的整体方案选型
对 TensorRT 而言,落地 INT8 大概有三条路:
- PTQ 离线校准:加载 FP32 模型,喂一批校准数据,统计激活值的动态范围,然后生成 INT8 engine。
- QAT 训练感知量化:在训练阶段就模拟量化误差,让模型自己“适应”低精度,导出后再转 TensorRT。
- 训练中直接导出 INT8 模型再转换:这条路其实也归入 QAT。
实际项目里该怎么选?我的判断标准很简单:
如果手头有足够的、有代表性的校准数据,而且模型对精度不太敏感(比如检测框的 IoU 要求不是极端严格),先跑 PTQ。如果 PTQ 掉点超过可接受范围,再上 QAT。QAT 也不是万能,但多数情况下能把精度拉回接近 FP32 的水平。
接下来,把 PTQ 和 QAT 两条路的实操细节分别展开。
2. PTQ 实战:别只会跑 calibration
PTQ 听起来简单——跑一遍校准工具,导出 INT8 engine,完事。但很多人在这一步就翻车了,而且翻得莫名其妙。
2.1 PTQ 的完整工作流程
PTQ 的标准流程分这么几步:
- 准备一个训练好的 FP32 模型,导出成 ONNX。
- 准备校准数据集,一般是几百张有代表性的输入图片。
- 用 TensorRT 的 calibrator 跑一遍校准,统计每层激活值的动态范围。
- 生成 INT8 engine,对比精度和速度。
看起来很顺,但每一步都有细节。先说模型导出。很多人用 PyTorch 训练,导出 ONNX 时只注意了 torch.onnx.export 能跑通,却忽略了动态 shape 的设置。如果你的模型要处理不同尺寸的输入,这里必须显式声明动态轴:
import torch # 假设 model 是训练好的模型 dummy_input = torch.randn(1, 3, 512, 512) torch.onnx.export( model, dummy_input, "model.onnx", opset_version=13, input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch", 2: "height", 3: "width"}, "output": {0: "batch", 2: "height", 3: "width"}, }, )opset_version 不要太老,建议 13 以上。碰到某些算子(比如一些自定义 op)导出失败时,先查算子版本支持情况,不要一上来就怀疑模型结构。
2.2 校准数据怎么准备:数量、多样性和预处理
校准数据是 PTQ 的灵魂。TensorRT 用校准数据来统计每一层激活值的 min/max 或者更复杂的分布信息,如果校准数据不能代表真实推理时的数据分布,那统计出来的动态范围就是偏的。
我常用的配置是:500~1000 张图片,类别分布尽量均匀。太少,统计不稳定;太多,校准时间拉长,收益也不明显。500 到 1000 这个区间,性价比最高。
更关键的是预处理必须和训练时完全一致。包括归一化系数、通道顺序、缩放尺寸、是否 BGR 等。这一点听起来简单,但踩坑的人最多。我见过一个项目,训练用 RGB 归一化到 [0,1],校准脚本却用 BGR 并且减均值,最后 INT8 精度直接崩了 10 个点,查了半天才发现是预处理不一致。
再补充一点:校准数据千万不要用训练集里那些“完美”的样本,最好从真实采集的数据里抽样。如果线上数据分布和训练分布差距很大,校准集应该偏向线上分布。
2.3 calibrator 的选择:min-max、百分位还是熵校准
TensorRT 提供了几种校准算法,常见的有:
- MinMax:直接统计张量的全局最小/最大值,实现简单,但对离群点极其敏感。某个极端值一出现,整个动态范围被拉宽,量化精度直接受损。
- Percentile(百分位):把分布头部和尾部的极端值截掉,一般取 99.9% 或 99.99% 的分位点。
- Entropy(熵校准):通过 KL 散度来找一个阈值,使量化前后的概率分布差异最小。这是 TensorRT 默认推荐的方式。
实际用下来,我做检测、分割这类任务时,首选是熵校准;如果熵校准掉点,再试百分位;min-max 基本不碰,除非模型非常简单(比如纯卷积分类小网络)。
补充一个细节:校准算法可以在 builder 配置阶段指定。下面这段代码是跑 PTQ 的核心骨架:
import tensorrt as trt import pycuda.autoinit # 注意:实际项目中应显式管理 CUDA 上下文 import pycuda.driver as cuda class EntropyCalibrator(trt.IInt8EntropyCalibrator2): def __init__(self, calib_loader, cache_file): trt.IInt8EntropyCalibrator2.__init__(self) self.calib_loader = calib_loader self.cache_file = cache_file self.buffer_size = 0 self.device_input = None def get_batch_size(self): return self.calib_loader.batch_size def get_batch(self, names): try: batch, _ = next(self.calib_loader) # 拷贝到 GPU if self.device_input is None: self.buffer_size = batch.nbytes self.device_input = cuda.mem_alloc(self.buffer_size) cuda.memcpy_htod(self.device_input, batch) return [int(self.device_input)] except StopIteration: return None def read_calibration_cache(self): import os if os.path.exists(self.cache_file): return open(self.cache_file, "rb").read() return None def write_calibration_cache(self, cache): with open(self.cache_file, "wb") as f: f.write(cache) def build_engine(onnx_path, calib_loader, cache_file): logger = trt.Logger(trt.Logger.WARNING) builder = trt.Builder(logger) network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)) parser = trt.OnnxParser(network, logger) with open(onnx_path, "rb") as f: assert parser.parse(f.read()) config = builder.create_builder_config() config.set_flag(trt.BuilderFlag.INT8) calib = EntropyCalibrator(calib_loader, cache_file) config.int8_calibrator = calib # 设置工作空间大小,单位是字节;用 2^30 = 1GB config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30) plan = builder.build_serialized_network(network, config) return plan几点提醒:
IInt8EntropyCalibrator2是当前推荐使用的接口,老的IInt8EntropyCalibrator已经不建议用了。- 校准缓存文件(cache file)非常有用。第一次校准可能需要几分钟,但缓存好后,后续构建 engine 会直接读缓存,几秒钟就完成。生产环境强烈建议开启。
- 注意校准加载器要用
DataLoader之类的方式按 batch 取数据,不要一次性把所有图都拉进内存。
2.4 从 ONNX 到 engine:Intermediate Layer 的精度监控
构建 INT8 engine 时,很多人忽略了一个问题:到底哪些层被量化了,哪些层还是 FP32?默认情况下,TensorRT 会对绝大部分层做 INT8 处理,但有些层(比如某些敏感算子)保持高精度反而更好。
这里介绍一个调试神器:network.set_output或者直接用IEngineInspector去检查每一层的精度。实际项目里,我会先把中间层输出 dump 出来,和 FP32 engine 对比,快速定位哪些层量化后误差被放大了。
代码层面可以先构建一个 debug 版本的 engine,在构建时指定保留中间层输出:
# 在 network 构建阶段,指定某个张量作为输出来观察 network.mark_output(tensor, dtype=trt.float32)当然生产环境不能保留这些额外输出,会拖慢速度。这只是排查问题时用的方法。
3. QAT 实战:在训练阶段就把量化误差“驯服”
PTQ 最大的问题是:校准集再怎么贴近真实分布,也只是“事后补救”。如果模型本身对数值扰动很敏感,PTQ 怎么调都调不回去。这时候就轮到 QAT 出场了。
3.1 QAT 的核心思路:fake quant 模拟量化误差
QAT 的做法,是在训练的前向过程中,把量化的误差“仿真”出来。具体说,就是在权重和激活之后插入一个 fake quant 算子,先量化再反量化。这样前向传播的数值和真实 INT8 推理时的数值是一致的,只不过权重还是浮点形式在更新,误差可以通过反向传播逐步减小。
可以这么理解:PTQ 是“先考完试再估分”,QAT 是“一边学习一边模拟考试环境”。模型在训练中就已经适应了量化噪声,推理的时候自然稳定得多。
PyTorch 里要用 QAT,最省事的方式是走torch.ao.quantization的流程,准备工作分这样几步:
- 在模型定义里插入
QuantStub和DeQuantStub。 - 用
fuse_model把 Conv+BN+ReLU 这类结构融合。 - 配置
QConfig,指定激活和权重的 fake quant 实现。 - 用
QAT模式训练若干个 epoch。
import torch import torch.nn as nn from torch.ao.quantization import QuantStub, DeQuantStub, fuse_modules class QATNet(nn.Module): def __init__(self, backbone): super().__init__() self.backbone = backbone self.quant = QuantStub() self.dequant = DeQuantStub() def forward(self, x): x = self.quant(x) x = self.backbone(x) x = self.dequant(x) return x # 融合 Conv+BN+ReLU model = fuse_modules(model, [["backbone.conv1", "backbone.bn1", "backbone.relu1"]]) # 配置 QConfig,使用常见的 fake quant 实现 model.qconfig = torch.ao.quantization.get_default_qat_qconfig("fbgemm") model = torch.ao.quantization.prepare_qat(model, inplace=True)训练过程中,QAT 模型的前向是带量化噪声的,但权重更新还是浮点。等训练完,再用 TensorRT 转换时,这些 fake quant 算子会被自动识别并转换成真正的 INT8 计算,不需要额外校准也能达到较高质量。
3.2 哪些层需要 QAT:敏感层的识别
不是所有层都得 QAT。有些层对量化极其敏感,有些层则无所谓。盲目 QAT 整个网络,时间和算力成本都上去了,效果提升却有限。
我在实践中总结了一套敏感层判断方法:
- 输入的第一层卷积通常很敏感。因为输入图是自然图像,动态范围大,量化误差容易被放大。
- 注意力机制相关的层,尤其是自注意力里的 QKV 投影层,对数值精度相当敏感。
- 检测头、分割头的最后一层卷积,直接决定输出结果,掉一点都会影响指标。
- BN 层融合进 Conv 之后,对量化的鲁棒性反而提升,所以尽量先融合再量化。
识别敏感层有一个很实用的实验方法:把某一层固定为 FP32(不量化),其他层全部 INT8,看精度指标变化。逐层试一遍,就能画出每层的“敏感度排行”。这个排行榜直接决定 QAT 重点照顾哪些层。
3.3 QAT 训练配置细节:学习率、动量和蒸馏辅助
QAT 不是简单地把原模型换个模式接着训。训练策略上有些讲究:
- 学习率要调小。QAT 最好是微调,而不是从头训。常用的做法是原训练最后阶段学习率的十分之一到五分之一。
- 动量建议调高一点,比如从 0.9 调到 0.95 左右,训练更稳定。
- 可以结合蒸馏。用 FP32 的教师模型约束 QAT 学生模型的输出,收敛更快,精度上限更高。
损失函数方面,分类任务就交叉熵,检测任务照样用原来的 loss,只是额外加上蒸馏 loss(可选)。前几个 epoch 可以先冻结 BN 的统计量,只让卷积核适应量化误差,等到后面再放开 BN 一起微调。
这里有一个关键点:QAT 训练完之后,BN 的统计量(running_mean 和 running_var)一定要在训练集上重新跑一遍 forward 来刷新。如果不做这一步,转换到 TensorRT 后 BN 融合的数值对不齐,精度会莫名其妙地掉。
3.4 把 QAT 模型导出为 TensorRT engine
QAT 模型的导出路径和 PTQ 不太一样。PTQ 是导出 FP32 ONNX 再交给 TensorRT 校准,QAT 则是把带 fake quant 算子的 ONNX 直接交给 TensorRT 解析。
PyTorch 里导出 QAT 模型的 ONNX,需要先把模型convert到量化态:
model.eval() # 将 QAT 模型转为推理态量化模型 model = torch.ao.quantization.convert(model, inplace=True) # 然后导出 ONNX # 注意:这里的输出张量带上量化-反量化节点,TensorRT 能识别导出后,TensorRT 构建 INT8 engine 时,依然可以提供一个空的 calibrator 或者直接跳过校准步骤,因为 fake quant 的 scale 已经刻在模型里了。实际项目中我会依然跑一遍校准,用校准缓存来辅助,让 TensorRT 更稳定地构建。
不过要提醒的是:PyTorch 版本和 TensorRT 之间存在算子兼容问题。如果导出后解析报错,先查一下 fake quant 算子是否被正确映射(例如QuantizeLinear/DequantizeLinear的 onnx 表示)。必要时可以手动操作 ONNX 图,把 scale 和 zero_point 作为常量节点写进去。
3.5 什么时候必须上 QAT:两个真实场景
我判断是否上 QAT 的标准很直白,两个场景同时存在时基本就要上 QAT:
一是 PTQ 掉点超过可接受阈值。比如分类任务 Top-1 掉超过 0.5%,检测任务 mAP 掉超过 1%,分割任务 mIoU 掉超过 1%。
二是模型结构里有较多对噪声敏感的层。比如有很多 attention 的结构、深度可分离卷积堆叠的结构。
反过来说,如果 PTQ 已经控制在可接受范围内,就不必折腾 QAT。毕竟 QAT 要多一轮训练,工程成本和计算成本都是真金白银。
4. 量化效果评估:性能与精度的平衡
不管 PTQ 还是 QAT,最后都要回答一个问题:效果到底怎么样,值不值得上 INT8。
4.1 性能测试怎么测才靠谱
很多人拿 TensorRT 自带的 benchmark 工具测完就完事了,其实不太够。有几点建议:
- 不要只测单次延迟,要测稳定状态下的延迟分布。GPU 频率、显存状态都会影响单次结果,最好 warmup 之后连续测几百次,取 P50、P90 甚至 P99。
- 不要忽略吞吐量。延迟重要,但批处理场景更看重吞吐(Throughput)。TensorRT 支持动态 batch,实际部署时尽量利用吞吐优势。
- 输入尺寸要贴合实际。你是 512x512 的输入,就别拿 640x640 来测。
我常用的做法是写一个简单的 Python 脚本,用 pycuda 或者 trt 的 Python binding 循环跑 engine,统计耗时分布。注意要用 CUDA 事件来计时,不要用 Python 的 time 模块卡在 CPU 和 GPU 同步上。
4.2 精度评估:不止是整体指标
精度评估不能只看最终指标(比如 mAP、Top-1)。我习惯再加两项检查:
- 逐层输出对比:跑一批固定输入,对比 FP32 engine 和 INT8 engine 的中间层输出差异。如果某层的相对误差超过 1%,就重点看这一层。
- 失败样本分析:挑出 INT8 掉点的样本,看是不是集中在某些特定类别或者某些特定的输入形态。有时候不是量化的问题,是校准集本身对这类样本覆盖不足。
这两项检查做下来,基本能把“量化掉点”的锅准确甩给某个环节:是校准集的问题,还是某一层的问题,还是某些算子支持不完善的问题。
4.3 实测数据参考:PTQ 与 QAT 的差距
用我最近跑的一个检测模型举例——某跨平台检测系统(这里用代称,训练框架是 PyTorch,检测头是自定义结构):
| 方案 | mAP(相对 FP32 变化) | 延迟(毫秒) | Engine 大小 |
|---|---|---|---|
| FP32 | 100%(基线) | 24.6 | 254 MB |
| FP16 | 99.8% | 14.2 | 127 MB |
| INT8-PTQ | 96.7% | 9.8 | 64 MB |
| INT8-QAT | 99.2% | 9.9 | 64 MB |
可以看到,PTQ 掉点约 3.3 个百分点,QAT 只掉了 0.8 个百分点,但两者延迟几乎一样。这就是 QAT 的价值:在相同速度下,精度明显高出 PTQ 一截。
需要说明,不同模型效果差异很大。有的模型 PTQ 就能保持 99% 以上精度,有的模型 QAT 也只能勉强救回 2 个点。所以任何方案都别想当然,必须实测。
5. 常见问题与排查技巧实录
这部分是踩坑合集,按问题类型整理成速查表,方便直接查阅。
5.1 构建 engine 阶段报错
错误类型:Assertion failed: Calibration failed to run properly
这个常见于校准数据太少或预处理出错导致校准过程中出现 NaN。检查流程:
- 确认校准集数量,最少也得 200 张以上,太少容易触发异常。
- 确认输入数据没有 NaN。图像读取失败、除以零等操作都可能引入 NaN。
- 确认预处理归一化参数正确,特别是除以标准差时别出现 0 值。
错误类型:Plugin not found或算子不支持
模型里含自定义算子,但 TensorRT 不支持。解决方案:
- 把自定义算子拆成 TensorRT 支持的原子算子组合,直接用 ONNX 层面的图改写实现。
- 或者在 TensorRT 里用 plugin 接口自己实现该算子。
5.2 精度掉点严重时,如何优雅定位
精度掉得离谱,不要一上来就怀疑量化方法。按这个顺序排查:
- 先排除预处理差异。确认校准数据和推理数据的预处理一致,包括尺寸、归一化、通道顺序。
- 再排查 ONNX 导出问题。用 FP32 engine 跑一遍,看精度是否和原框架一致。FP32 都对不上,那和量化没关系,先修 ONNX。
- 看深层输出差异。对比 FP32 和 INT8 的深层特征图,找到误差放大器所在。
- 针对敏感层做回退。把某个敏感层的精度从 INT8 改成 FP16 或者 FP32,再看指标回来多少。TensorRT 里可以通过设置
config.set_precision或者设置层精度来实现。
我碰到过一种情况:模型里有个很深的残差结构,对量化噪声有累积放大的效果,单独看每一层误差都不大,但累计到最后输出就崩了。解决方案是把残差连接的相加层保持高精度,误差累积立刻被切断。
5.3 Engine 构建成功但运行结果全错
这类问题十有八九是输入张量的内存布局和模型期望不一致。CHW 和 HWC 的差别、归一化后数据的 dtype 是 float32 还是 float16,都会导致输出结果完全不对。
排查办法也很直接:先用相同的输入分别跑 FP32 engine 和 INT8 engine,逐元素对比输出。如果 FP32 没问题、INT8 有问题,那就是量化环节。如果两个都有问题,那就是输入输出处理的 bug。
5.4 显存占用过大或者内存泄漏
TensorRT 的 engine 在构建阶段会分析计算图,分配 workspace。如果显存不够,可以降低set_memory_pool_limit的值。但注意,workspace 太小可能导致某些融合策略失效,性能反而下降。这个值需要多试几档,找到性能和显存的平衡点。
内存泄漏方面,常见原因是 Python binding 没有正确释放 engine 和 context。用with语句管理资源,或者显式调用del。
5.5 常用小技巧汇总
最后整理几条平时用得上的经验:
- 校准缓存文件务必保留。同一个模型重复构建 engine 时能大幅节省时间,而且缓存文件很小。
- FP16 是 INT8 的“热身”步骤。先把 FP16 跑通,确认模型转换链路没问题,再上 INT8 排错更容易。
- 动态 shape 和 INT8 组合时要小心。不同尺寸对应不同校准统计策略,建议在构建 engine 时先固定一组最常见尺寸,优化到极致后再考虑动态。
- TRT 版本之间差异很大。同一个 ONNX,8.5 时代构建的 engine 和 10.x 时代构建的 engine 在速度和精度上都有差异。建议新项目直接上较新版本,老项目不要轻易升级,至少做一次全量回归。
写在最后的一些体会
这套 PTQ 到 QAT 的流程,我前后在不同项目里反复用过很多次。最大的感受是:量化优化的核心不是调参数,而是搞清楚你的模型“怕什么”。
有些模型量化掉点,是因为对数值误差敏感;有些是因为校准集没有覆盖真实场景;有些是某一个层的结构设计天然不利于量化。问题根源不同,解法完全不一样。PTQ 解决不了的问题,QAT 不一定能凭空解决;QAT 都救不回来的模型,往往要从结构设计上做调整,比如把某些动态范围过大的层拆分,或者改损失函数。
另外想补充一个观点:量化不是部署的终点。INT8 跑通之后,后续还有算子融合、内存复用、多流并发这一堆优化手段。但量化确实是性价比最高的一步——花一天时间,换来成倍的推理速度提升。希望这篇内容能帮你在做这一大步的时候,少踩几个我已经替你踩过的坑。