资讯动态

TensorRT INT8量化实战:从PTQ到QAT的完整加速指南

发布时间:2026/10/19 9:09:25 来源:尧图企业网站定制
开局先交代一个大家可能都遇到过的情况模型跑通了准确率也没得说但一上生产环境就露怯——单张推理耗时动辄几十毫秒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。这也是为什么同样是 INT8TensorRT 的效果就是比某些自己写的简易量化工具好一截——细节都在这些看不见的地方。这里要特别注意量化粒度和校准数据的选择是一对“连体婴”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 实战别只会跑 calibrationPTQ 听起来简单——跑一遍校准工具导出 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_version13, 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 到 engineIntermediate Layer 的精度监控构建 INT8 engine 时很多人忽略了一个问题到底哪些层被量化了哪些层还是 FP32默认情况下TensorRT 会对绝大部分层做 INT8 处理但有些层比如某些敏感算子保持高精度反而更好。这里介绍一个调试神器network.set_output或者直接用IEngineInspector去检查每一层的精度。实际项目里我会先把中间层输出 dump 出来和 FP32 engine 对比快速定位哪些层量化后误差被放大了。代码层面可以先构建一个 debug 版本的 engine在构建时指定保留中间层输出# 在 network 构建阶段指定某个张量作为输出来观察 network.mark_output(tensor, dtypetrt.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把 ConvBNReLU 这类结构融合。配置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 # 融合 ConvBNReLU 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, inplaceTrue)训练过程中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 engineQAT 模型的导出路径和 PTQ 不太一样。PTQ 是导出 FP32 ONNX 再交给 TensorRT 校准QAT 则是把带 fake quant 算子的 ONNX 直接交给 TensorRT 解析。PyTorch 里导出 QAT 模型的 ONNX需要先把模型convert到量化态model.eval() # 将 QAT 模型转为推理态量化模型 model torch.ao.quantization.convert(model, inplaceTrue) # 然后导出 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 大小FP32100%基线24.6254 MBFP1699.8%14.2127 MBINT8-PTQ96.7%9.864 MBINT8-QAT99.2%9.964 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 版本之间差异很大。同一个 ONNX8.5 时代构建的 engine 和 10.x 时代构建的 engine 在速度和精度上都有差异。建议新项目直接上较新版本老项目不要轻易升级至少做一次全量回归。写在最后的一些体会这套 PTQ 到 QAT 的流程我前后在不同项目里反复用过很多次。最大的感受是量化优化的核心不是调参数而是搞清楚你的模型“怕什么”。有些模型量化掉点是因为对数值误差敏感有些是因为校准集没有覆盖真实场景有些是某一个层的结构设计天然不利于量化。问题根源不同解法完全不一样。PTQ 解决不了的问题QAT 不一定能凭空解决QAT 都救不回来的模型往往要从结构设计上做调整比如把某些动态范围过大的层拆分或者改损失函数。另外想补充一个观点量化不是部署的终点。INT8 跑通之后后续还有算子融合、内存复用、多流并发这一堆优化手段。但量化确实是性价比最高的一步——花一天时间换来成倍的推理速度提升。希望这篇内容能帮你在做这一大步的时候少踩几个我已经替你踩过的坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