☰
RK3588双路视觉QoS优化:线程池优先级与NPU调度实战
2026/10/31 6:22:38 网站建设 项目流程

1. 双路视觉方案的整体架构与QoS设计动机

1.1 为什么双路视觉不是简单跑两个YOLO

香橙派5搭载的RK3588芯片,CPU部分是4核A76加4核A55的big.LITTLE架构,GPU是Mali-G610 MP4,NPU算力标称6TOPS。很多刚上手的朋友第一反应就是:既然有6TOPS,那我同时跑两路YOLOv5s不就行了?理论算力确实够,但实际跑起来你会发现帧率掉得厉害,延迟忽高忽低,甚至有一路会莫名其妙卡住。

问题的根源不在NPU算力,而在资源调度。两路视频流从MIPI CSI或者USB摄像头进来,经过V4L2采集、格式转换、缩放、推理、后处理、显示输出这一整条链路,每一环都在抢CPU时间片、内存带宽和NPU队列。如果两路任务优先级一样,操作系统调度器就会公平地轮流分配,结果就是两路都跑得不痛快。

我在实际项目里踩过这个坑:一路做目标检测,一路做车牌识别,结果车牌识别那路因为后处理复杂,经常把检测那路的帧给挤掉,导致检测画面出现跳帧。后来引入QoS严格优先机制,把检测路设为高优先级,车牌路设为低优先级,高优先级任务永远先拿到资源,低优先级任务用剩余资源,整个系统才稳定下来。

1.2 QoS严格优先到底解决什么问题

QoS(Quality of Service)在嵌入式视觉里不是网络那个QoS,而是任务服务质量保障。严格优先的意思是:高优先级任务一旦就绪,立即抢占CPU和NPU资源,低优先级任务必须让路。这跟Linux默认的CFS完全公平调度器是两码事。

CFS的设计目标是公平,每个任务分到的时间片差不多。但视觉任务有个特点——实时性要求不对称。比如安防场景里,人脸检测那路要求30fps稳定输出,晚一帧可能就错过最佳抓拍时机;而环境监控那路10fps就够了,偶尔丢一帧无所谓。如果用CFS,两路各跑20fps,人脸那路反而不达标。

严格优先配合线程池的优先级队列,就能实现:高优先级任务来了立刻执行,低优先级任务排队等着。NPU这边也是同理,RKNN的推理任务可以设置不同的优先级,高优先级的推理请求先提交给NPU驱动。

1.3 阶段四在整体教程中的位置

这个系列教程从环境搭建开始,到模型转换、单路推理、双路采集,阶段四就是解决双路并发时的资源竞争问题。前面阶段你可能已经跑通了单路YOLOv5s,帧率能到25-30fps,但加上第二路之后发现总帧率上不去,或者某一路延迟暴增。阶段四的核心任务就是:用QoS严格优先加线程池优先级队列,把两路视觉任务调教到各自满足实时性要求。

具体目标可以量化:高优先级路稳定25fps以上,端到端延迟低于80ms;低优先级路稳定15fps以上,延迟低于150ms。这个指标在RK3588上是完全可以做到的,关键看你怎么调度。

2. RK3588平台特性与线程池优先级设计

2.1 RK3588的CPU/GPU/NPU异构资源盘点

要把QoS做好,先得摸清家底。RK3588的CPU是4×Cortex-A76(最高2.4GHz)+4×Cortex-A55(最高1.8GHz),A76和A55各自有独立的L2缓存,共享L3。GPU是Mali-G610 MP4,支持OpenCL和OpenGL ES。NPU是自研的RKNN架构,支持INT8/INT16量化推理。

关键点在于:NPU推理是异步的。你提交一个推理任务,NPU在后台算,算完了通过中断通知CPU。这意味着CPU在等NPU结果的时候可以去做别的事,比如另一路的图像预处理。但如果两路都往NPU塞任务,NPU的硬件队列满了之后,后来的任务就得等。

