做 RK3588 边缘 AI 最头疼的,往往不是单路模型跑不快,而是多路任务一起上时互相打架。这篇是这个系列的第 3 篇,前两篇我们把 RK3588 上的模型部署链路、单路视频的推理加速都过了一遍,这一篇专门聊聊“同源多任务调度”——也就是多路视频、多个检测需求同时存在时,怎么让它们共享一路数据源、共享同一个模型、共享同一块 NPU,还能各跑各的不掉链子。适合正在 RK3588 上做多路视觉检测、工业质检、安防布控类项目的朋友,看完可以直接拿去改到自己的框架里。
先交代一下背景。我手头这套设备是一块 RK3588 核心板,外接 4 路海康 IPC,需要同时跑人脸检测、安全帽未佩戴检测、明火/烟雾识别这几个业务。刚开始的做法非常朴素:每个业务各拉一路 RTSP 流,各加载各的模型,各开各的线程去推。结果一上电就发现,系统资源被迅速吃满,视频卡顿、NPU 占用率忽高忽低,内存也频繁告警。真正的问题不是某一个模型慢,而是多个任务在“同源”场景下没有统一调度,大家抢内存、抢解码器、抢 NPU,最后谁也跑不顺。
1. 先拆清楚“同源多任务”到底卡在哪
很多人一上来就想着怎么优化模型、怎么压帧率,但多任务场景下真正要解决的是资源编排问题。我先说清楚这里的“同源”指的是什么,以及 RK3588 这块芯片到底有哪些资源容易成为瓶颈。
1.1 边缘视觉任务的三种同源形态
我在实际项目里总结出“同源”至少有三层含义,这三层往往是叠加出现的。
第一层是数据源同源:4 路摄像头接入后,人脸检测、安全帽检测、明火检测都要消费这 4 路视频流。如果每个业务都独立拉流、独立解码,同一路视频会被解码好几遍,内存带宽和 CPU 消耗直接翻倍。正确做法是只拉一份码流、只解一次码,把结果放到公共帧池里,所有业务按需取帧。
第二层是模型同源:很多时候多个业务复用的是同一个基础模型。比如用同一个 YOLOv5s 做检测,只是不同业务对输出类别的过滤和后处理不一样。这时候完全没有必要加载 3 份模型、占 3 份内存、排 3 个推理任务,而是加载一次,多个业务共用同一个 RKNN context,靠调度器分配推理时间片。
第三层是资源同源:无论业务怎么拆,最终处理器、内存带宽、NPU 算力、解码器这些硬件资源都是同一套。RK3588 虽然有 3 个 NPU 核心,但依然不是“无限并发”的,更不能让每个任务都觉得自己独占 NPU。资源同源决定了多任务调度是必然要做的,而不是可选项。
1.2 RK3588 的家底够不够用
RK3588 的参数大家都很熟:CPU 是 4 个 A76 大核加 4 个 A55 小核,GPU 是 Mali-G610 MC4,NPU 标称 6 TOPS(INT8),内置 3 个 NPU 核心,还带 VPU,支持 8K 级别的视频硬解。单看数据很唬人,但落到真实项目里要清醒一点:6 TOPS 的算力其实相当有限。
拿最常见的 YOLOv5s 来说,如果输入分辨率是 640x640,INT8 量化后在 RK3588 上推理一次大概 15~25ms,具体取决于模型结构、量化精度和散热状态。这意味着 NPU 满负荷跑,每秒也就处理 40~60 帧。4 路视频如果每路都要求 25 帧,光一个模型就把 NPU 全部吃掉了,更别说其他任务。所以这类项目必须学会做“减法”:缩减输入分辨率、控制检测帧率、共享模型参数,以及最重要的——用调度去保证关键业务的优先级。不要指望硬件堆得越高越好,RK3588 的定义是“够用但必须精打细算”的边缘设备,调度的价值就在这里。
2. 数据通路设计:让一份视频流喂饱所有任务
多任务调度不是从推理才开始,而是从摄像头接入那一刻就要规划好。同一个视频源只有一份,但消费它的业务可能有四五个,这个阶段如果设计不好,后面调什么都白搭。我的做法是“统一接入、集中分发、零拷贝取帧”。
2.1 统一拉流与硬解码
4 路海康 IPC 通过 RTSP 协议接入。项目里不建议每个业务各自拉流,而是用一个单独的接入模块负责所有视频流。这个模块创建 4 个拉流线程,每个线程用 FFmpeg 的 API 去拉一路流,也可以通过命令行ffmpeg -rtsp_transport tcp -i rtsp://user:pass@ip:554/Streaming/Channels/101 -frames 1 out.jpg先验证流的可用性,但正式代码里还是推荐用 libavformat/libavcodec 做库调用,把 RTSP 流解码成 NV12 格式的原始帧。
解码这一步要放在 VPU 上,不要用 CPU 去软解。RK3588 的 VPU 可以并行处理多路 1080p 解码,CPU 软解 4 路 1080p 会直接吃掉好几个核。硬解码出来的帧建议直接落到 NV12 或 RGB 的共享内存里,这样后续做模型前处理时不用再转一次格式,省掉一笔不小的开销。
# 验证单路 RTSP 流是否可用 ffmpeg -rtsp_transport tcp -i rtsp://admin:pass@192.168.1.64:554/Streaming/Channels/101 -frames 1 -pix_fmt nv12 frame.nv12 -y2.2 帧池:所有业务从同一个地方取帧
视频帧被解出来之后,不能直接丢给各个业务线程,而是先放进一个“帧池”。帧池本质上是一个环形队列加引用计数:每一帧在内存里只有一份拷贝,谁要用就“借”走,用完立即归还或释放。这样能避免同一帧在多个业务线程里被复制好几遍,对内存带宽的压力小很多。
一个比较容易接受的类比是快递柜:快递员(解码线程)把包裹放进柜子,每个取件人(业务线程)凭取件码去拿,拿走之后快递员才能清空格子装下一个包裹。边缘视频任务帧率不需要无限高,帧池深度通常设置为 3~5 帧,超过这个阈值说明消费速度跟不上,应该做丢帧而不是越积越多。
帧池还需要考虑“帧龄”问题。如果某个业务跑得慢,拿到的一直是十几秒前的老帧,那结果没有意义。我的处理方式是在帧结构体里打时间戳,调度器在派发任务时会把过老的帧标记为失效,让后续业务跳过它直接取新帧,宁可少算一帧也不要算一个过期结果。
3. 推理任务的调度:NPU 是公共资源,必须排队
数据通了之后,核心矛盾就转移到推理环节。这里有一个 RKNN Runtime 的硬性规则:同一个 RKNN context 不允许被多个线程同时调用rknn_run,否则轻则结果不稳定,重则直接崩溃或使 NPU 掉算力。所以“多业务共享同一个模型”的前提,是必须有一个串行化的推理调度层,把所有请求排成队,一个一个喂给 NPU。
3.1 为什么不能直接多线程调同一个模型
第一次做多任务时我踩过这个坑:在一个进程里创建了 3 个线程,同时调用同一个rknn_inputs_set和rknn_run,结果半小时内 NPU 就出现输出张量错乱,后面模型推理速度肉眼可见地变慢。后来查了 RKNN Runtime 的文档才知道,NPU 的上下文是绑定到单个 context 的,多线程并发读写同一个 context 属于未定义行为。
解决办法有两种:一种是给每个业务创建独立的 RKNN context,相当于每个业务一个私有的推理通道;另一种是只创建一个 context,所有业务通过任务队列串行访问。前一种适合不同业务用不同模型的情况,后一种适合多个业务共用同一个模型的场景。我们这个项目里主要业务都复用一个检测模型,所以采用“单 context + 任务队列”的方案,内存占用小,调度也更可控。
3.2 一个可落地的推理队列实现
推理调度器是我后来反复打磨的核心。它的结构不复杂,但几个细节决定了稳定性。整体思路是:外部业务模块只负责构造推理请求,投递到一个队列里,调度器内部的工作线程逐个取出请求,执行 RKNN 推理,然后把原始输出写回请求对象,由各业务的回调去做后处理。
// 简化版推理任务结构 struct InferTask { int task_type; // 0: 人脸检测, 1: 安全帽, 2: 明火 int frame_id; // 当前帧号 uint8_t* input_ptr; // 指向帧池中的共享帧 uint32_t input_size; int priority; // 优先级, 数字越大越先执行 std::function<void(const rknn_output*, int)> callback; }; class InferScheduler { public: void push(InferTask&& task) { std::lock_guard<std::mutex> lock(mtx_); queue_.push(std::move(task)); // 按优先级重排(完整实现可用优先队列) } private: std::mutex mtx_; std::queue<InferTask> queue_; };调度器构造函数里调用rknn_init加载模型,rknn_query查询输入输出维度,然后启动一个常驻的工作线程。工作线程的伪代码就是不断从队列取任务、调用rknn_run、输出结果,再执行回调。这里有两个关键点:输入张量的内存最好提前分配好,不要在每次推理时重新申请;推理后处理的回调不要放在工作线程里做耗时操作,否则会把后续任务全部堵住,应该把后处理丢到业务自己的线程池。
3.3 多模型场景下的资源切分
如果业务确实需要多个不同的模型,比如除了 YOLOv5s 之外还要跑一个人脸关键点模型,那建议创建多个 context,并在调度器里做“模型分组排队”。每个 context 有自己的队列和工作线程,但全局共享同一份“NPU 权限管理”。RK3588 的 3 个 NPU 核心在驱动层是自动调度的,但我们不能依赖它自动均衡,还是要手动控制每个模型的时间片。
我的习惯是:N 个模型就创建 N 个工作线程,但每个线程默认执行不超过设定好的时间片(比如 100ms),时间片用完就切到下一个模型,防止某个模型长期霸占 NPU 导致其他任务饿死。整体上这是一个非常朴素的“时间片轮转 + 优先级提升”思路,但实测在边缘设备上已经够用了,不需要引入复杂度太高的实时调度算法。
4. 调度策略选型与权重计算
队列和线程只是骨架,真正体现“调度”的是策略。项目一开始我用的是最简单的先来先服务,结果发现一旦明火检测偶尔丢帧,人脸抓拍就把 NPU 排队通道占满,高优任务被低优任务堵死。因此我在调度器里加入了“优先级 + 权重轮询”的组合策略。
4.1 三种常见调度策略的取舍
| 策略 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 先来先服务 | 按请求到达顺序依次执行 | 实现最简单,公平性可接受 | 低优任务可能阻塞高优任务 | 单任务或任务相互独立 |
| 优先级抢占 | 高优先级请求插队 | 紧急任务及时响应 | 低优任务可能被饿死 | 明火、急停等强实时业务 |
| 加权轮询 | 按预设权重分配推理时间片 | 兼顾延迟和公平 | 权重设置依赖经验 | 多路视频多业务并存的常态 |
这个项目最终采用的是“加权轮询为主,优先级为兜底”。说白了就是平时大家都按权重排队,但如果监控业务出现了超过 N 次连续未处理的高优先级请求,调度器会临时把它前面的任务往后压,确保关键业务尽快执行。
4.2 权重是怎么算出来的
权重不能拍脑袋定,我有一套基于“目标帧率”的计算方式。假设当前模型单次推理耗时 T(比如 20ms),业务 A 的目标帧率是 25 FPS,业务 B 是 10 FPS,业务 C 是 5 FPS。不考虑其他开销时,每秒钟用于推理的总时间大约是25*20ms + 10*20ms + 5*20ms = 800ms,还不到 1000ms,说明算力是够的,这时候按目标帧率归一化就能得到权重比例 25:10:5,即 5:2:1。
真正执行轮询时,调度器按权重分配“连续推理次数”。权重为 5 的任务一轮最多连续推理 5 次,权重为 2 的 2 次,权重为 1 的 1 次,一轮结束后循环。这样既能保证高帧率业务拿到更多的时间片,又不至于让低帧率业务完全饿着。要注意的是,这个计算要在模型推理耗时稳定的前提下才有意义,所以调度器还要动态统计每次rknn_run的实际耗时,如果某段时间耗时明显上升(比如 NPU 降频了),就要及时调整分配次数,避免时间片被浪费。
4.3 帧率自适应的兜底策略
边缘设备的负载不是恒定的,高温降频、多路视频码流波动都会影响推理耗时。我在调度器里加了一个最简单的自适应逻辑:每隔 5 秒统计一次各业务的平均端到端延迟(从取帧到回调结束),如果某个业务延迟超过目标帧率对应的周期(比如目标 25 帧,周期 40ms),就把它的权重下调一个档位,同时把富余的时间片补给延迟较低的快速业务。这套逻辑不需要复杂的控制理论,就是一个比例积分式的调整,在实测中效果很直观,能让整体视频流畅度稳定在可接受范围内。
5. 实战调优与踩坑记录
调度框架搭好之后,真正的战斗才开始。下面把我在 RK3588 上跑同源多任务时遇到的高频问题、排查思路和最终方案整理出来,这些内容在官方文档里往往找不到,但对做项目的人来说比算法原理更救命。
5.1 NPU 降频与风扇散热的联动问题
RK3588 满载跑 NPU 时发热非常猛,尤其是塞在密闭工业机箱里的设备,不一会儿就触发热限。RK3588 的 NPU 频率受 devfreq 控制,不同板卡路径可能不同,可以用ls /sys/class/devfreq/查看,常见的是类似fdab0000.npu的节点。跑推理时可以监控它的 cur_freq,如果发现频率从 1.0GHz 掉到 500MHz 以下,功耗降了、帧率也必然崩。
# 查看当前 NPU 频率和可用频率 cat /sys/class/devfreq/fdab0000.npu/cur_freq cat /sys/class/devfreq/fdab0000.npu/available_frequencies我的做法是两层联动:第一层在 dts 里配置好 pwm-fan 策略,让风扇在 NPU 温度超过 60 度时提前介入,而不是等到 85 度才全速转;第二层在应用层把 NPU 负载反馈给调度器,如果检测到 NPU 频率长时间低于最高档位,就要主动降低非关键业务的帧率权重。优先保明火检测和实时预览,牺牲那些可以做慢速分析的统计业务,这样用户体验不会明显下降。另外,读取风扇转速可以看/sys/class/thermal/cooling_device*/cur_state,或者查驱动里暴露的 fan 节点,排查时先确认风扇策略真的生效,否则散热问题会直接表现为推理变慢,很容易被误判成算法问题。
5.2 内存带宽为什么比 CPU/GPU 更早成为瓶颈
多路视频硬解码非常吃内存带宽。4 路 1080p 解码后,每帧 NV12 数据大约 3MB,按 25 帧算每秒就是 300MB 的数据吞吐,这还没算模型输入缩放、输出后处理以及图形叠加的拷贝。当调度器把所有业务都跑起来后,我发现 CPU 占用并不高,NPU 也没跑满,但视频硬解码偶尔会丢帧,问题就出在内存带宽被抢了。
解决思路是把“不必要的数据搬运”砍掉。帧池里保留 NV12 原始帧,模型前处理直接在帧数据上做缩放和归一化,不要先把 NV12 转成 RGB 再缩放一遍;所有业务的后处理输出只保留小尺寸的结果图(比如 320x320),不要为了画框而保存整帧。另外,帧池深度控制在 3 以内,避免解码线程预解码过多帧把内存占住。
5.3 典型问题速查表
| 现象 | 可能原因 | 排查命令 / 手段 | 解决方案 |
|---|---|---|---|
| 多路视频画面周期性卡顿 | 内存带宽饱和 | htop看 CPU 不高但硬解线程丢帧 | 压缩帧池深度,减少格式转换 |
| NPU 推理越来越慢 | 芯片温度过高导致降频 | cat /sys/class/devfreq/fdab0000.npu/cur_freq观察频率 | 提前开启 PWM 风扇,降非关键业务权重 |
| 同模型多线程调用输出错乱 | 共享同一 RKNN context 并发调用 | 日志中出现rknn_run异常 | 改为单工作线程 + 推理队列 |
| 某业务长期拿不到推理机会 | 优先级或权重设置失衡 | 打印每个任务的执行次数和等待时长 | 引入最小执行时间片,用加权轮询替代完全抢占 |
| 拉流一段时间后 RTSP 断流 | 网络抖动或解码线程阻塞 | ping网关看延迟,dmesg看驱动错误 | 拉流线程加自动重连,解码和推理彻底解耦 |
| 内存持续增长最终 OOM | 帧池没有正确释放/引用计数错误 | free -m,持续监控进程 VmRSS | 检查帧借用和归还路径,设置最大帧数 |
5.4 调优后的一组参考数据
在我这套 4 路海康 IPC + YOLOv5s 同源多任务的工程里,调优前后的对比比较明显。最开始各业务独立处理时,4 路视频平均只有 12~15 帧,NPU 使用率忽高忽低,CPU 经常冲到 80%,内存 8GB 被吃掉 6.5GB。改成统一拉流、共享帧池、单模型推理队列和加权轮询调度之后,4 路视频稳定在 22~25 帧,NPU 使用率平缓地维持在 80% 左右,CPU 降到 40% 附近,内存占用控制在 4GB 上下。这些数字不同项目之间会有差异,但资源利用率提升的方向是一致的。
写在最后的一点经验
同源多任务调度这件事,做完之后回头看,真正重要的不是调度算法写得有多花哨,而是数据通路和资源边界从一开始就定了规矩。先保证同一路视频只解一次码,同一个模型只加载一份,再谈调度策略。调度器的代码可以很简单,一个队列加一个工作线程就能解决 80% 的问题,剩下的 20% 靠的是在对 NPU、VPU、内存带宽有了充分了解之后做的清醒取舍。
最后分享两个小技巧。第一,所有业务的推理请求一定要收敛到同一个调度器入口,后续要加新业务时只需注册一个任务类型和权重参数,框架不用动。第二,日志里一定要记录每个任务从入队到出队的等待时间,这个指标比帧率更能反映调度健康状况。排查问题时先看等待时间,再去看 NPU 频率和内存占用,基本能快速定位到瓶颈环节。