RK3588双路视觉的QoS严格优先调度实战:从CPU到NPU的全面优化
到阶段四这章,我琢磨着先交代一下背景,因为这个系列的读者跟着做到这里,应该能明显感觉到一件事:双路视觉方案在RK3588上"跑起来"不难,难的是"两路同时跑起来之后,系统还知道先伺候哪一路"。我做阶段四的起因很简单,双路摄像头都接入、两路yolov5s模型都转成rknn、推理线程框架也搭好了,可只要从路那一侧一启动,主路检测的延迟就从35ms左右一路涨到70ms以上,偶尔还飙到100ms。这不是算力不够,是调度出了问题。
阶段四要做的,就是给这套双路视觉方案加上一套"QoS严格优先"机制,让主路检测永远吃最好的资源,从路和其他业务(编码、录像、显示)只能在主路忙完之后捡剩下的资源用。这个思路不复杂,但落到RK3588这种芯片上,要调的地方比想象中多:CPU调度策略、NPU推理队列、内存带宽、帧缓冲策略,每一层都得打通。这篇文章就是把我实际操作的过程、配置方法、踩过的坑和实测数据完整记录下来。
1. 阶段四到底要解决什么问题:从两路抢芯片资源说起
1.1 到阶段三为止,双路方案已经具备了什么
按系列顺序走到这儿的读者,手里的环境通常是这样的:
- 香橙派5(RK3588这个级别)的板子上,双路MIPI CSI摄像头都已经接好,一路挂在CSI0,一路挂在CSI1,通过media链路把sensor的出图通道梳理干净,两路都能稳定拿到图像帧;
- yolov5s模型已经通过rknn-toolkit2转成了rknn格式,前面阶段里已经验证过单路推理、双路推理都能出结果;
- 检测线程有了自己的后处理逻辑,NMS、类别过滤、框坐标映射都放在了CPU侧处理;
- 如果我猜得没错,你还有一路显示或编码任务挂在旁边,比如把画面推到本地屏幕,或者把从路画面存成录像。
这些基础齐了之后,你大概率会遇到一个现象:单独跑主路检测时,一切正常,推理时间稳定在35到45毫秒之间;但一旦从路推理线程也开始往NPU提交任务,主路那个rknn_run的耗时就变得忽快忽慢,严重的甚至直接掉帧。这就是资源竞争。
1.2 为什么算力看起来够用,实际却互相拖累
RK3588这颗芯片的NPU实力不差,但两路yolov5s同时跑,已经不是"算力足不足"的问题了。咱们把竞争拆开看:
第一层是NPU队列竞争。NPU只有一个调度器,两路推理请求同时进来,谁先谁后、谁占多核谁占单核,如果没有明确配置,公平调度下大家轮流来,主路该等就得等。
第二层是CPU竞争。每路推理的后处理、图像预处理、坐标变换都在CPU上做,如果跑在同一个核或者同一组核上,主路的线程就会被从路的线程分走时间片。更麻烦的是,rknn_run本身还要在CPU上做数据搬运和结果回收,CPU调度延迟会直接影响推理延迟。
第三层是内存带宽竞争。双路图像帧都是几兆字节的YUV数据,RGB转换、缩放、再拷贝进NPU的输入缓冲区,再加上一路H.264编码,内存总线忙不忙直接决定NPU读数据快不快。
阶段四的QoS严格优先,核心就是把这四层竞争一一理顺,让系统在任何一个瞬间都知道:主路来了,其他都得让路。
1.3 QoS严格优先在视觉方案里的定义和验收标准
QoS这个词在网络设备里见得比较多,但在嵌入式视觉里,意思是一样的:对不同的数据流给不同的服务等级。严格优先意味着,当主路检测有请求时,资源直接切给主路,从路必须等待;主路空闲时,从路才能捡漏用资源。
我在做这一阶段前给自己定了几个验收指标,这里分享给你参考:
- 主路推理的p95延迟,在从路满载运行时,要保持和单路跑时接近,允许的浮动不超过15%;
- 从路推理允许变慢,但平均帧率不能掉到可用线以下,我给自己定的底线是10帧/秒;
- 主路的输入帧必须是新鲜的,不允许因为排队拿两三百毫秒之前的旧帧去推理;
- 测试过程中不允许出现CSI采集回调超时丢掉整帧的情况。
这些指标你不需要照抄,但建议动手之前先写清楚,不然调了两个小时,自己都不知道算不算调好了。
2. 严格优先不是一句口号:四个层面的优先级设计
2.1 第一层:CPU调度策略,谁先占核、谁能抢时间片
这层是基础,也是最容易被忽略的。Linux默认的CFS调度器,讲究的是公平,公平对严格优先来说恰恰是坏事。主路检测线程要告诉内核:我不是来参加公平竞赛的,我是有硬性延迟要求的。
具体做法是把主路的检测线程从普通调度策略切到实时调度策略。Linux下有两种实时策略:SCHED_FIFO和SCHED_RR,对嵌入式这种场景来说,SCHED_FIFO更合适,它的语义是:只要这个线程处于就绪状态,它就会抢占同一个核上所有非实时线程。配合优先级值(1到99),彻底把主路线程的地位抬高。
这块要注意一个原则:实时调度策略只给真正关键的那一两个线程,不要给整个进程。后面踩坑部分我会细说,提前打预防针:如果你把采集线程、显示线程、主路线程一股脑全设成SCHED_FIFO,系统真的会因为你来我往的实时抢占而崩溃式丢帧。
此外还有CPU亲和性的问题。RK3588有四个大核(A76)和四个小核(A55),主路检测应该锁在A76上,直接taskset绑死。从路检测和后台杂务锁在A55上,两边物理隔开,比单纯调优先级更稳。
2.2 第二层:NPU推理队列,直接告诉rknn谁更重要
CPU这层解决了,但如果两路推理请求到了NPU还是先来后到排队,主路照样被堵。好在rknn runtime在设计时就考虑过优先级,librknnrt的头文件里有一组初始化标志,核心的是几个带PRIOR的宏:RKNN_FLAG_PRIOR_LOW、RKNN_FLAG_PRIOR_MEDIUM、RKNN_FLAG_PRIOR_HIGH。
初始化rknn context的时候,把这个标志传到rknn_init的flags参数里,NPU侧的调度器就会按这个优先级给不同context分配执行机会。我的配置是主路context用RKNN_FLAG_PRIOR_HIGH,从路context用RKNN_FLAG_PRIOR_LOW,一高一低,严格优先的骨架就搭起来了。
这里要提醒一句,不同版本librknnrt的宏名可能略有差异,我在部分旧版本上见过的是RKNN_FLAG_PRIOR_XXX,但也有人遇到过名字带NATIVE_前缀的版本。拿到板子先翻一下头文件,确认实际宏名再写代码,不用硬背。
2.3 第三层:内存带宽与DMA通道,减少无声的竞争
内存带宽这层最不起眼,但对RGA和NPU的影响很大。我自己做个简单测试,主路推理时如果同时跑一路4K的h264编码,推理耗时能明显变长,原因就是编码器大量占内存总线带宽,NPU读输入数据要排队。
QoS严格优先到内存这层,能做的主要是"给关键道让路"而不是"锁带宽",毕竟Linux内核标准API没有直接给你设置内存带宽上限的接口。实际操作上我用了几个办法:
- 主路的图像预处理不放CPU上做,交给Rockchip的RGA硬件,用DMA通道做格式转换和缩放,不占用NPU逻辑和CPU时间片;
- 从路的图像处理帧率调低,在摄像头采集回调里直接丢弃多余帧,而不是每帧都送到NPU排队;
- 编码器的码率限制到一个合理值,我的从路录像用2Mbps的H.264,帧率15fps,编码线程绑定在A55小核上,减少对A76和内存通道的冲击。
这几个手段单个看不怎么起眼,合在一起效果非常明显,后面实测数据里有体现。
2.4 第四层:帧缓冲策略,永远消费最新一帧
这层很多人会忽略,但它是QoS体验的最后一公里。严格优先说得再好,如果主路推理线程拿到的还是几百毫秒前的旧帧,画面、追踪、报警通通滞后,等于白做。
主路的帧缓冲策略我用的是"最新帧覆盖":采集回调里拿到的帧直接覆盖上一次的帧,不排队。推理线程启动时就拿当前最新帧,然后立刻提交给rknn_run。这样即使NPU偶尔排队,推理结果对应的输入帧也是新鲜数据,不会因为积压导致越来越延迟。
从路的帧策略反而相反,它要的是保帧率,可以允许短暂排队,但也要设一个上限。我从路队列深度设为3帧,超过就直接丢弃最老的那一帧,绝不无限制积压。从路毕竟是捡漏的角色,丢几帧录像完全无所谓,不掉队才是关键。
这四个层面全打通,才叫真正的严格优先。只做其中一层,效果都要打折扣。
3. 落地步骤:从线程调度到NPU队列的完整配置
3.1 主路检测线程的实时调度与大核绑定
先说主路检测线程。我是在线程启动函数里写死配置的,用一个pthread_setschedparam加pthread_setaffinity_np的组合。
#include <pthread.h> #include <sched.h> void setup_main_detect_thread(pthread_t tid) { struct sched_param sp; memset(&sp, 0, sizeof(sp)); sp.sched_priority = 80; if (pthread_setschedparam(tid, SCHED_FIFO, &sp) != 0) { perror("main detect set sched failed"); } cpu_set_t set; CPU_ZERO(&set); for (int i = 4; i < 8; i++) { CPU_SET(i, &set); } pthread_setaffinity_np(tid, sizeof(set), &set); }A76大核在RK3588上的CPU编号逻辑上是4到7,A55是0到3。绑核的另一个好处是L1/L2缓存命中率更高,推理线程不会被迁移到别的核上导致缓存全部失效。优先级我给了80,这个数值不算极端,既能抢占普通线程,又不会跟内核实时任务纠缠太深,个人推荐在这个量级上下取。
主路线程的nice值顺便说一句,SCHED_FIFO下nice值已经不起作用了,实时调度线程不参与nice的优先级计算,所以不用额外设。
3.2 从路检测线程的低优先级与大核隔离
从路检测线程,我反着来:用SCHED_OTHER(就是普通调度),nice值调高一些,让它老老实实排在所有普通任务后面。从路绑定到A55小核上,彻底跟主路物理隔离。
void setup_slave_detect_thread(pthread_t tid) { struct sched_param sp; memset(&sp, 0, sizeof(sp)); sp.sched_priority = 0; if (pthread_setschedparam(tid, SCHED_OTHER, &sp) != 0) { perror("slave detect set sched failed"); } // 进程级的nice调整,在非实时调度下有效 setpriority(PRIO_PROCESS, 0, 10); cpu_set_t set; CPU_ZERO(&set); for (int i = 0; i < 4; i++) { CPU_SET(i, &set); } pthread_setaffinity_np(tid, sizeof(set), &set); }这里有个细节容易踩坑:setpriority设置的是进程级nice,多线程程序里如果用pthread的方式创建线程,setpriority会对整个进程生效,而不只是当前线程。所以从路线程的优先级调整,主要靠pthread_setschedparam的SCHED_OTHER参数,nice只是锦上添花。你要是发现setpriority之后主路也变慢,八成就是这里的问题。
3.3 NPU优先级:rknn_init的flags参数
NPU这层的配置在初始化阶段就定死。每个rknn context创建的时候传入不同的优先级标志。
#include "rknn_api.h" rknn_context g_main_ctx; rknn_context g_slave_ctx; // 主路模型,高优先级 int ret = rknn_init(&g_main_ctx, main_model_path, RKNN_FLAG_PRIOR_HIGH, NULL); if (ret != RKNN_SUCC) { printf("main ctx init failed: %d\n", ret); } // 从路模型,低优先级 ret = rknn_init(&g_slave_ctx, slave_model_path, RKNN_FLAG_PRIOR_LOW, NULL); if (ret != RKNN_SUCC) { printf("slave ctx init failed: %d\n", ret); }两个ctx一旦初始化完成,后面的rknn_run调用框架完全一样,不需要再传优先级。NPU调度器自己会按context的优先级属性做统筹。我实际测试下来,这个flag的作用非常直接,开启之前主路要从从路手里抢NPU,开启之后从路只要跟主路同时有任务,NPU会先执行主路context的任务。
如果你的runtime版本没有暴露优先级标志,还有个退路:把从路推理改成动态帧率,比如根据主路当前有没有在run来调度。但这个方案我觉得效果差不少,还是优先研究头文件确认标志位。
3.4 帧缓冲实现:主路最新帧覆盖、从路有界队列
主路这块我用一个带锁的"最新帧槽"就够了,不需要队列。
#include <mutex> #include <opencv2/opencv.hpp> std::mutex g_main_frame_mutex; cv::Mat g_main_frame; volatile bool g_main_frame_valid = false; // 采集回调里调用,只负责覆盖 void on_main_camera_frame(const cv::Mat& frame) { std::lock_guard<std::mutex> lock(g_main_frame_mutex); frame.copyTo(g_main_frame); g_main_frame_valid = true; } // 主路推理线程循环里调用 bool take_latest_main_frame(cv::Mat& out) { std::lock_guard<std::mutex> lock(g_main_frame_mutex); if (!g_main_frame_valid) { return false; } out = g_main_frame; g_main_frame_valid = false; return true; }我实际项目中因为图像帧比较大,用的是双缓冲+原子索引,避免每次copyTo。但如果你是第一次做,先用这个简单的版本跑通,等验证QoS有效,再优化拷贝开销。
从路的帧队列我直接用了固定长度3的环形缓冲。每帧进来如果队列满,就pop最老的再push新的。这个是有界队列的好处,它能帮从路在性能波动时不至于无限积压导致延迟失控。
3.5 配置完成后的验证方法
所有配置写完,开机,先别急着看推理效果,先验证线程的调度策略确实生效了。我用一套命令行组合:
# 先找到检测线程的TID ps -eLo pid,tid,cls,pri,ni,comm | grep detect # 假设主路TID是1234,从路是2345 chrt -p 1234 chrt -p 2345 taskset -c -p 1234 taskset -c -p 2345chrt输出里如果主路显示policy: SCHED_FIFO、priority: 80,说明配置成功。从路显示SCHED_OTHER、ni为10,也正常。taskset确认主路绑在4-7,从路绑在0-3,就说明CPU亲和性生效了。
NPU优先级有没有生效,命令行看不到,我用的是代码统计。在rknn_run前后各取一次clock_gettime,统计主路和从路的推理耗时时长。如果主路的耗时在从路满载时依然稳定,而只有从路变慢,说明NPU优先级通了。
如果你连着系统的perf工具,也可以看rknn相关线程在CPU上的调度延迟时间,能更直观。
4. 实测数据:QoS开关前后,主路和从路各发生了什么
4.1 测试环境与测试条件
先说明一下我测试的环境,方便你对比:
- 板卡:香橙派5,RK3588系列芯片,64GB存储版,运行官方debian内核;
- 摄像头:双路MIPI CSI,两路都是OV13850模组,分辨率1080p,输出30帧/秒;
- 模型:yolov5s,640x640输入,rknn量化版,rknn-toolkit2版本是1.5.2;
- 业务分配:主路做实时检测,用于屏幕显示和报警;从路做检测后只存Bounding Box信息和录像,走硬件H.264编码;
- 从路编码设置:1080p、H.264、2Mbps、15fps。
测试分三组:第一组什么都不做,保持默认调度;第二组只设置NPU优先级(就是只改rknn_init的flag);第三组把CPU调度、NPU优先级、帧策略全部打开。每组连续跑10分钟,统计推理耗时、p95延迟、掉帧数。
4.2 三组实验的对比数据
| 指标 | 默认调度 | 仅NPU优先级 | QoS全开 |
|---|---|---|---|
| 主路平均推理耗时 | 48.2ms | 36.1ms | 34.7ms |
| 主路p95延迟 | 87.5ms | 46.3ms | 39.8ms |
| 主路最大延迟 | 138ms | 58.1ms | 43.2ms |
| 主路丢帧数(10分钟) | 126帧 | 21帧 | 4帧 |
| 从路平均推理耗时 | 46.5ms | 71.2ms | 83.4ms |
| 从路有效帧率 | 18fps | 12fps | 10.5fps |
| 从路丢帧数 | 342帧 | 158帧 | 87帧 |
这套数据是在我从路满载的同时测的,主路的业务是报警和屏幕显示,所以主路实时性是第一位的。从路慢一点、丢一点帧无所谓,只要录像画面能保持基本的连续感。
4.3 从数据里能读出几个关键结论
第一个结论:只开NPU优先级,就已经能解决大部分主路延迟问题。主路平均耗时从48.2ms降到36.1ms,p95从87.5降到46.3,说明NPU队列竞争是主路延迟失控的最大因素,这个rknn_init标记得早点加上。
第二个结论:QoS全开之后,主路的最大延迟从58.1降到43.2,这个变化来自CPU调度和帧缓冲策略。CPU调度让主路线程不会在关键步骤上被从路线程抢时间片,帧缓冲策略保证推理线程永远拿的是最新帧,不会因为某个时刻排队久了,输出明显滞后的检测结果。
第三个结论:从路确实被牺牲了,这是严格优先的代价,也是预期内的事。没有QoS时两路差不多,QoS全开后从路平均耗时涨到83.4ms,帧率掉到10.5fps。如果从路的业务也是实时性要求很高的那种,严格优先就不能这么极端,要改成加权调度了。这个跟你的业务判断有关,技术上是完全可以调的。
5. 我踩过的几个QoS坑,以及怎么绕开
5.1 把所有线程都设成SCHED_FIFO,CSI回调直接丢帧
我刚开始调的时候,图省事,直接把整个进程里的线程全设成SCHED_FIFO,觉得既然要优先就大家一起优先。结果非常惨烈,双路摄像头的CSI采集回调频繁超时,每秒钟丢掉大量帧,主路画面直接卡成PPT。
原因是CSI回调线程变成实时高优先级后,会跟rknn的推理线程在CPU上互相抢占,而rknn_run执行时回调线程又必须等NPU完成才能拿新帧,两者形成了死锁式的循环等待。实时调度策略只会加重这个优先级反转导致的混乱。
正确的做法是把高优先级限定在"推理提交+结果回收"这个短临界区上,或者更简单点,只给推理线程设置SCHED_FIFO,采集回调保持普通优先级。采集回调代码本身就要短小精悍,只做拷贝帧、丢进队列这种极简操作,让它快点结束,没必要给实时优先级。
5.2 只调NPU优先级,不调帧缓冲,主路延迟还是会偶发跳变
第一次做完NPU优先级配置后我挺兴奋,实测平均延迟也很漂亮,36ms左右。但看p95和最大延迟时,还是有不小的毛刺。排查了半天发现是帧缓冲策略的问题:我主路用的是普通FIFO队列,采集速度和推理速度不匹配时,队列会积压,推理线程拿到的是排队前的旧帧,而且队尾越长,输入和输出之间的时间差越大。
这就是为什么帧缓冲策略必须跟上。改成最新帧覆盖后,那个偶发延迟毛刺基本消失了。严格优先不只是让NPU的执行顺序变得可预期,还要保证每个环节排队等待的人都是"最新鲜"的那个人。
5.3 两路都用PRIOR_HIGH,等于没有QoS
有个阶段我调试从路的时候,顺手也把从路的rknn_init flags改成了PRIOR_HIGH,想从路也"体验"一下高质量服务。结果两路都是高优先级,NPU还是交替执行,主路的延迟又回到了没有QoS时的水平。
这个坑背后的道理很简单:优先级的作用只有存在差异时才存在。一高一低,系统才有"谁先谁后"的依据。两路都高,等于排队规则变成插队规则,进去之后还是轮流。如果你有三路任务,就按业务重要性排HIGH、MEDIUM、LOW,确保有一个明确的梯度。
5.4 在主路推理线程里做printf日志,性能直接雪崩
这个坑严格说起来不是QoS配置本身,而是调优期间的自我干扰。我在验证主路推理耗时的时候,为了看得清楚,每帧都在推理线程里加printf输出时间戳。结果printf本身同步写终端,要等内核终端I/O完成,这个操作把主路线程的实时性彻底拖垮了,实测推理耗时从35ms变成800ms,看起来像是在慢动作播放。
后来我把日志改成累积到环形缓冲区,由另一个低优先级线程定期排空,才恢复正常。这个经验适用于所有做了实时优先级设置的线程:日志要异步、字符串拼接要放外面、调试打印不是你想加就能加的东西。
5.5 rknn runtime版本不同,优先级宏名可能不一样
最后提一句关于rknn版本差异的坑。RKNN官方对NPU优先级这块的接口并没有做到各版本完全一致,有的版本里flags就直接叫RKNN_FLAG_PRIOR_HIGH,有的版本还多出一组NATIVE_FP16模式之类扩展,命名可能变化。我在朋友的RK3588s板子上发现,他用的老版librknnrt根本不认PRIOR_HIGH这个宏,传进去直接初始化失败,返回错误码。
所以拿到新板子,第一时间去翻/usr/include/rknn_api.h或者你们SDK自带的头文件,搜PRIOR。别照着我的代码闷头抄,不同平台、不同runtime版本确实会有差别。
6. 阶段四做完之后的几点体会
严格优先这套配置做完到现在,我的双路方案已经连续跑了两周多,主路的延迟一直稳定在35到40毫秒,从路录像也从来没断过。回想一下,阶段四跟前面阶段最大的不同是,它不教你增加功能,而是教你管理资源。你手里的算力就那么多,NPU就一个,内存总线就一条,谁能用得好,谁的双路方案才是真正能落地的系统。
最后分享一个我觉得很实用的小经验:QoS配置完之后,不要急着收工,留一个环境变量开关在代码里,让QoS全开、NPU优先级only、默认调度三者可以一键切换。我后来验证从路模型换成yolov5n、甚至尝试yolov8时,都用这个开关快速对比过效果,你看哪个策略搭配新模型更好,一目了然。调试嵌入式视觉,这种"能随时切回去"的实验习惯,会帮你省很多时间。