我实测过,RK3588的NPU同时处理两路YOLOv5s(640×640输入,INT8量化),单路推理耗时约18ms,两路并发时如果队列管理不好,第二路可能要等30ms以上。所以NPU队列的优先级管理非常关键。

2.2 线程池的阻塞队列选择:为什么用优先级队列

线程池的核心是任务队列。常见的阻塞队列有ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue。前两个是FIFO,先提交先执行。PriorityBlockingQueue是优先级队列,任务按优先级排序,高优先级先出队。

在双路视觉场景里,我给高优先级路的每个处理任务打上优先级数值(比如10),低优先级路打上优先级数值(比如5)。线程池的工作线程从PriorityBlockingQueue里取任务时,自然先取优先级高的。这就实现了任务级的QoS。

但这里有个坑:PriorityBlockingQueue是无界的。如果低优先级任务生产太快,队列会无限增长,最后OOM。我的做法是配合信号量或者有界队列包装,限制队列最大长度。当队列满时,低优先级任务直接丢弃(丢帧),高优先级任务阻塞等待。这样保证高优先级路永远不会因为队列满而丢帧。

2.3 线程数量的确定:不是越多越好

线程池线程数怎么定?很多人拍脑袋写个8或者16。实际上要算。RK3588有8个核,但A76和A55性能差不少。视觉处理链路里,CPU密集型任务(如图像缩放、后处理NMS)适合放在A76上,IO密集型任务(如V4L2采集、显示输出)可以放在A55上。

我的经验公式是:CPU密集型线程数 = A76核心数(4)× 1.2,约5个;IO密集型线程数 = A55核心数(4)× 2,约8个。但两路视觉加起来,总线程数不要超过12个,否则上下文切换开销会吃掉收益。

实际配置时,我用了两个独立的线程池:一个高优先级池(4个线程,绑定A76),一个低优先级池(4个线程,绑定A55)。高优先级池只处理高优先级路的任务,低优先级池处理低优先级路。这样物理隔离,比单纯靠优先级队列更稳。

2.4 优先级反转问题与解决

优先级反转是QoS里的经典问题:高优先级任务等一个被低优先级任务持有的锁,而低优先级任务又被中优先级任务抢占,导致高优先级任务实际上在等中优先级任务。在视觉系统里,这个锁可能是NPU的访问互斥锁,或者内存池的分配锁。

解决办法有三种:优先级继承、优先级天花板、无锁设计。我在RK3588上用的是优先级继承互斥锁(PTHREAD_PRIO_INHERIT)。当高优先级线程等待低优先级线程持有的锁时,低优先级线程临时继承高优先级,避免被中优先级抢占。Linux的pthread_mutex支持这个属性,设置起来很简单:

pthread_mutexattr_t attr; pthread_mutexattr_init(&attr); pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT); pthread_mutex_init(&npu_mutex, &attr);

这样NPU访问锁就不会导致优先级反转了。实测下来,加了优先级继承之后,高优先级路的延迟抖动从±20ms降到了±5ms以内。

3. 双路视觉任务的优先级划分与代码实现

3.1 任务优先级的具体分配方案

两路视觉任务,我按业务重要性划分优先级。假设一路是主检测路(比如人脸/车辆检测),一路是辅助分析路(比如环境分类)。优先级分配如下表:

任务环节主检测路优先级辅助分析路优先级说明
V4L2采集8040采集不能丢帧,主路最高
图像预处理7035缩放、格式转换
NPU推理提交9030推理请求优先级
后处理NMS6025CPU密集型
显示输出5020显示可以稍等

Linux线程优先级范围是1-99,数值越大优先级越高。主检测路的NPU推理提交给到90,基本接近实时线程了。辅助分析路给30-40,属于普通优先级。

注意:不要把所有任务都设成99,那样等于没有优先级。要有梯度,让调度器有区分度。

3.2 线程池的优先级队列实现

我用C++11的std::priority_queue配合互斥锁和条件变量,手写了一个简单的优先级线程池。核心代码如下:

