拿到地瓜派 RDK X5 的第一件事,我就想把 YOLOv11n 跑上去。照理说这是最成熟的一条链路:ultralytics 训练好的权重导出 ONNX,丢给配套工具链转成 BPU 模型,板端再用 runtime 拉起来。原以为半小时就能调通,结果我在这上面整整折腾了两天。问题不出在卷积、C3k2 或 SPPF,而出在一个几乎没人在意的 Softmax——准确说,是 YOLOv11 检测头 DFL 模块里那个对 16 个分布 bin 做归一化的 Softmax。这篇文章就把我在这块板子上把模型从 6 FPS 拉到 35 FPS 的全过程拆开讲,包括算子落图报告怎么读、DFL 怎么改、量化校准怎么配,以及中途踩过的各种坑。
1. RDK X5 的真实算力与 YOLOv11n 部署链路
1.1 BPU 擅长计算什么
RDK X5 用的地平线征程 6E,核心加速单元是 BPU(Brain Processing Unit),标称 10 TOPS 级别算力。这个数字听起来不大,但它和 GPU 的 TOPS 不是一回事。BPU 是专门为卷积神经网络设计的专用处理器,不是通用并行计算单元。它在卷积、矩阵乘、池化、concat、elementwise 这类操作上效率极高,int8 定点计算尤其快。
但 BPU 不是万能的。它对动态 shape 支持很差,对 transpose、reshape 这类数据搬移类算子支持也受限,更别说 softmax 这种需要指数运算和 floor 求和的操作。BPU 的指令集里没有直接的浮点指数运算,即使工具链支持 softmax,也往往是有限制条件的:比如只能在某一个固定轴、固定维度上做,一旦形状或数据布局超出支持范围,就干脆不给你映射到 BPU 上。
理解这一点特别重要。很多人在部署时只关心"模型能不能转成功",没人看算子落图报告。结果模型确实能在板端跑,但里面悄悄有一堆 CPU 算子,性能直接打折,还找不到原因。
1.2 YOLOv11n 结构里"不友好"的算子集中在哪
YOLOv11n 是默认检测模型里体积最小的一个,主干和颈部用的还是熟悉的 Conv、C3k2、SPPF,这些 BPU 处理起来都很轻松。真正的变数在检测头。
YOLOv11 的 Detect head 相比 v8 增加了一些通道注意力模块,这部分会引入 AdaptiveAvgPool2d 和 Sigmoid。AdaptiveAvgPool2d 还好,工具链通常可以把它转换成固定池化核的 AvgPool 塞给 BPU,Sigmoid 一般也能用查表或近似实现兜住。真正难搞的是回归分支里的 DFL(Distribution Focal Loss)模块。
DFL 的 forward 大概是这样的:
def forward(self, x): b, c, a = x.shape # (batch, 64, anchor_num) return self.conv(x.view(b, 4, self.c1, a).transpose(2, 1).softmax(1))它先把 64 通道的分布 reshape 成 4 组、每组 16 个 bin,转置后对 16 个 bin 做 softmax,最后用 1x1 卷积做一个积分加权,得到 4 个距离值。这个结构里最要命的就是那个 softmax:它在非最后一维的轴上做,输入还是一个带动态 anchor 数的 4D tensor。地平线工具链对这种 softmax 的支持非常有限,转换的时候大概率直接标成 CPU 算子。
1.3 我以为的"标准部署"其实暗藏性能陷阱
我最初的操作路径很简单:用 ultralytics 导出静态 ONNX,固定 1x3x640x640 输入,然后丢给官方转换工具。转换没有报错,甚至板端推理也跑通了。但帧率一测,心凉了半截,只有 6 FPS 左右,CPU 单核占用还特别高。
这时候我才回头去翻转换日志和算子分配报告。一查发现,处理器统计里面 Softmax、Transpose、Reshape 一类算子全部落到了 CPU 上,而且恰好分布在检测头的关键路径上。BPU 的利用率只有百分之二十几,剩下的时间都在等 CPU 算完再把数据传回去。模型结构本身没问题,是算子的映射策略出了问题。这也奠定了后面整个改造方向:必须让 DFL 里的 softmax 从模型里消失,至少不能让它以这种方式阻断 BPU 的流水线。
2. 从算子落图日志定位 Softmax 瓶颈
2.1 转换之后第一件事是读处理统计信息
地平线工具链在转换模型后,会在输出目录里生成分析报告和日志。可能很多人只关心最后有没有生成.bin或.model文件,过程日志几乎不看。但部署这件事,最快的排查路径就是看日志里的算子落图统计。
我当时手上的报告,结构大概是这样的:
| 算子类型 | 期望实现 | 实际分配 | 说明 |
|---|---|---|---|
| Conv2d 3x3 | BPU | BPU | 常规卷积,数量最多 |
| Conv2d 1x1 | BPU | BPU | 检测头、C3k2 使用 |
| Split | BPU | BPU | C3k2 内分支,可被工具链优化 |
| Concat | BPU | BPU | Neck 和 SPPF 使用 |
| AvgPool | BPU | BPU | SPPF 和 attention 使用 |
| Sigmoid | BPU | BPU | 查表方式实现 |
| Reshape | BPU | CPU | DFL 内的 shape 变换 |
| Transpose | BPU | CPU | DFL 内的数据重排 |
| Softmax | BPU | CPU | DFL 4D softmax |
看到这类统计,基本就能定位问题。如果你用的工具链版本比较新,日志里甚至会直接列出"CPU 算子拓扑路径"和"推荐修改建议",但核心结论永远是那句话:这些算子应该从模型中移除或改写。
2.2 为什么 CPU 算子会让性能断崖式下跌
有个常见的误解:认为 Softmax 落到 CPU 上,也就是几十微秒的事,影响不大。实测完全不是这样。
BPU 推理一个模型,是整图流水线作业:BPU 算完前面一段,中间遇到 CPU 算子,数据要先从 BPU 内部存储搬回 DDR,再由 CPU 读取并计算,算完再通过驱动拷贝回 BPU 继续后面的卷积。一次 CPU fallback 不是简单加了一个算子的耗时,而是中断了一次 BPU 流水线,引入两次甚至多次数据搬运。如果这个 CPU 算子还恰好出现在主干路径上,它影响的不是一个算子,而是周围一大片算子的并行流水。
在 YOLOv11n 里,DFL 位于 Detect head 最末端,按理说只影响最后一个阶段。但检测头有三个尺度的输出,每个尺度都带一组 DFL softmax,三组 CPU 算子叠在一起,再加上前后 transpose/reshape 也在 CPU 上执行,流水线中断次数就非常可观。我的实测里,改造前单帧端到端约 160ms,而同样模型在纯 BPU 环境下推算只需要 25ms 左右,差距全花在等待和搬运上。
2.3 Profiler 数据不会说谎
后来我用了板端 runtime 自带的 profiler 接口,能看到更细的时间分布。改造前模型里 BPU 计算本身只占 30ms 左右,但每一帧从预处理到后处理,总耗时 160ms 以上,其中大量时间消耗在驱动同步和 CPU 算子执行上。BPU 利用率报告经常只有 20% 上下,这意味着 80% 的时间它在空转等数据。
这里也分享一个排查技巧:不要只看 FPS,要把每阶段的耗时拆开。如果发现 BPU 算得快但端到端慢,先怀疑 CPU fallback 算子;如果 BPU 本身就慢,再去考虑模型参数量、量化校准或输入尺寸。这是两条完全不同的优化路径,方向错了会浪费很多时间。
3. 动手拆 DFL:把 Softmax 从模型里请出去
3.1 先在 ONNX 图里看清 DFL 的真实面目
不管你用什么方式导出 ONNX,建议转换前先用工具把图过一遍,找到 DFL 相关节点。用 onnx 库直接打印也能做到:
import onnx m = onnx.load("yolo11n_original.onnx") for n in m.graph.node: if n.op_type == "Softmax": print("name:", n.name) print("inputs:", n.input) print("outputs:", n.output) for attr in n.attribute: print(attr.name, attr.i)我当时在图上看到的 Softmax 输入输出长这样:输入是经过 Reshape 和 Transpose 后的 4D tensor,axis=1,输出的形状是(1, 16, 4, 8400)左右。这个布局对于 BPU 来说非常不友好。你想想,一个硬件最喜欢的是通道维在最后、数据排列整齐的 tensor,现在软要在中间维度算 softmax,而且要沿着 16 个 bin 做指数和求和,光是数据重排步骤就能让 BPU 的向量单元抓狂。
3.2 三条改造路线的对比取舍
针对这个 softmax,我试过三套方案,可以给后来人做个参考。
第一套方案是把 DFL 整体注册成自定义 CPU 算子。虽然工具链支持自定义算子,但需要自己写板端执行代码、处理输入输出 tensor 布局,还要保证和 runtime 版本兼容。开发量不小,而且本质上你还是把 DFL 放到了 CPU 上,只是从"隐式 CPU fallback"变成了"显式自定义算子",性能提升有限。除非你有非保留 DFL 不可的需求,否则不建议优先尝试。
第二套方案是用 onnx-graphsurgeon 在 ONNX 图层面做手术,把 Softmax 节点直接删掉,让它的输出接到后面的 Conv 节点上。这个方案改动小,但问题在于 Conv 的输入是 softmax 结果,数值范围被限制在 0 到 1 之间。一旦你删掉 softmax,Conv 拿到的就是未经归一化的 logits,数值范围完全不同,量化时很容易出问题,精度也可能崩。这条路可以走,但要走的话必须连同积分逻辑一起改,不能只删 softmax。
第三套方案是我最终采用的:把 DFL 整体从模型里拿掉,让模型只输出 DFL 之前的原始分布特征,在后处理阶段用 CPU 重新实现 softmax 和积分。这套方案最干净,BPU 只负责它最擅长的卷积计算,后处理即便在 CPU 上做也足够快,因为 4x16x8400 个数据的 softmax 在 ARM CPU 上也就是亚毫秒级别。
3.3 改代码导出"无 DFL"版本的模型
具体操作上,我直接在 ultralytics 源码的 Detect head 上做了改动。找到ultralytics/nn/modules/head.py里的 Detect 类,把 forward 逻辑里经过self.dfl(box)计算的结果换成原始的 box 分支输出。
改完后模型输出会和原来不一样。原本 YOLOv11n 输出的是已经解码好的 box 坐标分布和分类 logits,现在变成了:
- 分类分支输出:shape 为
(1, 80, 8400)(COCO 80 类,也可以保留 sigmoid 在模型内) - 回归分支输出:shape 为
(1, 64, 8400),即 DFL 之前的 64 通道原始分布
两者在通道维拼接,最终输出(1, 144, 8400)。这个 ONNX 导出后在 netron 里看,结构会清爽很多,没有任何 softmax 和 transpose 掺杂在关键路径里。
导出指令和官方几乎没有区别,只是模型 weight 文件用改过 forward 的代码外挂。我建议导出时固定用 opset 11 或 12,不要开动态维度,输入尺寸固定 640x640。工具链对老版本 opset 的兼容性通常更好,新特性反而容易踩算子支持边界。
3.4 改造后的精度校验不能省
不要急着转 BPU,先在 PC 上用 onnxruntime 把改后的模型输出和原模型的输出对齐验证一遍。具体做法是把图片预处理成同样的输入,分别跑原模型和改后模型,然后你用 numpy 实现一遍 DFL 后处理,和原模型输出的 box 解码结果比对。
DFL 的积分过程用代码写出来非常短:
import numpy as np def dfl_decode(box_raw, anchors, stride): b, c, a = box_raw.shape box = box_raw.reshape(b, 4, 16, a).transpose(0, 1, 3, 2) box = softmax(box, axis=-1) weights = np.arange(16, dtype=np.float32) dist = (box * weights).sum(axis=-1) # (b, 4, a) # 结合 anchors 和 stride 解码成 x1y1x2y2 lt = anchors - dist[..., :2] * stride[..., None] rb = anchors + dist[..., 2:] * stride[..., None] return np.concatenate([lt, rb], axis=-1)这个softmax是自定义函数,需要注意数值稳定性,用x - x.max(axis=-1, keepdims=True)先做一次平移再算 exp。
实测下来,改造前后在 float32 下输出差异小于 1e-5,因为 softmax 本身是确定性的数学变换,没有引入任何近似。真正会影响精度的环节在后面量化阶段,这一步对齐的意义是确保模型结构和后处理公式之间没有理解偏差。
4. 重新转换与板端 Runtime 落地
4.1 导出 ONNX 的三个隐藏细节
改造完成后的导出,有几个细节直接影响后续工具链转换是否顺利。
第一,输入节点名要固定。ultralytics 导出的 ONNX 输入名可能是images,也可能被写成别的,取决于版本。先打印一下图的输入信息,记下名字,后面写转换配置要用。
第二,关闭所有动态维度。dynamic=True导出的 ONNX 在 BPU 工具链里很容易因为 anchor 数量可变而失败,或者触发大量 CPU fallback。DNN 场景下固定 640x640 或你需要用的分辨率,不要贪心。
第三,去掉不需要的后处理节点。如果你用的旧教程里面导出了 NMS 或者一些后处理封装,千万别直接套到 BPU 转换上。BPU 工具链对自定义后处理算子支持很差,最好让 ONNX 只包含前馈卷积部分,所有后处理留给板端代码。
4.2 量化校准配置与步骤
RDK X5 的 BPU 是 int8 定点加速,所以模型转换必须做量化。量化不是走过场,校准集的内容直接影响最终精度。
我准备的校准集是 200 张图片,覆盖了室内、室外、小目标、远景近景,分辨率五花八门,但都在预处理时代码统一 resize 到 640x640。有一个容易踩的坑是:校准用的预处理必须和部署时完全一致。比如训练或导出时模型期望的是 0-1 归一化输入,而你的采集脚本是直接用 OpenCV 读图、只做 resize 不除以 255,那转换出来的模型在板端精度会掉得很明显。很多 mAP 下降的案例,最后查出来不是量化问题,而是预处理不一致。
转换配置大概长这样,不同工具链版本字段名略有差异,但核心信息跑不掉:
model_type: onnx input_model: yolo11n_nodfl.onnx out_dir: ./output input_shape: - 1 - 3 - 640 - 640 mean: [0, 0, 0] std: [255, 255, 255] calibration: data: ./calib_images num: 200 batch_size: 1注意mean和std的配置不是让你填训练时的归一化参数,而是告诉工具链在板端推理时输入数据需要做什么变换。如果模型结构本身就包含归一化层,那这里就不用再填。最稳妥的做法是对照训练预处理:YOLOv11 训练时是 BGR 图除以 255 后送入网络,所以我把 mean 设成 0、std 设成 255,保证输入范围是 0 到 1。如果工具链有默认的 RGB/BGR 通道顺序转换,也要检查,否则红蓝通道互换,检测结果会完全错乱。
4.3 板端推理接口的核心流程
模型转换成功后会生成.bin或.model文件,板端 runtime 加载这个文件并执行推理。部署代码的核心流程并不复杂,但顺序不能乱:
- 初始化 runtime,加载模型文件
- 分配输入输出 tensor 内存
- 对输入图像做和训练一致的预处理(resize、通道转换、归一化)
- 将预处理结果拷贝到输入 tensor
- 调用推理接口,等待输出
- 拿输出 tensor,按之前约定的布局拆分类和回归分支
- 后处理:sigmoid、DFL 积分、anchor 解码、NMS
Python 示例可以简化成这样:
import numpy as np from rdk.dnn import Runtime # 具体包名以 SDK 版本为准 runtime = Runtime("yolo11n_nodfl.bin") input_tensor = runtime.get_input_tensor(0) output_tensor = runtime.get_output_tensor(0) # image 已经过 resize,shape 为 (640, 640, 3) input_tensor[:] = preprocess(image) runtime.run() raw = output_tensor[0] # shape (144, 8400) cls_logits = raw[:80] box_dist = raw[80:] # 继续后处理实际接口名每个 SDK 版本不太一样,但流程类似。我建议拿到 SDK 后先跑一遍自带的样例,重点看它如何处理输入 tensor 的格式(NHWC 还是 NCHW)。BPU 上的 tensor 布局和 PyTorch 默认的 NCHW 可能不同,很多第一次上手的朋友在这一步卡了很久。
4.4 后处理逻辑要和高层设计对齐
后处理代码我直接用 numpy 写的,逻辑清楚也容易调试。先把回归分支的 64 通道拆成 4 组 16 bin,softmax 后再乘 0 到 15 的权重,得到中心点到左右上下的距离。
因为模型输出里已经移除了 DFL,这一步相当于把原来网络内部的计算搬到了 CPU 端。很多人担心这样会不会变成 CPU 瓶颈,实测下来完全不会:在 8400 个 anchor 上做 4x16 的 softmax 和加权求和,加 NMS 一起也就 2 到 3ms,对 35 FPS 的目标来说完全可接受。
NMS 我用的还是 ultralytics 里常用 max 抑制逻辑,坐标按照 stride 和 anchor 偏移还原。这一步最容易出错的是 anchor 顺序和 stride 对齐。YOLOv8 系的输出排列规律是:每个尺度下所有 anchor 按照网格行优先排列,三个尺度再依次拼接。后处理时如果你拿到的输出恰好是不同顺序,解出来坐标会是一团乱麻。建议用一张单目标图像先做逐层打印验证,把预测框还原到图上确认位置正确,再做全量测试。
5. 实测数据与部署中最容易被绊倒的细节
5.1 改造前后的性能对照
整个改造完成、量化、部署跑通后,我重新做了全流程性能测试,数据如下:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| BPU 算子比例 | 约 94% | 100% |
| CPU 算子数 | Softmax、Transpose 等约 6 个 | 0 |
| 单帧端到端耗时 | 约 160 ms | 约 28 ms |
| 对应帧率 | 约 6 FPS | 约 35 FPS |
| profiler 中 BPU 利用率 | 约 20% | 约 70% |
| 单核 CPU 占用 | 高 | 明显降低 |
不同固件版本和工具链版本下,绝对数字会有差异,但这个量级的提升方向是确定的。核心结论只有一个:移除关键路径上的 CPU fallback 算子,比优化任何单个算子的计算效率都更重要。
精度方面,我用 COCO val 集抽了 500 张图做量化前后对比。float32 原始模型 mAP50 在 0.68 左右(YOLOv11n 的合理水平),改后模型在 PC 上 float32 推理几乎一致;量化到 int8 后 mAP50 掉了大约 1 到 2 个点,这个幅度在目标检测 int8 量化里是正常的。如果你发现掉点超过 3 个点,优先检查校准集和预处理,而不是怀疑模型改造。
5.2 部署路上我踩过的 6 个坑
第一个坑是预处理归一化。前面提过,校准和部署必须一致。我在板端一开始图省事,只做了 resize 没除以 255,结果检测框乱飘,精度惨不忍睹。后来把输入预处理对齐成除以 255,效果立刻正常。
第二个坑是通道顺序。OpenCV 读进来是 BGR,而某些推理库期望 RGB。YOLOv11 训练时数据增强用的是 BGR,但导出到 ONNX 后图里没有记录通道顺序信息。如果板端预处理做了 RGB 转换,模型看到的色彩空间就错了。解决办法是完全复刻训练时的读取逻辑,不要自己觉得"应该"怎样就改。
第三个坑是量化校准集太少。我第一次只用了 50 张图,想着能代表场景就够了,结果 int8 模型对亮度敏感,夜间场景掉点非常严重。把校准集扩充到 200 张,涵盖多种光照和场景后,整体精度才稳定下来。
第四个坑是 anchor 顺序。YOLOv11 输出三个尺度的预测,anchor 拼接顺序不能搞错。我曾在解码坐标时把 stride 顺序反了,结果小目标框被放大到错误位置,排查了很久才发现是 stride 对应关系不对。
第五个坑是 NMS 后处理的 Box 格式。模型输出的是 xyxy 还是 xywh,不同工具链转换后可能帮你做了一层变换,但更多时候是你自己要在后处理里转换。建议在后处理代码里打印几个已知目标的输出,和原模型结果对比,确认格式一致再往下走。
第六个坑是板端多线程调用。如果多个线程同时调用同一个 runtime 实例,有的 SDK 是支持的,有的会直接导致不稳定。最好是每个线程创建独立 runtime,或者加锁串行调用。对 YOLOv11n 这种单帧 28ms 的模型,单线程已经能支撑不错的吞吐量,没必要为了并发给自己找麻烦。
5.3 一些个人体会
回过头来看,这次部署最核心的收获是对 BPU 的算子边界有了真正的体感。跑通一个 demo 从来不是问题,问题永远是"跑在哪儿"。RDK X5 的 BPU 计算能力在轻量级检测模型上是完全够用的,但前提是你得让模型结构去适配硬件的脾气,而不是指望工具链帮你把一切不合理都优化掉。
类似 DFL softmax 的问题,其实在 YOLOv8、YOLOv10 里也存在,YOLOv11 只是把检测头改得更复杂了一些,让更多人撞上了这个点。如果你之后要部署其他带 DFL 或带注意力机制的检测模型,建议先做同样的事:导出 ONNX,扫一遍算子,把所有可能落到 CPU 的节点找出来,再决定是裁剪还是重写。提前看这一步,后面省下的调试时间是以天为单位的。
另外,如果后续想把性能再往上提,可以考虑两个方向:一是把 DFL 后处理从 numpy 换成 C++ 实现,或者嵌入 ARM NEON 指令优化;二是把检测头里剩余的 ChannelAttention 模块与工具链支持情况逐一对齐,看看有没有进一步压缩算子数量的空间。相比之下,把精力花在优化后处理上,收益会更直接。我目前的部署形态已经能满足实时检测需求,但如果有新项目要用更复杂的模型,我会优先把整条算子映射 check list 走一遍,再考虑跑起来的问题。