☰
YOLOv11在RDK S100P上的INT8量化部署实战
2026/10/21 4:44:41 网站建设 项目流程

以前在GPU上做目标检测,模型训完随便一跑,爽是真的爽。可一旦要把同样的YOLOv11模型搬到RDK S100P这种地瓜派板子上,问题马上冒出来:浮点模型直接部署,帧率难看得没法看,内存还顶着上限。我这次把YOLOv11从PyTorch一路量化到INT8,再部署到RDK S100P上,中间踩了不少坑,也把整套流程彻底理清楚了。

这篇东西适合谁看?手里有RDK S100P,想把YOLOv11这类检测模型真正跑起来的开发者;以及对量化部署一知半解、以为"跑个转换命令就行"的人。我会把模型导出、ONNX校验、INT8量化校准、板端推理和后处理全流程都写明白,尤其是那些不写在官方文档里的坑,尽量一次说透。

1. 为什么非要在RDK S100P上做量化部署:浮点模型跑不动是常态

1.1 板卡资源决定了你不能直接扔一个FP32模型上去

先把结论放前面:在RDK S100P上,直接跑FP32的YOLOv11,不是"慢一点"的问题,而是资源完全不够用。YOLOv11n这种轻量版本,输入640x640,一次前向推理的乘加运算量也在13GFLOPs上下。GPU能轻松吃掉这个量,但边缘板子的CPU通常只有几个ARM核,靠它硬算,单帧推理时间直接奔着几百毫秒去,这还没算预处理和后处理。

RDK S100P的算力主要来自BPU这样的神经网络加速单元,它的设计目标就是定点计算,对FP32浮点运算的支持非常有限。你让BPU去跑一个浮点模型,很多算子都得回退到CPU模拟,速度优势全没了。所以在这类板子上,模型量化不是可选项,是能否上线的前置条件。

1.2 INT8量化到底是什么:权重和激活怎么"压缩"

量化这个词听起来玄,本质就是把模型的权重和中间激活从FP32的32位浮点数,映射到INT8的8位整数。以对称量化为例,公式很简单:

scale = max(|r|) / 127 q = round(r / scale)

FP32的时候权重是类似0.0372这种连续小数,INT8则把它变成一个接近的整数。这个scale就是缩放系数,推理时NPU拿整数做矩阵乘法,最后再乘回scale恢复数值范围。因为卷积和矩阵乘对scale的运算可以融合到相邻层,所以实际推理时你甚至感觉不到多余的运算开销。

问题是映射过程中必然损失精度,尤其当权重分布有极端离群值时,一个大的离群值会把整个量化范围撑大,让大多数权重分到的量化步长变粗。这就是为什么同样一个模型,有的人量化后mAP掉0.2个点,有的人能掉4个点。怎么选量化范围、用什么校准策略,后面我会细讲。

1.3 RDK S100P这套开发链路对量化的支持边界

RDK S100P的部署链路,大致是:PyTorch训练好的模型,先导出成ONNX,然后用板卡配套的模型转换工具做算子和量化处理,生成板端能加载的量化模型文件。这个工具链对YOLO系列的检测头是有专门优化的,但前提是ONNX来源要"干净"。

有个很重要的边界要提前知道:工具链对算子的支持不是100%覆盖的。一些复杂的算子,比如动态形状相关的NonMaxSuppression、某些特殊上采样方式,很可能不被NPU支持。解决办法是让这些算子在ONNX里保留为可回退的CPU节点,或者干脆把后处理放到板子的CPU侧来做。我在第三部分会详细写转换失败的排查思路。

2. 第一步不是转换,是先把YOLOv11从PyTorch里"干净地"导出

2.1 导出前先确定权重、输入尺寸和类别数

很多人一上来就执行导出命令,根本没想过导出结果要满足工具链的胃口。我建议先想清楚三个问题:用哪个权重、输入尺寸多少、类别数对不对。