struct Task { int priority; std::function<void()> func; bool operator<(const Task& other) const { return priority < other.priority; // 大顶堆,高优先级先出 } }; class PriorityThreadPool { public: PriorityThreadPool(size_t threads) { for (size_t i = 0; i < threads; ++i) { workers.emplace_back([this] { while (true) { Task task; { std::unique_lock<std::mutex> lock(queue_mutex); condition.wait(lock, [this] { return stop || !tasks.empty(); }); if (stop && tasks.empty()) return; task = tasks.top(); tasks.pop(); } task.func(); } }); } } void enqueue(int priority, std::function<void()> func) { { std::unique_lock<std::mutex> lock(queue_mutex); if (tasks.size() >= max_queue_size) { // 队列满,低优先级任务丢弃 if (priority < 50) return; // 高优先级任务等待 while (tasks.size() >= max_queue_size) { condition.wait_for(lock, std::chrono::milliseconds(10)); } } tasks.push({priority, std::move(func)}); } condition.notify_one(); } private: std::vector<std::thread> workers; std::priority_queue<Task> tasks; std::mutex queue_mutex; std::condition_variable condition; bool stop = false; size_t max_queue_size = 32; };

这个实现里,max_queue_size设为32,超过之后低优先级任务直接return丢弃,高优先级任务等待。这样保证高优先级路永远不会因为队列满而阻塞。

3.3 线程绑定到特定CPU核心

光有优先级还不够,Linux调度器可能把高优先级线程调度到A55小核上,那就白搭了。所以要把高优先级线程绑定到A76大核。用pthread_setaffinity_np:

cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(4, &cpuset); // 绑定到CPU4(A76) CPU_SET(5, &cpuset); CPU_SET(6, &cpuset); CPU_SET(7, &cpuset); pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);

RK3588的CPU0-3是A55,CPU4-7是A76。高优先级线程绑到4-7,低优先级线程绑到0-3。这样物理隔离,互不干扰。

我实测过,不绑核的时候,高优先级路帧率波动在18-28fps之间;绑核之后稳定在26-28fps,抖动小了很多。

3.4 NPU推理任务的优先级提交

RKNN的推理接口本身不直接支持优先级,但可以通过队列管理来模拟。我维护了两个NPU任务队列:高优先级队列和低优先级队列。一个专门的NPU调度线程先检查高优先级队列,有任务就先提交高优先级的,高优先级队列空了才提交低优先级的。

void npu_scheduler_thread() { while (running) { // 先处理高优先级 if (!high_prio_queue.empty()) { auto task = high_prio_queue.pop(); rknn_run(ctx, &task.input, &task.output); task.callback(); } else if (!low_prio_queue.empty()) { auto task = low_prio_queue.pop(); rknn_run(ctx, &task.input, &task.output); task.callback(); } else { std::this_thread::sleep_for(std::chrono::milliseconds(1)); } } }

这样高优先级路的推理请求永远优先提交给NPU。实测下来,高优先级路的推理等待时间从平均12ms降到了3ms以内。

4. 实操过程与关键环节实现

4.1 环境准备与依赖安装

先确认系统环境。我用的是香橙派5官方Ubuntu 22.04镜像,内核5.10。RKNN Toolkit2版本1.5.2,NPU驱动版本0.9.6。

# 检查NPU驱动 cat /sys/kernel/debug/rknpu/version # 输出:RKNPU driver version: 0.9.6 # 安装依赖 sudo apt update sudo apt install -y cmake g++ libopencv-dev libv4l-dev

OpenCV用系统自带的4.5.4版本就够用,不需要自己编译。如果要更好的性能,可以编译OpenCV 4.8并开启NEON优化,但编译时间较长,新手先用系统包。

4.2 双路摄像头采集配置

两路摄像头,一路用MIPI CSI,一路用USB。MIPI CSI的延迟更低,给高优先级路;USB延迟稍高,给低优先级路。

MIPI CSI配置:

# 查看CSI设备 v4l2-ctl --list-devices # 通常MIPI CSI是 /dev/video0 或 /dev/video1 # 设置格式:1080P MJPG v4l2-ctl -d /dev/video0 --set-fmt-video=width=1920,height=1080,pixelformat=MJPG v4l2-ctl -d /dev/video0 --set-parm=30

USB摄像头:

v4l2-ctl -d /dev/video2 --set-fmt-video=width=1280,height=720,pixelformat=YUYV v4l2-ctl -d /dev/video2 --set-parm=30

注意:MIPI CSI和USB摄像头的像素格式可能不同,MIPI CSI常用NV12或MJPG,USB常用YUYV或MJPG。预处理时要统一转成RGB888再送给YOLOv5s。转换用OpenCV的cvtColor,或者用RGA硬件加速。

4.3 RGA硬件加速图像预处理

RK3588有个RGA(Raster Graphic Acceleration)硬件,专门做图像缩放、格式转换、旋转。用RGA比CPU做快很多,而且不占CPU资源。

#include <rga/RgaApi.h> // 将NV12缩放到640x640并转RGB888 rga_info_t src, dst; memset(&src, 0, sizeof(rga_info_t)); src.fd = -1; src.virAddr = nv12_data; src.mmuFlag = 1; src.format = RK_FORMAT_YCbCr_420_SP; memset(&dst, 0, sizeof(rga_info_t)); dst.fd = -1; dst.virAddr = rgb_data; dst.mmuFlag = 1; dst.format = RK_FORMAT_RGB_888; im_rect src_rect = {0, 0, src_width, src_height}; im_rect dst_rect = {0, 0, 640, 640}; imcheck(&src, &dst, &src_rect, &dst_rect); improcess(&src, &dst, &src_rect, &dst_rect);

RGA处理1080P到640×640的缩放加格式转换,耗时约2-3ms,比CPU快5倍以上。而且RGA是独立硬件,不跟CPU抢资源。

4.4 YOLOv5s RKNN模型加载与推理

模型转换在之前的阶段已经做过了,这里直接加载RKNN模型。注意:两路视觉最好用两个独立的RKNN上下文,避免上下文切换开销。

rknn_context ctx_high, ctx_low; // 加载模型 int ret = rknn_init(&ctx_high, model_data, model_size, 0, NULL); ret = rknn_init(&ctx_low, model_data, model_size, 0, NULL); // 设置NPU核心绑定(RK3588有3个NPU核心) rknn_set_core_mask(ctx_high, RKNN_NPU_CORE_0); rknn_set_core_mask(ctx_low, RKNN_NPU_CORE_1);

RK3588的NPU有3个核心,可以分别绑定。高优先级路绑核心0,低优先级路绑核心1,核心2留给系统或其他任务。这样两路推理物理隔离,不会互相抢NPU核心。

实测数据:单核跑YOLOv5s 640×640 INT8,推理耗时约18ms;双核各跑一路,每路还是18ms左右,没有明显下降。如果两路都绑同一个核心,每路耗时涨到28ms以上。

4.5 完整的主循环与QoS调度

主循环里,采集线程、预处理线程、推理线程、后处理线程、显示线程各司其职,通过优先级队列传递数据。

// 高优先级路主循环 void high_prio_pipeline() { while (running) { // 1. 采集(优先级80) cv::Mat frame = capture_high(); // 2. 预处理(优先级70) pool_high.enqueue(70, [frame] { cv::Mat rgb = preprocess(frame); // 3. 推理(优先级90) pool_high.enqueue(90, [rgb] { npu_high_queue.push(rgb); }); }); } } // 低优先级路主循环 void low_prio_pipeline() { while (running) { cv::Mat frame = capture_low(); pool_low.enqueue(35, [frame] { cv::Mat rgb = preprocess(frame); pool_low.enqueue(30, [rgb] { npu_low_queue.push(rgb); }); }); } }

显示线程从两个结果队列取结果,高优先级结果先显示。如果低优先级结果积压,直接丢弃旧帧,保证显示的是最新画面。

4.6 性能实测数据

我在香橙派5上跑了完整测试,两路都是1080P输入,YOLOv5s 640×640 INT8推理。结果如下:

指标高优先级路低优先级路
采集帧率30fps30fps
推理帧率27fps18fps
端到端延迟65ms120ms
CPU占用35%25%
NPU占用45%30%

高优先级路稳定27fps,延迟65ms,满足实时要求。低优先级路18fps,延迟120ms,也在可接受范围。两路加起来CPU占用60%,NPU占用75%,还有余量。

对比不加QoS的情况:两路都是22fps左右,延迟都在100ms以上,高优先级路不达标。加了QoS之后,高优先级路提升了5fps,延迟降低了35ms。

5. 常见问题与排查技巧实录

5.1 高优先级路帧率不达标怎么办

先查NPU是不是瓶颈。用cat /sys/kernel/debug/rknpu/load看NPU负载,如果持续100%,说明NPU算力不够。解决办法:降低输入分辨率到416×416,或者换YOLOv5n模型。YOLOv5n的算力需求只有YOLOv5s的40%左右,帧率能提升一倍。

如果NPU负载不高但帧率还是上不去,查CPU绑核。用taskset -cp <pid>看线程绑在哪个核上。如果高优先级线程跑在A55上,那肯定慢。重新绑到A76。

还有一个隐蔽问题:内存带宽。两路1080P图像同时读写,DDR带宽可能吃紧。用cat /sys/class/devfreq/dmc/load看DDR负载。如果超过80%,考虑降低图像分辨率或者用RGA直接处理NV12,减少内存拷贝。

5.2 低优先级路完全没输出怎么办

这是优先级设置太极端了。高优先级路把CPU和NPU全占了,低优先级路饿死。解决办法:给低优先级路留最低资源配额。比如NPU调度线程里,每处理3个高优先级任务,强制处理1个低优先级任务。

int high_count = 0; while (running) { if (!high_prio_queue.empty() && high_count < 3) { // 处理高优先级 high_count++; } else if (!low_prio_queue.empty()) { // 处理低优先级 high_count = 0; } }

这样低优先级路至少能拿到25%的NPU时间,不会完全饿死。

5.3 延迟抖动大怎么排查

延迟抖动通常来自锁竞争或者内存分配。先检查有没有用malloc/free在热路径上。YOLOv5s的后处理NMS会频繁分配vector,建议用内存池预分配。

// 预分配内存池 std::vector<cv::Mat> mat_pool(10); for (auto& m : mat_pool) { m.create(640, 640, CV_8UC3); }

另一个抖动源是NPU驱动的中断处理。如果NPU中断被其他任务延迟响应,推理结果回调就会晚。可以设置NPU中断的CPU亲和性,绑到A76上。

5.4 常见问题速查表

现象可能原因排查方法解决措施
高优先级路帧率低NPU瓶颈查看NPU负载降分辨率或换小模型
高优先级路帧率低线程跑在小核taskset查看绑核重新绑到A76
低优先级路无输出资源被抢占查看线程状态设置最低资源配额
延迟抖动大锁竞争perf查看锁等待用优先级继承锁
延迟抖动大内存分配查看malloc调用用内存池预分配
两路互相干扰NPU核心共享查看核心绑定分别绑不同NPU核心
画面撕裂显示不同步查看显示线程加垂直同步或丢帧

5.5 独家避坑经验

第一个坑:不要用OpenCV的VideoCapture读MIPI CSI。OpenCV的V4L2后端对MIPI CSI支持不好,经常花屏或者卡死。直接用V4L2的ioctl接口,或者用RKMPP硬件解码。我一开始用VideoCapture,调了两天才发现是OpenCV的锅。

第二个坑:RKNN的输入内存要对齐。RKNN要求输入数据地址按16字节对齐,不对齐会报错或者性能下降。用posix_memalign分配对齐内存。

第三个坑:线程优先级设置要在root下。普通用户不能设置实时优先级(1-99),只能设0。所以程序要么用sudo跑,要么给可执行文件加cap_sys_nice权限。

sudo setcap cap_sys_nice+ep ./your_program

第四个坑:NPU核心绑定不是万能的。RK3588的NPU三个核心共享L2缓存,如果两路模型都很大,缓存会互相踢。实测YOLOv5s两路绑不同核心,性能提升约15%;但如果换成YOLOv5m,提升就不明显了,因为缓存不够。

第五个坑:显示输出用DRM比X11快。如果跑在桌面环境下,X11的合成器会增加延迟。建议用DRM直接输出,或者跑在无桌面的console模式下。我用DRM输出,显示延迟从30ms降到了8ms。

6. 进一步优化方向与扩展思路

6.1 动态优先级调整

固定优先级在场景不变时没问题,但如果业务需求变了,比如低优先级路突然需要提高帧率,就得改代码重新编译。更好的做法是动态优先级:根据队列长度或者延迟指标自动调整优先级。

比如低优先级路的队列积压超过10帧,自动把优先级从30提到50,等队列降到5帧以下再降回去。这样既保证高优先级路,又不会让低优先级路彻底饿死。

6.2 多路扩展

两路调通了,三路四路也是同样的思路。但要注意NPU只有3个核心,超过3路就得共享核心。这时候优先级队列更重要,要确保高优先级路永远先拿到NPU。

CPU这边8个核,每路视觉大概需要2-3个核,理论上最多支持3-4路。再多就得考虑用RK3588的多个NPU核心并行,或者换更高算力的平台。

6.3 与ROS2的QoS机制结合

如果项目用ROS2,可以直接用ROS2的QoS策略。ROS2支持可靠性、持久性、历史记录、深度等QoS配置。把高优先级路的topic设为RELIABLE,低优先级路设为BEST_EFFORT,底层DDS会自动处理优先级。

不过ROS2的QoS偏网络通信,跟CPU/NPU调度是两回事。我的做法是ROS2 QoS管通信,线程池优先级管计算,两层配合。

6.4 功耗与散热的平衡

香橙派5跑双路视觉,功耗大概8-10W,不加散热片的话RK3588会降频。降频之后A76从2.4GHz降到1.8GHz,性能掉25%。所以散热一定要做好,至少加个铝散热片,最好加个小风扇。

我用红外测温枪测过,不加散热片跑10分钟,RK3588表面温度到85度,开始降频。加了散热片加风扇,稳定在55度,不降频。

6.5 模型轻量化与QoS的配合

如果NPU实在扛不住两路YOLOv5s,可以考虑把低优先级路换成YOLOv5n或者YOLOv8n。YOLOv8n在RK3588上推理耗时约12ms,比YOLOv5s的18ms快不少。高优先级路保留YOLOv5s保证精度,低优先级路用YOLOv8n保证速度,这样两路都能达标。

模型轻量化还有一招:输入分辨率降到416×416。YOLOv5s在416×416下推理耗时约10ms,比640×640快近一倍。精度会掉一些,但很多场景够用。

6.6 实际项目中的取舍

最后说点实在的。QoS严格优先不是银弹,它解决的是资源竞争问题,但前提是资源总量够用。如果NPU算力差太多,再怎么调度也变不出算力来。所以项目初期就要算好账:两路YOLOv5s需要多少NPU算力,RK3588能不能扛住。扛不住就降模型、降分辨率、降帧率,或者换平台。

我在实际项目里,一般是先跑个基准测试,单路能到多少fps,然后乘0.7估算双路性能。如果双路估算值满足需求,再上QoS优化;如果不满足,先改模型。这个顺序不能反,否则调QoS调到死也达不到目标。

另外,QoS严格优先会导致低优先级路的体验下降,如果产品对两路要求差不多,那就别用严格优先,用加权公平队列更合适。技术选型要看业务,不能为了用而用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询