098、NCNN/RKNN/OpenVINO 三平台部署对比:从模型转换到 C++ API 推理
098、NCNN/RKNN/OpenVINO 三平台部署对比:从模型转换到 C加加 API 推理
上周五凌晨两点,我在调试一个RK3588上的YOLOv8-seg模型,发现分割掩码输出全是0。折腾了三个小时,最后发现是RKNN的量化校准集里忘了加背景类样本——这种低级错误,说出来都丢人。但正是这种血泪教训,让我决定把NCNN、RKNN、OpenVINO这三个平台的部署经验系统整理一下。今天这篇笔记,就当是给自己挖坑填坑的记录。
模型转换:三个平台,三种脾气
先说NCNN。这玩意儿对PyTorch的ONNX导出要求最苛刻。我习惯在export.py里加这么一段:
# 这里踩过坑:NCNN不支持动态batch,必须固定torch.onnx.export(model,dummy_input,"model.onnx",opset_version=11,# 别用12以上,NCNN解析会炸input_names=["images"],output_names=["output"],dynamic_axes=None# 必须None,否则NCNN转的时候报shape不匹配)转NCNN时有个坑:ncnn2int8工具对量化校准集的数量有硬性要求,少于100张图直接报错。我一般准备200-300张,覆盖各种光照和角度。校准集图片尺寸必须和模型输入一致,别想着让工具自动resize——它只会粗暴地拉伸,导致精度崩盘。
RKNN的转换相对友好,但有个隐藏雷区:量化模式选择。RKNN支持asymmetric_quantized-u8和dynamic_fixed_point-i8两种。实测YOLOv8用前者在RK3588上掉点0.5-1个mAP,后者几乎不掉点。但后者对激活值范围敏感,需要在rknn.config里手动设置quantized_dtype='dynamic_fixed_point-i8',同时把quantized_algorithm设为normal而不是mmse——别问我为什么,RK的文档里写的是反的。
OpenVINO的转换最省心,mo.py一把梭。但有个细节:如果模型里有torch.chunk或torch.split操作,OpenVINO的IR中间表示会把它拆成多个Slice节点,推理时多出30%的延迟。解决办法是在导出ONNX前手动用torch.unbind替代,或者干脆在C++后处理里做切片。
推理引擎初始化:内存和时间的博弈
NCNN的Net类初始化,我习惯这样写:
ncnn::Net net;net.opt.use_vulkan_compute=false;// 别开,移动端兼容性差net.opt.use_bf16_storage=true;// 省内存,精度几乎无损net.load_param("model.param");net.load_model("model.bin");注意load_param和load_model的顺序不能反,否则会报param not loaded。这个错误信息极其误导人,我第一次遇到时以为是模型文件损坏,重下了三遍。
RKNN的初始化有个坑:rknn_init的第二个参数flags,如果传0,默认用CPU推理。要启用NPU必须传RKNN_FLAG_NPU_CORE_0或RKNN_FLAG_NPU_CORE_0_1。但别贪心全开,RK3588的NPU三核全开会发热降频,实测双核性能最优。
rknn_context ctx;intret=rknn_init(&ctx,model_data,model_size,RKNN_FLAG_NPU_CORE_0_1,nullptr);// 这里踩过坑:model_data必须保持有效直到rknn_destroy,不能提前释放OpenVINO的Core对象是线程安全的,但InferRequest不是。多线程推理时,每个线程必须创建自己的InferRequest,共享Core没问题。我见过有人把InferRequest当全局变量用,结果推理结果错乱,排查了两天。
前处理:数据排布的暗坑
NCNN要求输入数据是RGB顺序,但很多摄像头输出是BGR。别在C++里手动转换,用ncnn::Mat::from_pixels_resize的PIXEL_RGB2BGR标志位,它内部用NEON优化,比手写循环快5倍。
ncnn::Mat in=ncnn::Mat::from_pixels_resize(bgr_data,ncnn::Mat::PIXEL_BGR2RGB,src_w,src_h,target_w,target_h);// 别这样写:手动循环像素转换,慢且容易越界RKNN的输入要求更诡异:它期望的数据排布是NHWC,而PyTorch模型默认是NCHW。虽然RKNN转换工具会自动插入transpose,但推理时如果输入数据是NCHW格式,会多一次内存重排。我习惯在C++里直接构造NHWC的输入:
rknn_input inputs[1];inputs[0].index=0;inputs[0].type=RKNN_TENSOR_UINT8;inputs[0].size=target_h*target_w*3;inputs[0].fmt=RKNN_TENSOR_NHWC;inputs[0].buf=image_data;// 已经是HWC排布OpenVINO的输入是float32类型,需要做归一化。但别在循环里逐像素除255,用cv::Mat::convertTo转成float后,再调用cv::divide做批量除法,利用SIMD加速。
推理与后处理:性能瓶颈在这里
NCNN的Extractor对象每次推理都要创建,别复用。我见过有人为了省内存重复使用同一个Extractor,结果第二次推理时输出张量指针指向了错误地址。
ncnn::Extractor ex=net.create_extractor();ex.input("images",in);ncnn::Mat out;ex.extract("output",out);// 每次推理都重新create,别复用RKNN的rknn_run是同步的,但可以配合rknn_query查询NPU占用率。如果发现占用率超过80%,说明模型太大或batch太大,需要降频或切分。
OpenVINO的异步推理接口start_async和wait配合使用,能实现流水线。但注意:wait的超时时间别设太长,我一般设100ms,超时后主动丢弃当前帧,避免累积延迟。
后处理是性能大头。NCNN的输出是ncnn::Mat,访问元素用row指针:
float*ptr=out.row(i);// 别用at<float>(i, j),慢10倍RKNN的输出是void*,需要强转成float*。但注意:RKNN的输出数据排布是NCHW,而YOLO的检测头期望的是CHW,需要手动做一次维度重排。这个操作我放在NPU上做,用RKNN的rknn_set_io_mem指定自定义内存,避免CPU-GPU数据拷贝。
OpenVINO的输出是InferRequest::get_output_tensor,返回的是float*,可以直接用std::memcpy拷贝到自定义结构体。但别在每次推理时都get_output_tensor,这个调用有锁开销,在构造函数里获取一次指针,后续复用。
三平台性能对比:数字会说话
在RK3588上,同样的YOLOv8n模型,NCNN用CPU推理耗时45ms,RKNN用NPU双核推理耗时12ms,OpenVINO用GPU推理耗时18ms。但NCNN的CPU推理在树莓派4B上反而比OpenVINO快,因为OpenVINO的GPU驱动在ARM上优化不到位。
内存占用方面,NCNN最小,模型加载后约80MB;RKNN次之,约120MB(包含NPU驱动);OpenVINO最吃内存,约200MB,主要是IR中间表示的解析开销。
精度方面,三个平台在FP16模式下几乎无差异,INT8量化后RKNN掉点最少(0.3-0.5 mAP),NCNN次之(0.5-0.8 mAP),OpenVINO的INT8量化掉点最严重(1-2 mAP),除非用它的--data_type FP16配合--scale手动校准。
个人经验:选平台不如选工具链
如果你问我哪个平台最好,我会说:看你的目标硬件。NCNN适合ARM Linux和移动端,RKNN适合瑞芯微芯片,OpenVINO适合Intel平台。但真正决定开发效率的,是工具链的调试能力。
NCNN的ncnnoptimize工具能可视化模型结构,方便排查算子支持问题。RKNN的rknn_toolkit提供Python API,可以在PC上模拟推理,但模拟结果和真机有差异,别全信。OpenVINO的benchmark_app能自动测延迟和吞吐,但它的-d CPU和-d GPU结果差异巨大,别拿CPU的延迟去估算GPU性能。
最后说个血泪教训:无论用哪个平台,一定要在模型转换后做一次端到端的精度验证。我写了个脚本,用同一张图在PyTorch和部署平台上分别推理,对比输出张量的余弦相似度。低于0.99的,直接打回重转。这个习惯救了我无数次,尤其是在RKNN量化时,校准集选不好,相似度能掉到0.8以下。
部署不是终点,是另一个起点。每个平台都有自己的脾气,摸透了,它们就是你手里的工具。摸不透,它们就是你加班的理由。