权重建议用训练过程中验证集最优的best.pt,而不是最后一轮的last.pt。一些项目习惯用带EMA的权重,导出前记得把EMA权重合入。用Ultralytics训练框架的话,best.pt本身就带着EMA状态,直接导出即可。

输入尺寸方面,如果你要部署的分辨率是640x640,就别用1280训练的模型硬导。RDK S100P这类板子的NPU对输入尺寸有对齐要求,通常要求宽高是16或32的倍数。640x640是安全的,但如果你用608x608这类尺寸,建议先检查工具链配置里有没有对齐报错。

类别数量直接决定输出张量的大小。YOLOv11的检测头输出是(batch, 4 + num_classes, num_anchors)的结构。80类目标时,640x640输入对应的输出就是(1, 84, 8400)。8400是三个尺度特征图累加出来的anchor点总和。你如果训练的是自己的数据集,只有5类,那输出就是(1, 9, 8400)。这个维度后面写后处理代码时要严格对应,错一个数字整个解析就崩了。

2.2 ONNX导出的opset、simplify、动态轴设置

导出命令本身不复杂,但有几个参数值得抠一下。我的习惯是保留一份原始导出的ONNX,再导出一份simplify之后的ONNX,两份都要用:

python -c " from ultralytics import YOLO model = YOLO('yolo11n.pt') model.export(format='onnx', opset=11, imgsz=640, dynamic=False, simplify=False) "

然后另跑一次simplify=True。为什么搞两份?因为工具链转换报错的时候,你很难判断是模型本身的问题还是ONNX图冗余造成的,保留原始版本方便回溯。

opset推荐11或12。有些工具链对新版opset的支持会滞后,opset太高很可能遇到节点类型不匹配。dynamic=False是必须的,量化部署阶段不需要动态维度,动态轴会让校准过程复杂化,而且板端推理时动态shape往往不支持。

simplify=True依赖onnxsim,运行时会做常量折叠、冗余算子删除。但它也会重写部分节点名称,后面做层间精度对比时,你会看到大量"节点名变了"的现象。所以我强烈建议:先确认能正常转换,再用简化版。

2.3 用onnxruntime做导出后的数值一致性校验

导出ONNX这一步很多人跳过了校验,结果模型转换半天,板端推理全错,最后回头查才发现导出时数值就不对。其实校验很简单,用onnxruntime加载导出的ONNX,喂同一个输入,和PyTorch模型的输出做对比即可。

import numpy as np import onnxruntime as ort import torch # pytorch推理 model.eval() dummy_input = torch.randn(1, 3, 640, 640) with torch.no_grad(): torch_output = model(dummy_input).cpu().numpy() # onnxruntime推理 ort_session = ort.InferenceSession('yolo11n.onnx') ort_output = ort_session.run(None, {'images': dummy_input.numpy()})[0] print('max abs diff:', np.abs(torch_output - ort_output).max())

这里有个最常见的坑:PyTorch模型默认输入是RGB、CHW、0-1归一化后的数据;而YOLOv11在推理框架里实际用的预处理是BGR、HWC、除以255。你如果拿一张经过BGR通道处理的图直接喂给ONNX校验,对比结果差异会非常大。正确的做法是让PyTorch和ONNX都吃同一个张量,即模型内部约定的输入格式。

通常max abs diff在1e-4量级以上就得警惕,如果你看到差异达到1e-1,基本可以确定前处理或通道顺序出了问题。

3. 模型转换与INT8量化校准:这一步决定板子跑得好不好

3.1 校准数据集的准备:数量、分布和预处理

量化校准的过程,简单说就是拿一批真实数据喂给待量化的模型,统计每一层激活值的数值分布,然后据此确定量化范围。所以校准数据的质量,直接决定量化参数的质量。

数量上,300到500张图是多数项目的合理区间。太少了,统计出来分布不稳定;太多了,校准时间呈线性增长,收益却迅速饱和,没必要。

