Skip to content

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 返回的 typefmtdims、量化信息和 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”就完成了优化。但检查消费端热路径后,仍然能看到下面这条链:

text
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_bridgecvtColorconvertTorknn_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 表达四对逻辑切片:

text
pair 0: y =   0, height = 256
pair 1: y = 256, height = 256
pair 2: y = 512, height = 256
pair 3: y = 768, height = 256

consumer 读取 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 后,还需要一组可执行的属性:

属性需要回答的问题
shapebatch、channel、height、width 各是多少
layout数据按 NCHW 还是 NHWC 排列
dtype应用提交 FP32、FP16、INT8 还是 UINT8
quantizationscale、zero point 和量化方式是什么
stride每行和整个 buffer 的实际跨度是多少

这里最容易混淆的是模型精度和 Runtime 输入类型。FP16 模型不代表应用只能提交 FP16;普通输入路径可以发生类型转换。但一旦追求低拷贝和稳定延迟,上游就应该尽量直接生产模型需要的 native contract。

3. 内存与所有权合同

即使 tensor 属性全部正确,内存仍有自己的规则:谁分配、谁写入、何时交给 NPU、什么时候允许复用、CPU 与设备之间是否需要同步缓存。

所以,“zero-copy”不能只看有没有 DMA-BUF fd。应用中间做了一次 memcpy,或者 Runtime 又做了 layout/dtype 转换,端到端链路就仍然存在搬运。

运行时第一步:查询,不要猜

加载 .rknn 后,应先把每个输入的真实属性打印出来:

cpp
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 视觉应用,可以把数据流明确成以下阶段:

text
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 会依据调用方填写的 typefmt 做必要转换:

cpp
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。

这时必须同时满足四个条件:

  1. buffer 容量覆盖 size_with_stride
  2. 写入内容与声明的 dtype/layout 一致;
  3. producer 完成写入后才把内存交给 NPU;
  4. cacheable 内存在 CPU 与设备交接时执行正确的同步。

pass_through 也不是一个“性能开关”。打开它意味着数据直接进入模型输入节点,不再由 Runtime 按 typefmt 转换。合同有任何一项不匹配,结果都可能错误。

FP16 模型的输入到底该怎么做

如果 .rknn 是非量化 FP16 模型,可以按两阶段推进:

第一阶段先用 FP32 或 Runtime 明确支持的输入类型跑通参考结果,证明 resize、颜色、归一化和输出解析全部正确。第二阶段再根据 RKNN_QUERY_INPUT_ATTR 的结果,把上游输出收敛到模型 native dtype/layout,并测量 CPU、内存带宽和端到端延迟是否真的下降。

不要因为模型叫“FP16”就跳过查询,也不要为了 zero-copy 把 UINT8 图像强行解释成 FP16 tensor。类型匹配只是合同的一部分,数值范围同样必须匹配训练和转换配置。

INT8/UINT8 输入最容易错在哪里

量化模型需要重点确认 qnt_typescalezp。以非对称量化为例,浮点值与量化值之间通常可以写成:

text
q = round(real_value / scale) + zero_point
real_value ≈ (q - zero_point) × scale

但是否由应用执行这一步,取决于模型与输入路径。如果 Toolkit 已把预处理固化进模型,而应用又手动量化一次,结果就会发生双重量化。反过来,如果 pass_through 直接提交 native INT8,而上游没有完成模型要求的量化,数值也会完全错误。

所以不能从 INT8UINT8 这一个枚举推导完整处理方式。必须同时检查转换配置、tensor attr 与实际调用路径。

用症状快速定位输入问题

症状优先检查
能运行但分类/检测结果明显不对RGB/BGR、normalize、量化是否重复
画面主体位置对,框整体偏移resize、letterbox、ROI 与反变换
普通路径正确,zero-copy 错误layout、stride、buffer 容量、cache sync
换模型后随机崩溃或结果漂移输入数量、shape、size_with_stride
CPU 仍然很高memcpycv::Mat、Runtime 隐式转换
首帧正确,后续帧偶发污染buffer 生命周期、fence/同步与复用时机

最小验收闭环

我更倾向把 NPU 输入验收拆成五步:

  1. 用同一张输入图对齐训练框架、Toolkit 模拟器与板端输出;
  2. 保存预处理后的 tensor,逐元素或按统计量比较;
  3. 比较普通输入和 zero-copy 输入的结果误差;
  4. 分开统计预处理、提交、推理和后处理耗时;
  5. 在连续流中验证 buffer 复用、缓存同步与长时间稳定性。

只有结果一致、链路没有隐式复制、buffer 所有权可证明,才能说 NPU 输入真正接好了。

结语

NPU 输入设计的核心不是选择 FP16 还是 INT8,而是建立一份不会被误解的端到端合同。图像语义、tensor 属性和内存所有权缺一不可。

当这份合同足够明确,CPU 基线路径、RGA/OpenCL 预处理和 RKNN zero-copy 才能逐步切换、分别验证。否则,“零拷贝”很容易只剩一个 DMA-BUF fd,而错误和性能损耗被藏在下一层。

延伸阅读