所属合集 RK3588 端侧 AI 部署 第 5 / 6 篇
RK3588 端侧 AI 部署(五):Lite2、C API 与零拷贝工程化
模型通过 Toolkit2 验证后,下一步是让程序真正独立运行在 RK3588 上。可以先用 Lite2 快速复现 Python 结果,再用 RKNPU2 C/C++ API 构建正式应用。
上一篇:量化、评估与模型优化
一、三种运行方式如何选择
| 方式 | 运行位置 | 适合阶段 | 主要限制 |
|---|---|---|---|
| Toolkit2 连板 | x86 主机控制 RK3588 | 转换后快速验证和评估 | 依赖主机、ADB 和 server |
| Toolkit-Lite2 | RK3588 板端 Python | 原型、算法验证、小工具 | Python 开销和工程能力有限 |
| RKNPU2 C/C++ | RK3588 板端原生程序 | 产品、视频流、并发和性能优化 | 开发复杂度更高 |
推荐顺序:
Toolkit2 连板结果正确→ Lite2 在板端复现→ 通用 C API 复现→ 零拷贝、多 Context、RGA 和线程池优化二、RKNN-Toolkit-Lite2 快速验证
从 RKNN-Toolkit2 仓库 中选择与下面三项匹配的 Lite2 wheel:
- RKNN SDK release;
- 板端 Python
cpXX; aarch64架构。
conda create -n rknnlite python=3.10 -yconda activate rknnlitepip install ./rknn_toolkit_lite2-*-cp310-*-linux_aarch64.whl最小推理结构:
from rknnlite.api import RKNNLite
runtime = RKNNLite(verbose=True)
ret = runtime.load_rknn("model.rknn")if ret != 0: raise RuntimeError(f"load_rknn failed: {ret}")
ret = runtime.init_runtime(core_mask=RKNNLite.NPU_CORE_AUTO)if ret != 0: raise RuntimeError(f"init_runtime failed: {ret}")
try: outputs = runtime.inference( inputs=[input_tensor], data_format=["nhwc"], )finally: runtime.release()这里的 input_tensor 必须与 Toolkit2 验证脚本完全一致。Lite2 只运行 .rknn,不能加载 ONNX 后执行 build()。
三、RKNPU2 通用 C API 的生命周期
完整流程可以记成:
rknn_init→ rknn_query→ 准备输入→ rknn_inputs_set→ rknn_run→ rknn_outputs_get→ 后处理→ rknn_outputs_release→ rknn_destroyrknn_context 是贯穿整个流程的句柄。所有返回值都要检查,所有成功申请的资源都要在错误路径中释放。
四、读取模型并创建 Context
模型可以先读入内存,再传给 rknn_init():
#include <fstream>#include <stdexcept>#include <string>#include <vector>#include "rknn_api.h"
std::vector<unsigned char> LoadFile(const std::string& path) { std::ifstream file(path, std::ios::binary | std::ios::ate); if (!file) { throw std::runtime_error("cannot open model: " + path); }
const auto size = file.tellg(); if (size <= 0) { throw std::runtime_error("empty model: " + path); }
std::vector<unsigned char> data(static_cast<size_t>(size)); file.seekg(0, std::ios::beg); file.read(reinterpret_cast<char*>(data.data()), size); if (!file) { throw std::runtime_error("read model failed: " + path); } return data;}
int main(int argc, char** argv) { if (argc < 2) return 1;
auto model = LoadFile(argv[1]); rknn_context ctx = 0;
int ret = rknn_init(&ctx, model.data(), model.size(), 0, nullptr); if (ret != RKNN_SUCC) { throw std::runtime_error("rknn_init failed: " + std::to_string(ret)); }
// query / inference / postprocess ...
rknn_destroy(ctx); return 0;}正式工程应使用 RAII 封装 Context,确保异常或提前返回时也能执行 rknn_destroy()。
五、不要写死张量属性
先查询输入输出数量:
rknn_input_output_num io_num{};int ret = rknn_query( ctx, RKNN_QUERY_IN_OUT_NUM, &io_num, sizeof(io_num));再逐个查询 Tensor:
std::vector<rknn_tensor_attr> input_attrs(io_num.n_input);for (uint32_t i = 0; i < io_num.n_input; ++i) { input_attrs[i] = {}; input_attrs[i].index = i; ret = rknn_query( ctx, RKNN_QUERY_INPUT_ATTR, &input_attrs[i], sizeof(rknn_tensor_attr) ); if (ret != RKNN_SUCC) { throw std::runtime_error("query input attr failed"); }}每个 Tensor 至少打印:
- index 和 name;
n_dims、dims、元素数;- NHWC/NCHW;
- UINT8/INT8/FLOAT16/FLOAT32;
- 量化类型、zero point 和 scale;
- size、
size_with_stride和w_stride。
后处理应依据查询结果验证模型契约,而不是看到“YOLO”就默认三个输出。
六、通用 API 输入、运行和输出
1. 设置输入
rknn_input input{};input.index = 0;input.buf = image_data;input.size = image_bytes;input.type = RKNN_TENSOR_UINT8;input.fmt = RKNN_TENSOR_NHWC;input.pass_through = 0;
ret = rknn_inputs_set(ctx, 1, &input);if (ret != RKNN_SUCC) { throw std::runtime_error("rknn_inputs_set failed");}image_data 的实际 shape、通道、dtype 和预处理必须匹配模型。如果 RKNN 已内置均值和标准差,输入通常可以是预处理后的 UINT8 图像;不能再次手工标准化。
2. 执行推理
ret = rknn_run(ctx, nullptr);if (ret != RKNN_SUCC) { throw std::runtime_error("rknn_run failed");}3. 获取和释放输出
std::vector<rknn_output> outputs(io_num.n_output);for (uint32_t i = 0; i < io_num.n_output; ++i) { outputs[i] = {}; outputs[i].index = i; outputs[i].want_float = 1;}
ret = rknn_outputs_get(ctx, io_num.n_output, outputs.data(), nullptr);if (ret != RKNN_SUCC) { throw std::runtime_error("rknn_outputs_get failed");}
// 使用 outputs[i].buf 完成 Softmax、Top-K、解码或 NMS
rknn_outputs_release(ctx, io_num.n_output, outputs.data());want_float=1 使用方便,但 Runtime 需要反量化/转换。高性能检测后处理可以保持 INT8 输出,并用查询到的 scale、zero point 自行计算:
float_value = (quant_value - zero_point) × scale七、通用 API 的数据路径
CPU buffer→ rknn_inputs_set 复制/转换→ NPU 推理→ rknn_outputs_get 复制/转换→ CPU 后处理它的优点是简单可靠,适合作为正确性基线;缺点是高清、多路或高帧率时,输入输出搬运可能占据明显时间。
计时应至少拆成:
读取/解码 | 预处理 | 输入提交 | rknn_run | 输出获取 | 后处理 | 绘制/编码八、零拷贝 C API
零拷贝使用绑定到 Context 的 Tensor Memory:
rknn_tensor_mem* input_mem = rknn_create_mem(ctx, input_attr.size_with_stride);
rknn_tensor_mem* output_mem = rknn_create_mem(ctx, output_attr.size_with_stride);
if (!input_mem || !output_mem) { throw std::runtime_error("rknn_create_mem failed");}
rknn_set_io_mem(ctx, input_mem, &input_attr);rknn_set_io_mem(ctx, output_mem, &output_attr);
// 把输入写入 input_mem->virt_addrrknn_run(ctx, nullptr);// 从 output_mem->virt_addr 读取结果
rknn_destroy_mem(ctx, input_mem);rknn_destroy_mem(ctx, output_mem);完整函数声明和结构体以当前 release 的 RKNNRT C API 文档与头文件 为准。
最容易踩坑的 stride
width 是逻辑宽度,w_stride 是实际每行对齐宽度。二者不同时不能把整张图片直接连续复制:
for (int y = 0; y < height; ++y) { std::memcpy( dst + y * width_stride * channels, src + y * width * channels, width * channels );}分配大小应按 size_with_stride 或 API 查询值。使用外部 FD/物理内存时,还要按照 BSP/SDK 要求处理 Cache flush/invalidate。
“零拷贝”表示减少 RKNN 路径上的额外搬运,不代表摄像头、RGA、NPU、显示之间绝对没有任何数据移动。
推荐迁移顺序
- 通用 API 输出完全正确;
- 保存同一输入的通用 API 输出;
- 改用 Runtime 分配的 Tensor Memory;
- 对比两条路径输出;
- 再导入 DMA-BUF/外部 FD;
- 最后进行 RGA、摄像头和显示链路内存复用。
九、交叉编译和板端运行
官方 Model Zoo 已提供构建脚本,MobileNet 示例:
cd rknn_model_zooexport GCC_COMPILER=<AARCH64_TOOLCHAIN_PREFIX>./build-linux.sh -t rk3588 -a aarch64 -d mobilenet
adb push install/rk3588_linux_aarch64/rknn_mobilenet_demo/ /userdata/adb shellcd /userdata/rknn_mobilenet_demoexport LD_LIBRARY_PATH=./lib./rknn_mobilenet_demo model/mobilenetv2-12.rknn model/bell.jpg如果在板端原生编译,要确认 OpenCV、CMake 和 RKNN 头文件/库都是 AArch64 版本。交叉编译报 invalid ELF 通常表示链接到了错误架构的库。
十、把 Demo 重构成可维护工程
推荐目录:
src/├─ engine/│ ├─ nn_engine.hpp # 与硬件无关的引擎接口│ └─ rknn_engine.cpp # RKNPU2 实现├─ tasks/│ ├─ classification/ # Softmax、Top-K│ └─ detection/ # 解码、阈值、NMS├─ preprocess/ # resize/letterbox、颜色和 layout├─ types/ # Tensor、Detection、错误码├─ utils/ # 日志、计时、模型文件读取└─ main.cpp # 参数和业务流程分层原则:
- 引擎层只负责模型和 Tensor,不知道“安全帽”或“公交车”;
- 任务层实现分类/检测后处理,不直接管理 RKNN Context;
- 业务层管理摄像头、队列、告警和输出;
- 使用 RAII、
std::vector、智能指针明确资源所有权; - 错误码区分模型加载、输入、推理、输出和后处理阶段。
十一、多 Context 和线程
一个高吞吐视频系统通常是流水线:
解码线程→ 有界输入队列→ 多个推理 worker(每个独立 Context)→ 有界结果队列→ 后处理/显示/编码线程注意:
- 不要让多个线程无保护地共享同一个 Context;
- 限制队列长度,避免延迟和内存无限增长;
- 为帧附带序号和时间戳;
- 明确实时模式是丢旧帧还是阻塞生产者;
- 统计吞吐的同时统计单帧端到端延迟;
- 退出时唤醒等待线程并释放全部 Context。
十二、本篇实践验收
- Lite2 能在板端复现 Toolkit2 的结果;
- 通用 C API 能正确查询 Tensor、推理和释放资源;
- 能解释
want_float、scale/zero point 和数据复制; - 零拷贝结果与通用 API 在容差内一致;
- 工程按引擎、任务、预后处理和业务分层;
- 性能报告能区分预处理、NPU、后处理和端到端时间。
Some information may be outdated