分布上,校准集必须覆盖你实际场景的变化。做行人检测就多放不同光照、不同遮挡程度的图;做工业缺陷检测就把几种缺陷类型都放足。注意不要全用简单样本,简单样本的激活分布通常很集中,量化后遇到复杂场景就原形毕露。我是从训练集里按类别和场景分层抽样,保证每类目标出现次数大体均衡。

预处理上,校准图的预处理必须和部署时的预处理完全一致。这个"完全一致"包括缩放方式、填充颜色、通道顺序、归一化参数。很多时候精度掉点,不是量化本身的问题,而是校准阶段的预处理和部署阶段的预处理不一致。后面5.2节我会用实际项目数据说明这一点。

3.2 量化配置里最关键的两个参数:量化方式和校准方法

RDK S100P的工具链一般提供一个yaml配置来控制转换过程,核心参数大概长这样:

model_type: yolov11 input_type: rgb input_shape: [1, 3, 640, 640] calibration_type: percent calibration_percent: 99.99 quant_type: per_channel preprocess: type: norm mean: [0, 0, 0] std: [255, 255, 255] keep_aspect_ratio: true

quant_type这个参数很关键。per_tensor是整层共用一个scale,简单但对小模型不友好;per_channel按输出通道分别算scale,精度保留能力更强,代价是推理时计算稍复杂。我建议能开per_channel就开,YOLOv11这种小模型对per-tensor的精度损失尤其敏感。

calibration_type常见有max、percent、entropy。max就是拿激活的绝对最大值做范围,简单但容易被离群点带偏。percent取99.99分位数,等于主动丢弃一小部分极端值来换取大多数数据的精度,这是我最常用的。entropy是让量化前后的信息熵损失最小,理论上最优,但有些工具链实现得一般,需要实测对比。

preprocess里的mean/std要和导出时匹配。YOLOv1系列在训练时普遍只做除以255归一化,不按ImageNet减均值,所以这里通常是0和255。你要是配了个mean: [0.485, 0.456, 0.406],量化校准的分布就全错了,检测头输出会离谱。

3.3 跑一次转换,看懂输出日志里的精度对比表

正常跑转换时,工具链会输出大量日志,看着很唬人,其实核心要看的就几类信息。

首先要看算子映射报告。ONNX里的算子会被映射成NPU内置算子,映射不上的会被回退到CPU。日志里通常有一行汇总,比如"CPU fallback ops: 3"。回退的算子多了,推理耗时会被拖住,而且某些算子回退到CPU反而比NPU慢两个量级。YOLO系列的NMS回退是正常的,只要Conv、Relu这些主流算子不大量回退就行。

其次看每一层量化前后的数据范围对比。工具链会打印每层激活的min/max,如果发现某个中间层激活范围异常大,比如达到几百,这一层很可能有数值稳定性问题,会导致量化误差被放大。

最后是转换完成后的精度对比报告。工具链会在校准集上跑FP32模型和INT8模型,给出每层激活误差和整体的余弦相似度。这里的数字不用追求极端,但检测模型更推荐用实际mAP来验证量化损失,而不是只看余弦相似度。因为检测头输出的坐标和置信度,对数值误差的敏感度是非线性的。

3.4 一次失败的转换:算子不支持的排查思路

我遇到过一次转换直接报错:提示某个节点类型不被支持,一看是GridSampler。这是模型里用了重采样层才会出现的算子,YOLOv11标准版本不会带,但如果你在neck部分改过结构,或者拼接了别的模块,就会引入新算子。

排查思路是先用onnx.graphsurgeon之类工具找到报错节点的名字和输入输出,定位到模型的具体位置。然后看有没有等价替换方案,比如把GridSampler换成多个标准算子的组合,或者把这一段子图抽出来单独作为CPU预处理。实际操作中,最省事的办法是修改模型结构,把有问题的模块替换成标准卷积或最近邻上采样,重新训练或微调一下,保证整体结构符合工具链的算子支持列表。

如果实在不想动模型结构,另一个思路是"切开"模型:把报错的子图砍掉,在板端推理时先用NPU跑前段,再用CPU跑那段特殊算子,最后再接回NPU后段。这样能用,但工程复杂度上了一个台阶,不建议优先采用。

