用 128 张 COCO 图片,实现「数据准备 → ONNX 导出 → 静态 INT8 量化 → mAP / 延迟 / 一致性评估 → 报告」的完整流程。
先看结果
| 指标 | FP32 ONNX | INT8 QDQ | 变化 |
|---|---|---|---|
| 模型体积 | 12.82 MB | 3.68 MB | 3.49 \\times 更小 |
| mAP@50 | 0.6184 | 0.5940 | -0.0244(-3.9%) |
| mAP@50-95 | 0.4713 | 0.4161 | -0.0552(-11.7%) |
| 精确率 P | 0.6398 | 0.6415 | +0.0017 |
| 召回率 R | 0.5486 | 0.5207 | -0.0279 |
| CPU 推理 p50 | 20.60 ms | 10.99 ms | 1.87 \\times 更快 |
| CPU 吞吐(mean) | 47.72 FPS | 87.61 FPS | +84% |
| 检测框保留率 | --- | 93.1% | 274 中保留 255 |
| 匹配框平均 IoU | --- | 0.871 | 位置高度一致 |
验证集:COCO128 的 64 张图、429 个 GT 实例、65 个类别。INT8 模型 3.68 MB,CPU 单图 11 ms,mAP@50 只掉 3.9%,mAP@50-95 掉 11.7%。
量化方案与环境准备
方案选择
PTQ 还是 QAT?量化路线分为:
- PTQ(Post-Training Quantization,训练后量化):拿训练好的 FP32 模型,用一小批校准数据统计激活范围,直接转 INT8。不用重新训练,几秒钟到几分钟搞定,适合快速上线。
- QAT(Quantization-Aware Training,量化感知训练):训练时模拟量化误差,让模型学会适应 INT8。精度通常更好,但要改训练流程、重新训练,成本高。
此次目标是用最小成本验证「YOLOv8n 能不能压到 INT8」,所以选 PTQ。
INT8 量化又分动态和静态:
- 动态量化:运行时根据每个 batch 的激活值动态算 scale,适合 LSTM/Transformer 这类激活分布变化大的模型;卷积网络用它收益有限,很多后端也不支持。
- 静态量化:先用校准集跑一遍,统计每层激活的 \\min/\\max ,推理时用固定 scale。卷积层是主要计算量,静态 INT8 在 CPU/边缘芯片上收益最大。
格式上选 QDQ(QuantizeLinear / DequantizeLinear):
\\text{FP32 weight} \\xrightarrow{\\text{QuantizeLinear}} \\text{INT8 Conv} \\xrightarrow{\\text{DequantizeLinear}} \\text{FP32}
Q/DQ 节点显式写在 ONNX 图里,ONNX Runtime、TensorRT、OpenVINO 都能识别;出问题时也能直接看哪一层被量化了。
为什么先转 ONNX,而不是直接量化 .pt?
PyTorch 自带的 torch.ao.quantization 对检测模型并不友好:YOLOv8 的检测头里有 Concat、Resize、Sigmoid、Split 等结构,静态量化需要手工处理很多边界;而且量化后的 .pt 最终往往还是要导出 ONNX 才能部署。
ONNX Runtime 的量化工具链成熟、API 简单,产出的 ONNX 可以直接给 ORT / TensorRT / OpenVINO 用。所以流程定为:
\\text{yolov8n.pt} \\xrightarrow{\\text{export}} \\text{yolov8n.onnx (FP32)} \\xrightarrow{\\text{quantize_static}} \\text{yolov8n_int8.onnx}
环境准备
建虚拟环境
bash
sudo apt update
sudo apt install -y python3.11-venv
python3 -m venv ~/.venvs/py-ai
source ~/.venvs/py-ai/bin/activate
python -m pip install -U pip
安装依赖
bash
pip install ultralytics==8.4.183 \
onnx==1.23.2 \
onnxruntime==1.31.0 \
opencv-python \
matplotlib \
pyyaml
说明:
ultralytics会自动装 PyTorch、torchvision、numpy 等;导出和验证都能用 CPU,GPU 只在跑.pt基线时更快。onnxruntime装 CPU 版即可。要测 GPU INT8,需要另外装onnxruntime-gpu并确认 CUDA/TensorRT EP。- 这个环境大约 6.5 GB,主要体积在 PyTorch。
验证环境
python
import torch, ultralytics, onnx, onnxruntime as ort
print("torch:", torch.__version__, "cuda:", torch.cuda.is_available())
print("ultralytics:", ultralytics.__version__)
print("onnx:", onnx.__version__)
print("onnxruntime:", ort.__version__, ort.get_available_providers())
数据准备与 ONNX 模型导出
数据集
COCO128 是 Ultralytics 提供的一个 COCO 2017 小子集:
- 128 张图,百张级别,只有 7 MB;
- 自带 YOLO 格式标注,80 个 COCO 类别,和 YOLOv8n 的训练类别完全对齐;
- 足够做一次「标定 + 验证」的完整演示,但不足以得出统计上很稳的结论。
如果要做真实业务评估,需换成自己的数据,或从 COCO val2017 里抽 300--500 张。
数据源:https://github.com/ultralytics/assets/releases/download/v0.0.0/coco128.zip
curl -L --retry 5 --retry-all-errors下载;zipfile.is_zipfile()校验,不合法就重试。
python
for attempt in range(3):
subprocess.run([
"curl", "-L", "--fail", "--retry", "5", "--retry-all-errors",
"--connect-timeout", "20", "-o", str(tmp), URL,
])
if zipfile.is_zipfile(tmp):
break
else:
raise RuntimeError("download failed")
解压后整理到 pictures/:
latex
pictures/
├── coco128.zip # 原始压缩包(7.0 MB)
└── coco128/
├── images/train2017/ # 128 张 JPG
├── labels/train2017/ # 128 个 YOLO txt
├── splits/calib/{images,labels} # 64 张标定集(符号链接)
├── splits/val/{images,labels} # 64 张验证集(符号链接)
├── coco128_split.yaml # 本次使用的数据配置
├── coco128.yaml # 全量 128 张配置
└── README.md
images/ 和 labels/ 保持 COCO128 原始结构;splits/ 用符号链接指向具体文件,不复制数据。
量化最忌讳标定集和验证集重叠:模型在标定图上看过激活分布,再拿同一批图评估,mAP 会偏乐观。所以必须切分。次按 64 / 64 切,随机种子固定为 42:
- 统计每个类别的出现次数;
- 把图片按「图中最稀有类别的频次」排序;
- 依次分配,优先给当前该类实例更少的一侧;
- 最后再平衡到 64 / 64。
python
rng = random.Random(42)
rng.shuffle(items)
items.sort(key=lambda t: min(class_freq[c] for c in t[2])) # 稀有类优先
calib, val = [], []
seen = {0: {}, 1: {}}
for img, lab, classes in items:
rare = min(classes, key=lambda c: class_freq[c]) if classes else None
if rare is None:
target = 0 if len(calib) <= len(val) else 1
else:
c0, c1 = seen[0].get(rare, 0), seen[1].get(rare, 0)
target = 0 if c0 <= c1 else 1
(calib if target == 0 else val).append((img, lab, classes))
for c in classes:
seen[target][c] = seen[target].get(c, 0) + 1
while len(calib) > 64:
val.append(calib.pop())
while len(calib) < 64:
calib.append(val.pop())
最终:
latex
calib = 64 张,覆盖 68 类
val = 64 张,覆盖 65 类,429 个 GT 实例
两类共享 62 类
数据配置 yaml
Ultralytics 的 data.yaml 至少要有 path、train、val、names:
yaml
path: /home/xugd/llm/cv-lab/pictures/coco128
train: splits/calib/images
val: splits/val/images
names:
0: person
1: bicycle
2: car
# ... 共 80 类
79: toothbrush
几个容易踩的点:
path建议写绝对路径,避免从不同目录运行时找不到数据;train/val指向图片目录,Ultralytics 会自动把/images/替换成/labels/找标签;names的顺序必须和模型训练时一致。顺序错了不会报错,但 mAP 会莫名其妙地低;
导出 ONNX
先把 FP32 基线做对,导出代码
python
from ultralytics import YOLO
model = YOLO("models/yolov8n.pt")
exported = model.export(
format="onnx",
imgsz=640,
opset=17,
simplify=False, # 不依赖 onnxslim/onnxsim
dynamic=False, # 静态 1×3×640×640
device="cpu", # 导出是图追踪,CPU 稳定
)
参数说明:
imgsz=640:和训练/验证一致。改尺寸会改变 anchor 数量和精度,量化前不要引入额外变量。opset=17:覆盖面广,ONNX Runtime 1.31 支持良好;opset 18/19 也可以,但没必要追新。dynamic=False:静态 shape 对量化最友好。动态轴会让激活范围随输入变化,校准和部署都更麻烦。simplify=False:少一个onnxslim依赖。想要更干净的图,可以pip install onnxslim后改True,但记得重新验证。device="cpu":导出只是图追踪,CPU 足够,也避免 GPU 环境差异。
导出后会得到 models/onnx/yolov8n.onnx,12.82 MB。
输出张量长什么样
latex
input: images float32 [1, 3, 640, 640]
output: output0 float32 [1, 84, 8400]
84 = 4 + 80:前 4 个是框的cx, cy, w, h,后 80 个是类别分数;8400 = 80×80 + 40×40 + 20×20:三个尺度(下采样)检测之和;
| 检测头 | stride | 特征图大小 | 网格位置数 | 主要检测 |
|---|---|---|---|---|
| P3 / 浅层 | 8 | 640/8 = 80 → 80×80 | 80×80 = 6400 | 小目标 |
| P4 / 中层 | 16 | 640/16 = 40 → 40×40 | 40×40 = 1600 | 中目标 |
| P5 / 深层 | 32 | 640/32 = 20 → 20×20 | 20×20 = 400 | 大目标 |
- 输出已经是 sigmoid 后的类别概率,不需要再套一层激活。
导出后立刻验证:不要等到量化完才发现导出错了
python
import onnx
onnx.checker.check_model("models/onnx/yolov8n.onnx")
import onnxruntime as ort, numpy as np
sess = ort.InferenceSession("models/onnx/yolov8n.onnx",
providers=["CPUExecutionProvider"])
x = np.random.rand(1, 3, 640, 640).astype(np.float32)
y = sess.run(None, {sess.get_inputs()[0].name: x})[0]
print(y.shape, y.min(), y.max())
再用 Ultralytics 在同一验证集上跑一遍 FP32 ONNX,和 .pt 对比:
| 模型 | mAP@50 | mAP@50-95 |
|---|---|---|
| PyTorch FP32 (.pt) | 0.6182 | 0.4722 |
| ONNX FP32 | 0.6184 | 0.4713 |
差距只有 0.0009 mAP@50-95,说明导出没有引入可感知的损失。
静态 INT8 量化实战与调优
静态 INT8 量化
ONNX Runtime 的 quantize_static 需要一个 CalibrationDataReader,每次返回一个 {input_name: ndarray}。预处理必须和推理时完全一致:
python
class LetterboxCalibReader(CalibrationDataReader):
def __init__(self, image_paths, input_name, imgsz=640):
from ultralytics.data.augment import LetterBox
self.paths = list(image_paths)
self.input_name = input_name
self.letterbox = LetterBox((imgsz, imgsz), auto=False, stride=32)
self.index = 0
def preprocess(self, path):
im0 = cv2.imread(str(path))
im = self.letterbox(image=im0) # 保持比例,灰边填充
im = im[:, :, ::-1].transpose(2, 0, 1) # BGR→RGB, HWC→CHW
im = np.ascontiguousarray(im, dtype=np.float32) / 255.0
return im[None, ...] # 1×3×640×640
def get_next(self):
if self.index >= len(self.paths):
return None
path = self.paths[self.index]
self.index += 1
return {self.input_name: self.preprocess(path)}
关注点:
- LetterBox 而不是直接 resize:直接拉伸会改变长宽比,标定分布和验证分布不一致;
- BGR→RGB、/255:和 Ultralytics 推理管线一致,少一步都会让激活范围偏掉;
- batch=1:静态量化对 batch 不敏感,batch=1 最省内存,也最贴近部署。
调用 quantize_static
python
from onnxruntime.quantization import (
quantize_static, QuantFormat, QuantType, CalibrationMethod,
)
quantize_static(
model_input="models/onnx/yolov8n.onnx",
model_output="models/onnx/yolov8n_int8.onnx",
calibration_data_reader=reader,
quant_format=QuantFormat.QDQ,
per_channel=True,
reduce_range=False,
activation_type=QuantType.QUInt8,
weight_type=QuantType.QInt8,
calibrate_method=CalibrationMethod.MinMax,
extra_options={
"ActivationSymmetric": False,
"WeightSymmetric": True,
"CalibMovingAverage": True,
},
)
| 参数 | 作用 | 说明 |
|---|---|---|
quant_format=QDQ |
显式插入 Quantize/Dequantize | 后端兼容性好,方便调试 |
per_channel=True |
卷积权重逐通道算 scale | 比 per-tensor 精度更好,几乎无额外成本 |
activation_type=QUInt8 |
激活用无符号 8 bit | 激活多为非负,uint8 利用率高 |
weight_type=QInt8 |
权重用有符号 8 bit | 权重有正有负,int8 是标准做法 |
calibrate_method=MinMax |
用校准集 min/max 定 scale | 简单、稳定;数据干净时够用 |
CalibMovingAverage=True |
校准统计做滑动平均 | 减少极端 batch 的影响 |
复制 metadata
QDQ 重写会丢掉 Ultralytics 写在 ONNX 里的自定义 metadata。没有这些字段,YOLO("xxx.onnx") 可能读不到类别名、输入尺寸或 stride,val() 直接报错或结果异常。
python
import onnx
src = onnx.load("models/onnx/yolov8n.onnx", load_external_data=False)
dst = onnx.load("models/onnx/yolov8n_int8.onnx", load_external_data=False)
have = {p.key for p in dst.metadata_props}
for prop in src.metadata_props:
if prop.key not in have:
dst.metadata_props.add().CopyFrom(prop)
onnx.save(dst, "models/onnx/yolov8n_int8.onnx")
onnx.checker.check_model("models/onnx/yolov8n_int8.onnx")
复制完 metadata,再用 ORT 跑一张图确认输入输出:
latex
FP32: input=(1, 3, 640, 640) output=(1, 84, 8400)
INT8: input=(1, 3, 640, 640) output=(1, 84, 8400)
踩坑实录
mAP 为什么会变成 0。
第一次量化完,直接跑 Ultralytics 验证:
latex
all 64 429 0 0 0 0
mAP50-95=0.0000 mAP50=0.0000 P=0.0000 R=0.0000
模型能加载,能推理,输出 shape 也对,但一个检测框都没有。
定位:直接看原始输出。同一张图,分别喂给 FP32 和 INT8:
python
for f in ["yolov8n.onnx", "yolov8n_int8.onnx"]:
sess = ort.InferenceSession(f, providers=["CPUExecutionProvider"])
y = sess.run(None, {sess.get_inputs()[0].name: x})[0]
cls = y[0, 4:, :] # 80 类分数
box = y[0, :4, :] # 框坐标
print(f, "box max", box.max(), "cls max", cls.max(),
"n>0.25", (cls > 0.25).sum())
结果非常明确:
latex
FP32: box max 636.59 cls max 0.901171 n>0.25 37
INT8: box max 637.89 cls max 0.000000 n>0.25 0
框坐标还在,类别分数全变 0。这不是模型坏了,是输出被量化"压死"了。
原因:一个 Concat 混了两种量纲。看 ONNX 图的最后一个节点:
latex
OUTPUT NODE: Concat /model.22/Concat_3
inputs = ['/model.22/Mul_2_output_0', '/model.22/Sigmoid_output_0']
outputs = ['output0']
Mul_2是框坐标,范围约 0--640;Sigmoid是类别概率,范围 0--1;Concat把它们拼成1×84×8400。
默认量化会把这个 Concat 的输出也量化,并且用 per-tensor scale。校准集上框坐标最大约 637,于是:
\\text{scale} \\approx 637 / 255 \\approx 2.5
也就是说,量化后的一个整数步长代表约 2.5 个浮点值。类别概率全在 0--1 之间,连半个步长(约 1.25)都不到,四舍五入后全部变成 0。
框坐标因为本身数值大,量化误差 ~2.5 px 还能接受;类别分数却需要 0.004 级的分辨率。两种量纲共用一个 scale,必然有一方被牺牲。
修复:实际脚本不写死节点名,而是自动找出「产出 graph output 的节点」,这样换模型/换版本也不会失效:
python
import onnx
m = onnx.load("models/onnx/yolov8n.onnx", load_external_data=False)
graph_outputs = {o.name for o in m.graph.output}
out_nodes = [n.name for n in m.graph.node if set(n.output) & graph_outputs]
print(out_nodes) # ['/model.22/Concat_3']
然后把 out_nodes 传给量化器,并加上禁止量化 Concat 输出的保险:
python
nodes_to_exclude=out_nodes,
extra_options={
"OpTypesToExcludeOutputQuantization": ["Concat"],
...
}
nodes_to_exclude=out_nodes让输出节点保持浮点;OpTypesToExcludeOutputQuantization是更通用的保险:任何 Concat 的输出都不量化。
这样两条输入分支各自量化:
- 框分支:scale 按 0--640 算,精度 ~2.5 px;
- 类别分支:scale 按 0--1 算,精度 ~0.004;
然后再由浮点 Concat 合并,输出保持 float32。
所有「框 + 类别」共用输出张量的检测模型(YOLO 系列、部分 SSD)都可能遇到。输出张量里如果有量纲差异巨大的字段,就别整体量化。
还能怎么优化
如果 0.055 的 mAP@50-95 损失不可接受,可以按成本从低到高尝试:
- 扩充校准集到 300--500 张,覆盖更多场景、光照、尺度。校准集越接近真实分布,scale 越准。
- 换校准方法:
MinMax容易被离群值拖累,可以试Percentile(如 99.99%)或Entropy。通常能挽回 1--3 个 mAP 点。 - 混合精度:对掉点严重的类别相关层保留 FP16/FP32,只量化主干。ORT 的
nodes_to_exclude可以按名字排除,但需要逐层实验。 - QAT:量化感知训练,精度最好,成本也最高。YOLOv8 社区有一些 QAT 实践,但需要改训练代码。
- 换后端:NVIDIA GPU 上用 TensorRT INT8,通常比 ORT CPU 更快;x86 上确认 CPU 是否支持 VNNI/DP4A,支持的话 INT8 加速比会更高。
- 重标阈值:INT8 置信度整体低约 0.10,部署前在目标数据上重新扫一遍
conf阈值,别直接沿用 FP32 的 0.25。 - 按类别设阈值:对掉点严重的类别降低阈值,或对这些类别保留 FP32 分支。
附录
Q1:量化后 mAP=0,但模型能跑,是什么原因?
先看输出张量的类别分数是不是全 0。大概率是输出 Concat 被整体量化了:框坐标 0--640 和类别分数 0--1 共用一个 scale,类别分数全部被舍入到 0。解决:nodes_to_exclude 排除输出节点,并设置 OpTypesToExcludeOutputQuantization=["Concat"]。
Q2:为什么不用动态量化?
动态量化在运行时算激活 scale,适合 RNN/Transformer;卷积网络的计算量集中在 Conv,静态量化才能用上 INT8 的整数卷积加速。动态量化在 CPU 上对 YOLO 的收益通常很小。
Q3:64 张校准图够吗?
对演示够了,对生产不够。校准集要覆盖真实分布:类别、尺度、光照、遮挡。建议 300--500 张起步;如果数据分布很宽,甚至要上千张。
Q4:INT8 一定比 FP32 快吗?
不一定。要看 CPU 是否支持 VNNI/DP4A、模型是否 memory-bound、线程数是否合理。本次 16 线程下纯推理 1.87 \\times ,但如果预处理占比高,端到端只有1.61,但如果预处理占比高,端到端只有 1.61,但如果预处理占比高,端到端只有1.61 \\times 。上目标硬件实测才算数。
Q5:输出还是 float32,算真正量化了吗?
算。INT8 量化的是内部卷积/矩阵乘的权重和激活;输出反量化回 float 是标准做法。部署时只有图内部用 INT8,接口仍然是 float,调用方无感知。
Q6:为什么 ONNX FP32 比 .pt 大?
.pt checkpoint 的权重以 FP16 保存,ONNX 导出默认 FP32,所以 ONNX 文件大约翻倍。比较压缩比时用「ONNX FP32 → ONNX INT8」,不要用 .pt 当分母。
Q7:能不能在 GPU 上跑 INT8?
可以,但需要 onnxruntime-gpu + TensorRT/CUDA EP,或直接转 TensorRT。