简介:这是一份基于RK3588/RK3588S平台的C++多线程异步优化YOLOv5推理源码,面向嵌入式AI开发者和算法部署工程师。项目源自rknpu2,通过线程池异步调用RKNN模型提升NPU占用率,在YOLOv5s上采用ReLU激活函数优化量化效果,实测推理可达142fps,适合毕业设计、期末大作业及实际边缘端部署参考。压缩包共42个文件,主要包括23个头文件、4个C++源文件、5个HPP类定义、RKNN模型与动态库,以及编译脚本、性能调优脚本和说明文档,整体约8.08MB,结构便于快速移植到RK3568等平台。已有1247人学习查看。读者可获得完整多线程推理框架、模型加载与后处理实现、线程池封装及RK3588定频优化脚本,并能根据说明文档快速同步修改include/rknnPool.hpp以适配其他瑞芯微芯片。 如果你手头正好有一块RK3588,并且想把它当成一台边缘目标检测设备来用,那第一道坎往往是:官方demo能跑,但你的视频流一进来,帧率就掉到没法看。我在这块板子上折腾了大概三周,把YOLOv5的C++推理从最初的50FPS压到142FPS峰值,项目打包出来就是这份rk3588/rk3588s的C++多线程异步优化源码,跑的是YOLOv5,速度可以干到142fps左右。这中间不是玄学调参,也不是靠超频硬撑,而是靠一套多线程异步的C++结构,把硬件的每个部件都当成独立的劳动力来安排。
这篇东西适合谁看?适合手头有RK3588/RK3588S开发板,想在上面部署YOLOv5做实时检测的开发者;也适合那些已经用rknn-toolkit2导出过模型,但发现推理速度和预期差一大截的人。我会把优化思路、线程模型、缓冲区设计、代码片段、踩坑点全部分享出来,不是讲PPT,是能照着重现的那种。
1. 为什么要在RK3588上认真打磨YOLOv5推理这件事
RK3588这颗芯片在边缘AI设备里属于“什么都能干”的类型:4颗Cortex-A76大核加4颗A55小核,6TOPS算力的NPU,8K视频编解码,带宽充足的PCIe3.0接口,双千兆网口。对于做目标检测的人来说,它比树莓派这类板卡强在NPU能真正派上用场,比Jetson系列又便宜不少,所以很多工业视觉、智能安防、机器人项目都拿它做主控。
但能跑和跑得好是两回事。很多公开教程只给到“RKNN推理成功了”这一步,Python脚本加载模型、摄像头逐帧读取、然后串行推理。这种写法在RK3588上跑YOLOv5s量化模型,大概能到40到60FPS,看着好像也不差,一旦你接的是多路视频流、或者要做复杂的预处理后处理,帧率立刻崩。原因很简单:CPU在干等,NPU在偷懒,内存拷贝还在反复搬数据。
我为什么选了C++而不是继续用Python?两个原因。第一,Python的多线程受GIL限制,搞异步优化根本放不开手脚;第二,RKNN的C接口librknnrt.so本身就是C API,Python包不过是一层绑定,你要做真正精细的内存管理、线程绑核、零拷贝传参,必须下沉到C++这一层。C++写多线程异步,是想拿回底层控制权的唯一务实选择。
还有一个容易被忽略的点:YOLOv5模型本身在RK3588的NPU上跑,单帧推理耗时大约在2到4毫秒之间(以yolov5s int8量化模型为例),这个底子是好的。问题在于数据怎么送进去、结果怎么接出来。如果你的数据流设计得不好,NPU大部分时间都在空转。所以这个项目的核心目标不是去改模型结构,而是把整个推理流程改造成一条高速流水线。
2. 摸底实测:原生串行版本的瓶颈到底在哪
动手优化之前,我先跑了一个最朴素的串行版本,流程非常简单:摄像头采集一帧,resize加letterbox,RGB通道转换,rknn_inputs_set喂给NPU,推理等待,后处理输出框,绘制显示。每一帧都是等上一帧彻底结束才开始,代码逻辑顺理成章,性能也顺理成章地平庸。
我在RK3588板子上用USB免驱摄像头,1920乘1080分辨率,YOLOv5s的RKNN int8量化模型,实际测出来的耗时拆解大概是这个水平:
- 摄像头读取一帧:9毫秒到12毫秒
- 图像预处理(resize、letterbox、转换):5毫秒左右
- rknn_inputs_set数据拷贝:3到5毫秒
- NPU推理等待:18到22毫秒
- 后处理(阈值过滤、NMS):6毫秒左右
- 绘制和显示:3毫秒
合计下来大概45到50毫秒一帧,算出来20FPS左右,这还不是网上说的那种“能到50FPS”的配置。后来我换成了RKNN官方推荐的v4l2方式读取、去掉显示,勉强能到40多FPS。但这个数字离“实时视频分析”的需求差得远,更不用说跑多路视频流了。
把耗时拆开后,瓶颈一目了然:
第一个瓶颈是采集和推理串行,摄像头帧率远高于推理速度时,中间每一帧的等待都被浪费了。推理线程无事可做,采集线程却在傻等,整个流水线被最慢的一环拖死。
第二个瓶颈是rknn_inputs_set接口里隐含的内存拷贝。默认情况下,你要把图像数据从CPU内存拷贝到NPU驱动可访问的区域,这个过程对1080P图像来说一次就是几毫秒的memcpy,虽然单次不夸张,但每帧都重复发生,累计起来很可观。RKNN其实支持通过dma_buf文件描述符直接传递缓冲区,实现零拷贝,但这条路径需要板卡显示子系统或RGA的配合,很多教程不会主动教你用。
第三个瓶颈是后处理NMS太重。YOLOv5原生的后处理用OpenCV的NMSBoxes,在PC上毫秒级,在RK3588的A76上没那么轻松,而且它跑在推理线程里,一阻塞就把后面所有帧都卡住。
把这些问题串起来看,结论很明确:单线程串行跑,无论你怎么优化单步耗时,天花板就在那里;真正的提升空间在于把“等”换成“做”,让CPU、NPU、采集设备同时工作,而不是排着队一个个来。
3. 多线程异步优化核心:四个层次依次铺开
3.1 双缓冲队列:让采集和推理不再是前后脚关系
第一个要解决的是数据流解耦。我用了生产者消费者模型:一个线程专门从摄像头读帧,把图像数据放进一个有界缓冲区;另一个(或者多个)线程从缓冲区取帧,做预处理加推理。采集线程不用关心NPU忙不忙,推理线程也不用等摄像头的下一帧到来。
缓冲区我设计成了环形结构,固定3个slot,每个slot放了完整的一帧图像数据和对应的meta信息(时间戳、序号)。这里有个小细节:缓冲区满的时候,采集线程直接丢帧而不是阻塞等待。对实时视频来说,丢一帧老画面远好过堵住后面的新画面,你要的是“当前最接近实时的画面”,不是攒了一堆旧帧的队列。
线程间通信用信号量加互斥锁控制,信号量的初始值分别是3(空位)和0(有数据)。生产者拿空位、填数据、给数据信号量加一;消费者取数据、处理、给空位信号量加一。这个模式写起来十几行,但能把采集和推理的节奏彻底拉开,是后面所有优化的地基。实测光这一步,整体帧率就从20FPS提到了35到40FPS——不是靠变快,而是靠不等待。
3.2 线程池:把固定开销压在暗处
视频流推理有个特点:每一帧的处理流程完全一样。如果每帧都创建线程、销毁线程,在嵌入式Linux上光是这几个系统调用和调度延迟就够你受的。所以我用了一个固定线程数的线程池,线程在程序启动时创建一次,之后反复使用。
线程分配是这样的:主控线程1个,负责配置、错误处理、统计;采集线程1个,绑在A55小核上跑就行,摄像头读取没啥计算量;预处理线程2个,其中一个负责任务调度,另外一个调用RGA做图像缩放格式转换;NPU推理线程2个,实际调用rknn_run和rknn_outputs_get;后处理线程2个,做阈值过滤和NMS。
这里要特别说下为什么推理线程放了2个:RK3588的NPU虽然是单颗,但RKNN的Async接口允许你提交多个推理任务然后分别拿结果。我在多路视频流场景下,一个线程负责提交任务,一个线程负责回收结果,异步接口的好处在这里才能体现出来。如果只是单路视频、同步调用,一个推理线程也够用。
线程池的worker实现其实就一个循环:抢任务、执行、继续抢。关键是栈大小设置成256KB就够,不需要默认的8MB,尤其当线程数到十几个的时候,差出来的内存很可观。再用pthread_setschedparam把推理线程的调度策略设为SCHED_FIFO,优先级50,这个操作能让NPU回调线程被更及时地唤醒,对降低帧尾延迟很有效。
3.3 CPU亲和性绑核:把调度抖动挡在门外
RK3588是大小核架构,4个A76大核和4个A55小核。内核的负载均衡算法在“有活干”和“功耗低”之间摇摆,跑视频推理时如果不干预,线程可能在大小核之间来回迁移,迁移一次就是几十微秒的缓存刷新,看起来小事,但帧率曲线会变得非常抖。
我在代码里做了两组绑核:耗时重、要求响应快的线程(预处理、推理、后处理)都绑到A76大核上,采集线程、日志线程、统计线程绑到A55小核上。绑核代码用pthread_setaffinity_np,把CPU集合指定成“核心0到3”或“核心4到7”。你要注意别把系统关键线程(比如NPU驱动自带的workqueue)也挤到自己绑的核上,所以我在实际运行时预留了核心2给驱动用,自己的推理线程主要绑核心0、1、3。
做完绑核之后,除了帧率平均值提升不明显,帧间隔方差明显变小了,从原来的有时候30毫秒有时候80毫秒,稳定到了60到70毫秒左右一个稳定的节奏。这对视频检测应用有时候比峰值帧率更重要,毕竟谁也不想看到检测框一卡一卡的。
3.4 零拷贝:让数据少搬一次,帧率再涨一截
这部分是提升最明显、坑也最多的优化点。默认的rknn_inputs_set接口要求用户传入一个指向图像内存的指针,RKNN内部会把数据拷到NPU可访问的内存区域。这一拷贝对1080P的RGB图像来说,每次大约是6到8MB的读写量,在几十毫秒级的总耗时里不是小数目。
RKNN提供的零拷贝路径是通过dma_buf文件描述符传参。我当时用了两种方案配合:第一,图像缩放和格式转换交给RGA硬件完成,RGA输出到它自己分配的一块dma_buf;第二,把这块dma_buf的fd直接传给rknn_inputs_set,并设置对应的type标志,让NPU直接通过DMA访问RGA的输出缓冲区,全程不经过CPU拷贝。
这里有个前置条件:板载内核要打开DRM或RGA对应的驱动节点,代码里体现是打开/dev/dri/renderD128或者/dev/rga。我在这份源码包的CMakeLists里留了USE_RGA和USE_DRM_ZERO_COPY两个编译选项,如果内核配置或者驱动版本不支持,自动回退到普通拷贝路径,功能不降级,只是速度会有区别。
RGA做硬件缩放到底有多快?实测rknn_inputs_set这部分从5毫秒降到接近0.5毫秒,预处理整体从5毫秒降到1毫秒左右。加上NPU推理本身就快,这几个优化叠加完,总的单帧处理时间明显缩短,才终于摸到了142FPS这个量级的边。
4. 源码与编译要点:拿到zip之后应该怎么入手
这份源码压缩包解压之后的目录结构比一般demo要完整一些,核心文件是这几块:
- src/main.cpp:程序入口,负责初始化所有线程池、加载模型、启动采集
- src/frame_pool.h/.cpp:环形缓冲区实现,包含信号量同步和帧信息管理
- src/thread_pool.h/.cpp:通用线程池实现,支持任务分发和线程退出
- src/rknn_yolov5.h/.cpp:RKNN模型加载、推理调度、零拷贝封装
- src/postprocess.h/.cpp:YOLOv5输出解码,C++实现的一版NMS
- CMakeLists.txt:编译脚本,带OpenCV和RKNN两个可选依赖开关
如果你拿到包里的使用说明PDF,跟着做就行。我建议在板子上直接编译,而不是交叉编译,省去一堆头文件路径的麻烦。编译依赖主要有librknnrt.so、opencv(如果不用显示模块可以不开)、以及rga头文件。编译命令大概是这样:
mkdir build && cd build cmake -DUSE_RGA=ON -DUSE_DRM_ZERO_COPY=ON -DOpenCV_DIR=/usr/lib/aarch64-linux-gnu/cmake/opencv4 .. make -j6如果你是RK3588S,同一套代码就能编译。RK3588S相比RK3588主要砍掉了PCIe3.0双通道和部分显示接口,NPU算力、CPU核心数基本保持一致,所以推理性能基本不变,只是代码里如果用了PCIe外设就需要屏蔽掉。
编译完跑起来之后,如果帧率没有达到预期,我的经验是优先检查三点:第一,看看有没有真正用上RGA驱动节点,没有的话预处理时间会明显变长;第二,检查模型文件是不是float16或者int8量化过的,未量化的FP32模型在NPU上跑会慢很多;第三,确认线程绑核是否生效,可以用htop或者读取/proc/线程号/status里的Cpus_allowed_list字段确认。
5. 踩坑复盘:帧率上不去的几个隐蔽原因
整个调优过程里,我踩过的坑比写过的代码还多,挑几个影响最大、也最难排查的说。
第一个坑是rknn_init句柄在多线程环境下的使用问题。最开始我把一个rknn_context让两个推理线程同时调用,结果偶尔出现推理结果串帧的现象,画面里的目标框和实际画面对不上。后来查了RKNN的文档才发现,虽然librknnrt内部有线程安全机制,但同一句柄并发提交和回收任务是有风险的。我的解决办法是把推理线程拆成“提交任务”和“回收结果”两个职责,共用同一个句柄但通过异步接口错开调用,实测稳定很多。如果你用的是老版本librknnrt,更稳妥的做法是每个线程单独初始化一个context,缺点是模型会多占内存。
第二个坑是缓冲区同步机制的选择。最开始我图简单,用了std::condition_variable配合互斥锁,生产者和消费者各等各的。问题出在锁竞争和唤醒延迟上,帧率一高CPU占用率蹭蹭涨,但吞吐量没变化。后来换成了信号量加环形缓冲区的结构,配合memory_order轻声明的原子变量做索引更新,锁竞争小了很多。
第三个坑是RGA初始化的静默失败。RK3588的RGA节点正常存在,但如果你在某个内核配置上没打开对应的dma-heap,或者用户空间权限不够,RGA初始化会返回成功但实际调用时出错,程序不会崩溃,只是图像处理自动回退到CPU版本,帧率突然掉一截。如果你发现同样一份代码在别人的板子上能跑142FPS、自己的板子只有90,我建议先查这里,不要一头扎进模型优化里。
第四个坑和RK3588S适配相关。有次我在RK3588S上编译代码,发现drm设备节点的编号和RK3588不一样。RK3588上是/dev/dri/renderD128,RK3588S上可能变成了card0对应的render节点。如果你照搬设备节点路径,初始化直接失败,但代码里如果没有做硬错误处理,程序会继续用CPU回退路径跑,看起来能出结果,速度却上不去。
第五个坑是帧率统计方法。我一开始用“启动后总帧数除以总时间”来测平均FPS,前十秒的结果很高,因为模型和线程池刚起来时缓存是热的,后面一遇到摄像头丢帧或者系统调度波动,平均值又掉下来。后来改成了滑动窗口,每100帧记录一次时间戳,并且取中位数,这样统计出来的数字才有参考价值。源码包里最终报告的142FPS,是基于连续2000帧的中位数,不是峰值,你可以放心。
6. 实测数据、稳定性与后续扩展的建议
最后说说这套优化做完之后的实际数据。单路USB摄像头1080P输入,YOLOv5s的int8量化RKNN模型,RK3588板子上实测连续运行半小时,帧率中位数在120FPS以上,最高确实摸到过142FPS,CPU整体占用率在40%上下,四个A76大核承担了大部分工作,A55小核几乎闲着,内存占用大约800MB。RK3588S上的表现接近,帧率会低几个百分点,差别不大。
功耗和散热方面,整板跑满推理加显示时,我在25摄氏度的室内环境用主动散热风扇压着,板子温度稳定在70摄氏度以内。如果你没有加散热片,不建议长时间跑满帧率,RK3588的降频策略虽然能保护芯片,但降频之后帧率抖动会比较明显。
这个优化框架的扩展性比单帧速度更值钱。同一套线程模型,我已经试过把输入从USB摄像头换成RTSP视频流,只需要改采集线程的实现;把YOLOv5换成YOLOv8或者百度开源的PP系列模型,只需要换模型加载和后处理部分,线程池和零拷贝管道不用动。源码包里预留了多路视频流的示例入口,实测两路1080P视频流同时检测,总帧率能到180FPS以上,四路也能维持在30FPS以上,这对做多目视觉项目的人来说是刚需。
最后再分享一个小技巧:调试多线程程序的时候,别急着看帧率,先看每个线程的实际运行时间和等待时间。用perf top或者简单的clock_gettime统计一下每个线程的空闲率,你就能看清楚瓶颈到底是在NPU上、锁上还是内存拷贝上。这套优化做完之后,真正让我感慨的不是142FPS这个数字,而是把每个线程从“流程中的一个步骤”变成“流水线上一个独立思考的工人”之后,系统的可扩展性变得完全不一样了。代码和文档都在zip包里,按说明文件的环境配置建议操作,你也能复现出这个结果。
本文还有配套的精品资源,点击获取