4. 板端部署:把量化后的模型真正用起来

4.1 部署工程的目录结构和模型bin的加载方式

量化转换成功后,拿到的不再是onnx文件,而是板端专用的模型格式,比如.bin或类似的自定义格式。这个文件里已经包含了量化参数、算子调度序列、融合后的层结构。部署时你不需要再关心权重怎么存、量化参数怎么设置,全部打包好了。

我的工程目录一般是这样的:

project/ ├── model/ │ └── yolo11n_quant.bin ├── src/ │ ├── main.cpp │ ├── yolo_postprocess.cpp │ └── img_preprocess.cpp ├── config/ │ └── deploy_config.json └── build/

模型bin的加载,本质上是把文件读入内存,再通过推理接口创建一个模型句柄。板子上的推理库通常会提供一个类似load_model(path)的接口,返回一个模型对象。这里要注意:模型文件很大也可以加载,但板端内存是有限的,跑模型的同时还要跑系统服务,建议上线前先单独跑一个最小推理程序测一下空闲内存余量。

4.2 推理数据管线:图像前处理、张量申请、输出解析

板端推理的数据管线和GPU上差别很大。在GPU上你可以把原图直接resize再归一化,然后一次性拷贝到显存;在BPU上,输入张量对内存对齐和格式有要求。我一般先做CPU侧预处理,再把数据拷贝到NPU输入张量。

下面是一个结构化的伪代码示意,具体API名以你手上固件头文件为准:

import numpy as np # 1. 读取图像并做letterbox img = cv2.imread("test.jpg") img_letterbox = letterbox(img, (640, 640), fill=(114, 114, 114)) # 2. BGR转RGB,HWC转CHW,并除以255 input_blob = img_letterbox[:, :, ::-1].transpose(2, 0, 1).astype(np.float32) / 255.0 input_blob = np.ascontiguousarray(input_blob).reshape(1, 3, 640, 640) # 3. 申请模型输入张量并拷贝 input_tensor = net.get_input_tensor(0) input_tensor.copy_from_numpy(input_blob) # 4. 执行推理 net.forward() # 5. 获取输出张量 output_tensor = net.get_output_tensor(0) output_data = output_tensor.to_numpy() # shape: (1, 84, 8400)

这里最容易出问题的三件事:一是letterbox的填充值必须是训练时的值,YOLO系列默认是114,但你要是训练代码里改过,部署必须跟着改;二是输入格式是BGR还是RGB,一旦搞反,检测效果会突然变得极差但又不是完全失效,很有迷惑性;三是内存连续性,np.ascontiguousarray这步不能省,否则数据在内存里的排列和NPU预期不符。

4.3 后处理解算:坐标映射、置信度阈值、NMS

从NPU拿到的输出是(1, 84, 8400)的原始检测头输出,不是最终的检测框。84的含义是4个坐标分量加80个类别得分。4个分量对YOLOv11来说,是中心点坐标和宽高的相对编码值,需要先解码才能转成实际的框。

解码过程大致是:把8400个anchor点对应到三个特征图尺度,然后根据每个anchor的位置、步长和预测的偏移量,还原出绝对坐标。这一步你可以用纯numpy实现,也可以直接调用一些现成的检测库,但核心逻辑必须自己心里清楚。

坐标还原之后,乘以letterbox的缩放系数和填充偏移,把坐标映射回原图尺寸。然后做置信度阈值过滤和NMS。我用的是cv2.dnn.NMSBoxes,在板子上也能跑,几千个候选框过滤一遍的耗时非常低。

一个重要提示:如果你想让后处理更省事,可以尝试把NMS也放进模型里,但工具链对NMS的支持通常有限,而且很多时候板端NMS是CPU回退的。亲自测试后,我发现纯CPU侧做NMS的稳定性反而更高,也更容易调试。

5. 板端实测和那些我踩过的坑

5.1 耗时、内存和温度的实测记录

