RK3588 NPU 输入踩坑复盘:能跑不等于接对
这篇文章不从理想架构开始,而从几次真实误判开始。因为 NPU 输入最麻烦的地方不是 API 不会调用,而是代码能运行、结果也能出来,却仍然可能存在类型混淆、隐式复制、布局漂移和伪 zero-copy。
把一张图的地址交给 RKNN,并不等于完成了 NPU 输入。真正的输入是一份完整合同:图像表达什么、tensor 怎样排列、数值如何变换、buffer 怎样对齐,以及 CPU、RGA、GPU 与 NPU 在什么时刻拥有它。
先看问题:四次最有价值的误判
| 现场问题 | 第一反应 | 最终定位 |
|---|---|---|
| FP16 模型到底该传 FP32 还是 FP16 | 根据模型名称直接选 dtype | 模型精度、Runtime 输入和 NPU 计算精度是三份合同 |
| 相机已经输出 DMA-BUF,CPU 却没有明显下降 | 继续优化 NPU 推理 | CPU 仍耗在 ROS、颜色转换、float32 物化与输入复制 |
| 同时输出 atlas 和四对独立图像 | 多一种输出更方便 | 把逻辑视图做成了重复的物理存储 |
| 下游能按固定偏移切 ROI | 常量一致就够了 | layout 没进入 typed contract,组件升级后必然漂移 |
踩坑一:把“模型是 FP16”理解成“应用必须传 FP16”
现场症状
一个非量化模型已经能通过 rknn_inputs_set() 正常推理,上游使用的是 float32 buffer。准备切换高性能输入时,问题变成了:“模型是 FP16,那应用是不是必须直接生产 FP16?”
这类问题表面在选 dtype,实际已经把三个不同概念混到了一起:模型图的精度、Runtime API 接受的输入,以及 NPU 内部最终执行的精度。
错误判断
第一种误判是看到 FP16 模型就把应用 buffer 改成 FP16。第二种误判则相反:既然 FP32 输入能跑,就认为整个网络仍按 FP32 计算。
两者都可能让程序继续运行,所以单看返回码找不到问题。
证据链
真正有效的证据不是模型文件名,而是 RKNN_QUERY_INPUT_ATTR 返回的 type、fmt、dims、量化信息和 size_with_stride。同时还要检查调用路径是否启用了 Runtime 转换。
pass_through = 0 时,调用方声明的 dtype/layout 可以先由 Runtime 转换;直接绑定输入内存时,数据则应更严格地匹配模型输入合同。
根因与解决
根因是把“模型精度”当成了“应用输入格式”。修复时没有直接跳到 zero-copy,而是保留普通 FP32 输入作为正确性基线,再查询当前 .rknn 的真实属性,最后决定 OpenCL 应输出 FP16 native tensor,还是继续让 Runtime 转换。
怎么验证修复
同一输入至少比较三份结果:训练框架或参考实现、普通 Runtime 输入、native/zero-copy 输入。只有输出误差在预期范围内,并且预处理与提交耗时确实下降,dtype 改造才算完成。
踩坑二:换成 DMA-BUF 后,CPU 为什么还是高
现场症状
相机流水线已经能输出 DMA-BUF,于是很容易认为下游模型“把 topic 换成 DMA-BUF”就完成了优化。但检查消费端热路径后,仍然能看到下面这条链:
ROS Image exact-time sync
→ cv_bridge
→ BGR to RGB
→ convertTo(float32)
→ 独立 float buffer
→ rknn_inputs_set()数据入口看起来更现代了,真正吃 CPU 的预处理和输入复制却一个都没消失。
错误判断
错误在于把传输载体当成完整数据路径。DMA-BUF 只解决“这块内存能否跨组件共享”,不会自动消除 cv::Mat 物化、颜色转换、float32 扩展和 Runtime 内部转换。
证据链
这类问题不能只看 NPU 利用率。需要同时做两件事:沿源码逐段标出每帧的 map、copy、convert 和 submit;把预处理、输入提交、NPU 推理、后处理分别计时。
如果 NPU 推理时间基本不变,而 CPU 仍耗在 cv_bridge、cvtColor、convertTo 和 rknn_inputs_set(),继续调 NPU core mask 并不能解决问题。
根因与解决
根因是只替换了接口,没有替换数据面。正确方向是让模型成为相机图的直接 consumer,由 RGA/OpenCL 完成 resize、颜色和 native tensor 生成,再用 RKNN IO memory 接口提交,ROS 图像桥只保留为兼容或诊断输出。
需要特别说明:这次结论来自代码热路径与架构分析,它证明“仅换 topic 不够”,但不能替代改造后的板端 A/B 数据。完成实现后仍要重新测 CPU、RSS、带宽、帧率与 NPU 利用率。
踩坑三:同时输出 atlas 和四对图像,资源反而更差
现场症状
为了同时服务不同 consumer,一个看似方便的方案是既保留两张 320×1024 双目 atlas,又额外生成四对 320×256 图像。接口确实更直观,但同一批像素被物化了两次。
错误判断
这里混淆了逻辑视图和物理存储。四对图像只是 atlas 中四个纵向 ROI,不需要天然对应八块新的像素内存。
证据链
判断方法很直接:把每帧新增的 buffer 数量、单帧字节数、队列深度和帧率相乘,再检查额外的颜色转换与复制次数。即使单帧看起来不大,连续流中的内存带宽和 CMA/DMA-BUF 压力也会被放大。
根因与解决
根因是为了 consumer 方便,破坏了单一物理表示。最终方案只保留左右两张 atlas DMA-BUF,并用 typed ROI metadata 表达四对逻辑切片:
pair 0: y = 0, height = 256
pair 1: y = 256, height = 256
pair 2: y = 512, height = 256
pair 3: y = 768, height = 256consumer 读取 ROI view,不重新生成独立图像。验证时要确认四个区域边界不越界、时间戳继承一致,并比较 ROI view 与旧物化图像的像素结果。
踩坑四:让下游自己猜 layout,测试迟早会失败
现场症状
早期实现里,atlas 的宽高和四段 ROI 偏移散落在消费端常量中。第一次写 contract test 时,测试直接假设执行器已经返回 layout;实际接口里根本没有这个字段。
这个失败反而暴露了真正的问题:layout 只存在于人的共识和几个常量里,没有进入执行结果合同。
错误判断
最初想法是让下游根据固定尺寸重新计算 ROI。短期能工作,但一旦分辨率、排列方式或 stride 变化,不同 consumer 会各自得到一份“看起来合理”的解释。
根因与解决
根因不是测试写早了,而是 contract 放错了层。修复后,由实际生成 atlas 的 executor/builder 输出 typed layout;中间 element 只验证并转发;算法 consumer 只消费,不再发明布局。
怎么验证修复
验证分成两组:一组检查 canonical atlas 与四个 ROI 的几何关系,另一组检查硬件执行结果确实携带同一份 layout。两组包级测试全部通过后,才算关闭这个问题。
这次修复带来的价值不只是“测试绿了”。它消除了相机侧、OpenCL 侧和 NPU consumer 之间最容易静默漂移的一份隐式协议。
先把输入拆成三层合同
1. 图像语义合同
相机通常给出 NV12、NV21、YUYV 或 Bayer 数据,模型训练时看到的却可能是 RGB 图片。二者之间至少要明确:
- 输入来自完整图像还是 ROI;
- resize 是直接拉伸、等比例缩放还是 letterbox;
- YUV 使用哪种色彩矩阵和数值范围;
- 输出是 RGB 还是 BGR;
- 是否需要减均值、除标准差或缩放到特定区间。
这些信息不会因为 buffer 能被 NPU 访问就自动正确。最危险的情况不是接口报错,而是图像“看起来差不多”,模型精度却悄悄下降。
2. Tensor 合同
图像变成 tensor 后,还需要一组可执行的属性:
| 属性 | 需要回答的问题 |
|---|---|
| shape | batch、channel、height、width 各是多少 |
| layout | 数据按 NCHW 还是 NHWC 排列 |
| dtype | 应用提交 FP32、FP16、INT8 还是 UINT8 |
| quantization | scale、zero point 和量化方式是什么 |
| stride | 每行和整个 buffer 的实际跨度是多少 |
这里最容易混淆的是模型精度和 Runtime 输入类型。FP16 模型不代表应用只能提交 FP16;普通输入路径可以发生类型转换。但一旦追求低拷贝和稳定延迟,上游就应该尽量直接生产模型需要的 native contract。
3. 内存与所有权合同
即使 tensor 属性全部正确,内存仍有自己的规则:谁分配、谁写入、何时交给 NPU、什么时候允许复用、CPU 与设备之间是否需要同步缓存。
所以,“zero-copy”不能只看有没有 DMA-BUF fd。应用中间做了一次 memcpy,或者 Runtime 又做了 layout/dtype 转换,端到端链路就仍然存在搬运。
运行时第一步:查询,不要猜
加载 .rknn 后,应先把每个输入的真实属性打印出来:
rknn_input_output_num io_num{};
rknn_query(ctx, RKNN_QUERY_IN_OUT_NUM, &io_num, sizeof(io_num));
for (uint32_t i = 0; i < io_num.n_input; ++i) {
rknn_tensor_attr attr{};
attr.index = i;
int ret = rknn_query(
ctx, RKNN_QUERY_INPUT_ATTR, &attr, sizeof(attr));
if (ret != RKNN_SUCC) {
throw std::runtime_error("query RKNN input attr failed");
}
printf("input[%u] name=%s type=%s fmt=%s size=%u "
"w_stride=%u size_with_stride=%u "
"qnt=%s zp=%d scale=%f\n",
i, attr.name, get_type_string(attr.type),
get_format_string(attr.fmt), attr.size,
attr.w_stride, attr.size_with_stride,
get_qnt_type_string(attr.qnt_type), attr.zp, attr.scale);
}这份 dump 应与模型 hash 一起进入部署记录。模型文件一旦替换,输入合同也必须重新验证,而不是沿用旧代码里的宽高和类型常量。
一条可维护的 NPU 输入流水线
对于 RK3588 视觉应用,可以把数据流明确成以下阶段:
V4L2 / Rockit capture
→ NV12 DMA-BUF
→ crop / resize / letterbox
→ YUV to RGB/BGR
→ normalize or quantize
→ native dtype + layout + stride
→ RKNN input memory
→ cache/device synchronization
→ rknn_run()这条链路最重要的设计原则是:每个阶段只负责一种变换,并把输出属性写成明确合同。不要让消费端根据 buffer 大小反推 layout,也不要让多个组件分别做一次颜色或归一化转换。
RGA 适合做什么
RGA 适合处理 crop、resize、letterbox 和常见像素格式转换。官方示例也展示了用 RGA 将图像缩放到 RKNN 创建的输入内存。
但“RGA 已经把图送进输入 buffer”仍不代表 tensor 一定正确。RGB/BGR 顺序、归一化、量化、layout 和 stride 仍需要按模型合同确认。
OpenCL 适合做什么
当预处理需要自定义畸变校正、atlas/ROI 重排、归一化或 FP16 tensor 生成时,OpenCL 更适合表达融合算子。理想状态是让 OpenCL 直接写入最终输入 buffer,避免先生成中间 cv::Mat,再由 CPU 转成 float tensor。
这里应该优化的是完整的数据流,而不是单个 kernel 的耗时。减少一次 CPU 物化、一次颜色转换或一次大块复制,通常比继续压缩已经很短的算子更有价值。
普通输入与 zero-copy 是两种验收阶段
普通路径:先证明语义正确
rknn_inputs_set() 适合模型接入初期。pass_through = 0 时,Runtime 会依据调用方填写的 type 与 fmt 做必要转换:
rknn_input input{};
input.index = 0;
input.buf = rgb_data;
input.size = rgb_bytes;
input.type = RKNN_TENSOR_UINT8;
input.fmt = RKNN_TENSOR_NHWC;
input.pass_through = 0;
rknn_inputs_set(ctx, 1, &input);
rknn_run(ctx, nullptr);这条路径的价值是快速建立正确性基线。它不应该被默认当成最终性能方案,因为 Runtime 内部可能仍有 dtype 或 layout 转换。
Zero-copy 路径:再消除搬运和隐式转换
稳定生产路径可以使用 rknn_create_mem() 创建输入内存,或按具体 SDK 能力导入外部分配的 fd,然后通过 rknn_set_io_mem() 绑定 tensor。
这时必须同时满足四个条件:
- buffer 容量覆盖
size_with_stride; - 写入内容与声明的 dtype/layout 一致;
- producer 完成写入后才把内存交给 NPU;
- cacheable 内存在 CPU 与设备交接时执行正确的同步。
pass_through 也不是一个“性能开关”。打开它意味着数据直接进入模型输入节点,不再由 Runtime 按 type 和 fmt 转换。合同有任何一项不匹配,结果都可能错误。
FP16 模型的输入到底该怎么做
如果 .rknn 是非量化 FP16 模型,可以按两阶段推进:
第一阶段先用 FP32 或 Runtime 明确支持的输入类型跑通参考结果,证明 resize、颜色、归一化和输出解析全部正确。第二阶段再根据 RKNN_QUERY_INPUT_ATTR 的结果,把上游输出收敛到模型 native dtype/layout,并测量 CPU、内存带宽和端到端延迟是否真的下降。
不要因为模型叫“FP16”就跳过查询,也不要为了 zero-copy 把 UINT8 图像强行解释成 FP16 tensor。类型匹配只是合同的一部分,数值范围同样必须匹配训练和转换配置。
INT8/UINT8 输入最容易错在哪里
量化模型需要重点确认 qnt_type、scale 与 zp。以非对称量化为例,浮点值与量化值之间通常可以写成:
q = round(real_value / scale) + zero_point
real_value ≈ (q - zero_point) × scale但是否由应用执行这一步,取决于模型与输入路径。如果 Toolkit 已把预处理固化进模型,而应用又手动量化一次,结果就会发生双重量化。反过来,如果 pass_through 直接提交 native INT8,而上游没有完成模型要求的量化,数值也会完全错误。
所以不能从 INT8 或 UINT8 这一个枚举推导完整处理方式。必须同时检查转换配置、tensor attr 与实际调用路径。
用症状快速定位输入问题
| 症状 | 优先检查 |
|---|---|
| 能运行但分类/检测结果明显不对 | RGB/BGR、normalize、量化是否重复 |
| 画面主体位置对,框整体偏移 | resize、letterbox、ROI 与反变换 |
| 普通路径正确,zero-copy 错误 | layout、stride、buffer 容量、cache sync |
| 换模型后随机崩溃或结果漂移 | 输入数量、shape、size_with_stride |
| CPU 仍然很高 | memcpy、cv::Mat、Runtime 隐式转换 |
| 首帧正确,后续帧偶发污染 | buffer 生命周期、fence/同步与复用时机 |
最小验收闭环
我更倾向把 NPU 输入验收拆成五步:
- 用同一张输入图对齐训练框架、Toolkit 模拟器与板端输出;
- 保存预处理后的 tensor,逐元素或按统计量比较;
- 比较普通输入和 zero-copy 输入的结果误差;
- 分开统计预处理、提交、推理和后处理耗时;
- 在连续流中验证 buffer 复用、缓存同步与长时间稳定性。
只有结果一致、链路没有隐式复制、buffer 所有权可证明,才能说 NPU 输入真正接好了。
结语
NPU 输入设计的核心不是选择 FP16 还是 INT8,而是建立一份不会被误解的端到端合同。图像语义、tensor 属性和内存所有权缺一不可。
当这份合同足够明确,CPU 基线路径、RGA/OpenCL 预处理和 RKNN zero-copy 才能逐步切换、分别验证。否则,“零拷贝”很容易只剩一个 DMA-BUF fd,而错误和性能损耗被藏在下一层。