我实际部署YOLOv11n量化模型后,记录了一组数据,放在这里供参考。测试环境:RDK S100P,官方Debian系统,单路视频流,输入640x640,YOLOv11n INT8量化版本。

指标数值
NPU单帧推理耗时约18ms
整体端到端耗时(含预处理+后处理)约24ms
帧率约40FPS
峰值内存占用约320MB
满载运行15分钟后芯片温度约68°C

说明一下,这个数据不是官方标称值,和固件版本、系统负载、是否开启多线程都有关系。同一个模型在不同版本固件上可能差3到5毫秒,所以别拿我的数字当基准,更重要的是掌握方法,自己测自己调。

让我比较欣慰的是,同样的模型用FP32跑CPU,单帧推理就要400ms以上,量化部署的收益是数量级的提升。这也是为什么说量化部署不是可选项,而是必选项。

5.2 量化后精度掉点:最常见的根因是预处理不一致

第一次做这个项目时,量化后mAP掉了整整4个点,我当时差点以为是量化方法选错了。后来反复排查才发现,问题出在预处理上。

训练脚本里的letterbox填充值是(114, 114, 114),但我在写校准数据预处理时,习惯性地用了(0, 0, 0)。校准阶段给模型喂的图像分布和实际推理时的图像分布出现了系统性偏差,激活值的统计自然就乱了,量化参数全部偏向错误方向。

把校准预处理改成和部署完全一致的(114, 114, 114)之后,量化模型和浮点模型的mAP差距从4个点缩到了0.3个点以内。这等于告诉我们:量化的精度损失确实可以把控,但前提是每个环节的输入分布必须对齐。

5.3 检测框整体偏移:anchor与解码逻辑的错位

另一个很有迷惑性的坑是:量化模型在板子上跑,检测框位置整体偏移,但偏移方向一致,比如总是往右上角偏。第一反应以为是量化精度不够,实际上是因为输出解码时用错了坐标基准。

YOLOv11的输出在不同导出方式下,可能是不同的布局。标准Ultralytics导出的ONNX,输出是(1, 84, 8400),解码时要用anchor点坐标乘以下采样步长来还原。但如果你在导出时把检测头某些层优化了,工具链可能直接输出解码后的(x, y, w, h)格式。这种情况下你还按anchor解码逻辑再算一遍,就会得到叠加的偏移。

排查方法是打印输出张量的数值分布,看看坐标分量的范围。如果坐标值很小,集中在0到1之间,多半是相对编码,需要解码;如果坐标值已经和输入分辨率同级,大概率是解码后的坐标。发现布局不对,改后处理逻辑即可,不需要重新量化。

5.4 多路并发和动态batch怎么安排

单路跑通之后,很自然的下一步就是多路并发。RDK S100P的NPU支持多个任务并行处理,但要注意几点。

一是模型实例不要开太多。每个模型实例都会复制一份运行时状态,占内存。实测开4路视频时,我选择只加载一个模型实例,用四个线程轮流喂数据,让NPU按任务队列调度,整体吞吐比开四个实例要高,内存也省很多。

二是输入batch可以适当加大。如果你的工具链支持batch推理,把多帧图像拼成一个batch输入,能提高NPU利用率。但batch增大会成倍占用内存,需要权衡。我在4路场景下选择batch=2,两帧一提交,性价比最高。

三是动态shape尽量避开。板端推理时用固定输入尺寸,不要试图在运行时改输入分辨率。工具链对动态shape支持有限,一旦开了这个口子,性能优化空间会被堵死。

最后说一下我的整体体会。量化部署这件事,90%的问题出在"分布一致性"上:校准数据分布、图像预处理分布、输出解码逻辑,任何一个环节和训练时不一致,最终都会在精度或性能上体现出来。RDK S100P这套流程跑通后,我的固定套路是先校准集小验证,再板端小规模精度对比,最后才压测多路并发。每一步都留好日志和中间文件,出了奇怪问题能快速回溯。如果你也在做类似的事,希望这篇记录能帮你少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询