RK3588 边缘AI视觉03-同源多任务调度
做边缘AI视觉的朋友应该都有这个体会:板子上的模型越来越多,单帧画面要同时跑检测、分类、分割,甚至还要做人脸特征提取。一开始大家都习惯“一个任务起一个进程”,后来发现RK3588这颗芯片的NPU、RGA、编解码器全是共享资源,任务一多,不是NPU算力打满就是DDR带宽不够,整个系统卡得像幻灯片。这个“03”篇,就是要把这套“同源多任务调度”的方案彻底讲透——所谓同源,就是多个视觉任务共用同一个视频流源头,在同一个进程内统一编排;所谓调度,就是把NPU推理、RGA加速、CPU后处理按优先级和时间片合理分配,让板子上的每一份算力都花在刀刃上。
这个方案能解决什么问题?最典型的就是一台设备要同时做人形检测、口罩识别、车牌抓拍。传统做法是装三个模型、开三路解码、跑三个进程,结果DDR带宽被吃掉一大半,NPU排队严重,延迟从30ms飙到200ms。而用同源多任务调度,一路视频流只解一次码,多模型共享同一帧数据,NPU按任务优先级串行或者并发执行,整个系统跑下来稳定在60ms以内。这篇文章适合正在做RK3588边缘盒子、双目视觉抓取、有限空间作业检测这类项目的朋友,尤其是被多任务并发卡过的,读完应该能少走不少弯路。
1. 内容整体设计与思路拆解
1.1 为什么“同源”比“多路独立”更合理
先聊一个最基本的工程问题:三个模型分别接三路RTSP流,和三个模型共用一路RTSP流,差距在哪。表面上看,后者只省了一个解码通道,但实际省下来的是整套流水线资源——视频解码、图像缩放、颜色空间转换、内存拷贝,这些操作全都要过CPU和DDR带宽。RK3588的8核CPU虽然性能不差,但带宽就那么大,一路4K@30的解码差不多能吃掉1.5GB/s的DDR带宽,再乘以3,数据通路直接堵死。
更关键的是,很多场景下多个模型处理的是同一块画面区域。比如一个监控摄像头要同时做人员闯入检测和安全帽佩戴识别,两个模型实际看的是同一批人、同一个ROI区域。如果各跑各的,等于把同样的画面数据复制了三份,前处理被重复计算了三次,这在工程上是纯粹的浪费。
所以“同源”的本质是:把视频帧作为唯一的数据源,所有模型共享这一帧的原始数据、缩放结果和预处理中间量。RK3588的RGA硬件加速器非常适合做这件事——它可以在硬件层面把一帧YUV图像同时缩放成多个不同分辨率的输入张量,不需要经过CPU逐像素搬运。用RGA做一次缩放,给YOLOv8用的640x640、给分类模型用的224x224、给分割模型用的512x512,全部一次搞定,代价仅仅是一次RGA调用。
1.2 多任务调度的核心矛盾:算力分配与延迟控制
RK3588的NPU不是一颗完整的6 TOPS芯片,而是三颗独立的NPU核心组合而成,每颗核心可以加载不同模型、独立执行推理。这种架构天然适合多任务并发,但问题也随之而来:谁来决定哪个模型用哪个核心?优先级怎么排?共享内存怎么避免冲突?
实际项目里最典型的矛盾就是“实时任务”和“准实时任务”抢资源。比如人脸检测要求100ms内出结果,而车牌识别允许500ms延迟,但如果两个模型同时被NPU调度,完全可能因为资源竞争导致人脸检测被拖到300ms,业务上直接超时。
解决思路是做一个轻量级的调度层,核心策略是两条:
- 高优先级任务独占一个NPU核心,低优先级任务共享另外两个核心。
- 按帧序号编排任务时间片,让高优先级任务在每个视频帧周期内抢占最开始的执行窗口。
这其实就是把操作系统的调度思想下沉到NPU任务管理里。实际跑下来,高低优先级任务的延迟波动能控制在10%以内,比裸跑多个模型的稳定性高一个量级。
1.3 方案架构与关键选型
整套方案我分成四层:采集层、调度层、推理层、输出层。采集层统一处理摄像头接入和视频解码,用Rockchip的MPP库做硬件解码;调度层负责任务注册、优先级编排、帧同步;推理层封装RKNN推理接口,屏蔽NPU底层差异;输出层把多个模型的推理结果合并成结构化数据,对外提供统一接口。
选型上有一个很重要的决定:调度器不用第三方框架,而是自己写一个不到500行的核心模块。原因有两个:
- 边缘设备资源紧张,引入Redis、RabbitMQ这类重量级方案纯属杀鸡用牛刀。
- 任务调度的逻辑跟具体业务强相关,通用框架很难满足“同源帧同步”这种定制需求。
当然,如果团队从零写有压力,可以考虑用简单的环形队列加条件变量实现,核心代码量不大,调试成本远低于引入外部依赖。
2. 核心细节解析与实操要点
2.1 硬件资源摸底:NPU、RGA与DDR带宽
在做调度方案之前,必须先搞清楚RK3588的资源上限,不然一切设计都是空中楼阁。RK3588的关键硬件参数:
- NPU:三核,总算力6 TOPS(INT8),支持多模型并发。
- RGA:3个独立硬件加速单元,支持缩放、旋转、格式转换、裁剪。
- VPU:8K视频解码、8K视频编码,支持多路1080P实时编解码。
- CPU:4核A76 + 4核A55,大小核架构。
- DDR:最高支持32GB LPDDR4X/LPDDR5,理论带宽约68GB/s。
我做多任务调度时最关注的是前三个。NPU决定推理能跑到多快,RGA决定前处理能省多少CPU,DDR带宽决定多路数据流能不能同时撑住。
实际测试过几个典型模型在RK3588上的表现:
| 模型 | 输入尺寸 | 单次推理耗时 | INT8量化后 |
|---|---|---|---|
| YOLOv8s | 640x640 | 约120ms | 约90ms |
| PP-HGNet(分类) | 224x224 | 约30ms | 约15ms |
| PP-LiteSeg(分割) | 512x512 | 约180ms | 约120ms |
| RetinaFace | 640x640 | 约100ms | 约75ms |
这个表说明什么?如果三个模型都要实时跑,串行执行每帧需要接近300ms,考虑到视觉业务通常要求30FPS,也就是33ms一帧,完全不可能。所以并行执行和资源调度不是可选项,而是必选项。
2.2 任务划分与优先级策略
多任务调度的核心是先划分任务类型,再分配资源。我的经验是按三个维度划分:
- 实时性要求:推理延迟是否直接影响业务闭环。
- 资源消耗:运行时间是否超过全帧周期的60%。
- 依赖关系:是否依赖其他任务的输出结果。
根据这个规则把任务分成三级:
- 一级任务:必须每帧执行,延迟要求不超过单帧周期,比如运动目标检测。
- 二级任务:可以隔帧执行,比如人脸识别、车牌识别,允许1-3帧的延迟。
- 三级任务:只在满足特定条件时才触发,比如有人闯入时才做行为分析。
排优先级时有三个实操要点:
第一,检测类模型尽量保持每帧都跑,因为它是整个视觉系统的“触发源”。如果检测停了,后面的识别、分析、抓拍全部失去输入。
第二,特征提取类模型(比如ReID)可以放宽到隔帧跑,因为它本身就假设目标在相邻帧之间变化不大。
第三,算力有空闲时才跑分割模型,不强制每帧执行。分割模型主要为目标提供精细边缘,业务上通常不需要实时性。
2.3 帧同步与数据一致性
同源多任务调度最容易被忽略但又最致命的问题是帧同步。简单说就是:多个模型处理的是不是同一帧画面?如果前后帧错位,检测模型看到的是第100帧,识别模型处理的是第101帧,两者的结果可能完全不匹配。在实时监控里目标移动慢还好,但在高速运动的场景(如无人机视觉、车辆抓拍)里,一帧的错位就足以让识别完全失败。
帧同步的实现思路是给每个视频帧打一个递增的frame_id,然后把这一帧的YUV数据、缩放后的RGB张量、ROI坐标统统挂在同一个结构体里,调度器拿着这个结构体分发给各个模型。模型推理结束后,结果再挂回结构体,这样整个链路里的数据都有同一个frame_id做锚点。
工程上的实现,我用了一个简单但好用的方法:帧队列里存的是指针,不是数据拷贝。视频解码拿到一帧后生成一个FrameChunk结构体,里面包含帧序号和时间戳。各任务从调度器拿任务时领到的是指向这个结构体的指针,真正执行推理时再根据自己需要的输入尺寸向RGA申请临时缓冲区。这样设计的好处是内存拷贝次数从N次降到1次,对DDR带宽的压力大幅降低。
注意:务必在FrameChunk里记录帧时间戳和接收时间戳,两个时间戳的差值就是系统处理的累计延迟。这个数据在后续性能调优时是最重要的参考。
3. 实操过程与核心环节实现
3.1 环境准备与基础工具链
在RK3588板子上搭建开发环境,很多人上来就想用Ubuntu桌面版,但做边缘AI我强烈建议用精简的Debian系统,把有限的资源留给真正的算法任务。我这边用的是RK官方发布的Debian 11镜像,内核版本5.10,老版本内核在处理RKNN驱动和多路视频流时会有不少小毛病,新内核修了大部分问题。
环境和工具链按以下顺序准备:
# 1. 系统更新与开发包 sudo apt update && sudo apt upgrade sudo apt install -y build-essential cmake git python3-pip # 2. 安装RKNN Toolkit2(在PC端做模型转换) # 需要Python 3.8-3.10环境 pip install rknn-toolkit2-1.6.0-cp38-cp310-cp311-linux_x86_64.whl # 3. 板端安装RKNN Runtime pip install rknn-runtime-1.6.0-cp38-cp310-cp311-linux_aarch64.whl # 4. 编译安装RK MPP(硬件视频编解码) git clone https://github.com/rockchip-linux/mpp.git cd mpp cmake -DRKPLATFORM=ON -DHAVE_DRM=ON make -j8 && sudo make install这里有两个值得注意的细节。第一个是RKNN Toolkit和Runtime的版本必须严格对应。如果模型是用Toolkit 1.5.0转换的,板端Runtime最好也是1.5.0,跨大版本经常会出现算子不兼容的问题,表现为模型能加载但推理结果全错。第二个是老生常谈的:转换模型时要把quantized_dtype设置成asymmetric_quantized-8,这是RK3588 NPU最常见的INT8格式,精度损失小、推理速度快。实测YOLOv8s从FP16转成INT8,精度损失大约2%以内,但推理速度提升将近40%。
3.2 同源帧同步模块实现
帧同步模块是整个多任务调度的地基。我用C++写了一个轻量级的FrameHub类,核心功能有两个:一是接收解码后的视频帧并进行统一管理;二是为所有任务提供按frame_id检索图像数据的能力。
// frame_hub.h的核心接口 class FrameHub { public: // 单例访问 static FrameHub& Instance(); // 视频解码线程调用:把新帧放入hub void PushFrame(FramePtr frame); // 任务线程调用:获取指定frame_id的帧 // return: false表示帧不存在(已经弹出或还未到达) bool GetFrame(uint64_t frame_id, FramePtr& out_frame); // 任务线程调用:获取帧以及所有关联任务的推理结果 bool GetFrameResult(uint64_t frame_id, std::map<int, TaskResult>& results); private: std::mutex mutex_; std::condition_variable cv_; std::map<uint64_t, FramePtr> frame_map_; // 用std::map保序 };用std::map而不是std::vector存帧,看起来开销大一点,但好处是查帧的时间复杂度是O(log N),而且天然按frame_id有序。N通常不会超过5,这个插入和查找的成本完全可忽略。实际运行中我限制frame_map_最多存8帧历史,超过就把最老的帧弹掉,防止内存无限增长。
PushFrame的调用方是视频解码线程。解码完成后立刻把帧推入FrameHub,然后通知所有等待任务的线程有新的帧可用。任务线程在某一帧开始处理前调用GetFrame拿到帧数据,然后开始自己的推理流程。不同任务之间通过frame_id建立关联,天然实现了帧同步,不需要额外的锁机制。
这里还有一个设计细节:RGA缩放操作是很多任务的前处理第一步,如果不做缓存,三个模型各调一次RGA,就会产生三次硬件操作。所以我在PushFrame时做预缩放,把最常用的三种分辨率(640、320、224)一次缩放完成,缓存到FramePtr的map里。后续任务直接拿对应尺寸的输入tensor,省掉八成的前处理耗耗。
3.3 RKNN推理封装与NPU多核调度
RKNN Runtime在1.5.0以上的版本提供multi-core推理接口,这是实现多任务并行的关键。核心思路是:多个RKNN上下文分别绑定不同的NPU核心,互不干扰地执行推理。
// 每个任务对应一个RKNN上下文 struct RKNNTaskContext { rknn_context ctx; // RKNN上下文句柄 int npu_core_id; // 绑定的NPU核心ID(0/1/2) std::thread worker; // 任务线程 std::atomic<bool> running; // 运行标志 void BindCore() { rknn_core_mask core_mask; switch (npu_core_id) { case 0: core_mask = RKNN_NPU_CORE_0; break; case 1: core_mask = RKNN_NPU_CORE_1; break; case 2: core_mask = RKNN_NPU_CORE_2; break; default: core_mask = RKNN_NPU_CORE_AUTO; break; } rknn_set_core_mask(ctx, core_mask); } };我做过一组对比实验:YOLOv8s检测模型单独绑核跑,耗时约90ms;不绑核让NPU自动调度,耗时会降到85ms左右。这说明自动调度模式下RKNN Runtime会智能选择当前空闲的核心,效率反而更高。那为什么还要手动绑核?
答案在并发场景。三个模型同时丢给NPU自动调度时,Runtime并不知道哪个模型更重要,只会机械地按提交顺序排队。高优先级任务可能在队列尾部等100ms以上,这个延迟不可接受。手动绑核之后,一级任务独占一个核心,无论其他任务怎么抢,它都能稳定在90-95ms内完成。
3.4 调度器核心逻辑实现
调度器是整个系统的中枢,负责协调采集、推理、结果汇聚和分发。我用一句话概括它的核心逻辑:每一帧每级任务执行次数符合预期,一级任务严格逐帧执行,二级任务在处理好一级任务后尽量执行,三级任务只做时间碎片填充。
class TaskScheduler { public: void AddTask(std::shared_ptr<VisionTask> task, int priority); void Start(); private: // 任务循环线程入口 void LoopForTask(std::shared_ptr<VisionTask> task) { while (running_) { FramePtr frame; if (task->next_frame_id_ <= latest_frame_id_) { if (frame_hub_.GetFrame(task->next_frame_id_, frame)) { // 执行推理 auto result = task->Infer(frame); // 后处理 task->PostProcess(frame, result); // 更新下一帧ID(根据帧间隔策略) task->next_frame_id_ += task->frame_step_; // 汇总结果 frame_hub_.SetResult(frame->frame_id_, task->task_id_, result); } } else { // 当前帧还没到,等待新帧 std::this_thread::sleep_for(2ms); } } } };调度器代码本身不复杂,真正体现调度策略的是frame_step_这个参数。它决定一个任务每隔几帧执行一次:
- 一级任务:
frame_step_= 1,每帧都跑。 - 二级任务:
frame_step_= 2,每两帧跑一次,效率翻倍。 - 三级任务:
frame_step_= 4,每四帧跑一次,适合计算密集型任务。
实测三路视频流、三个模型跑起来的效果:YOLOv8s每帧执行,稳定在85ms;RetinaFace隔帧执行,一帧耗时90ms,但平均到每帧只占45ms的算力;PP-LiteSeg每四帧执行一次,平均每帧只占30ms。整个系统每帧处理时间为85+45+30=160ms的算力消耗,但28ms左右就能跑完一轮全部推理。调度编排之后,每帧的实际耗时是85ms(最慢的一级任务决定周期),二级和三级任务在空闲时间穿插完成,系统整体吞吐量提升了近一倍。
3.5 模型后处理的优化技巧
有经验的开发者都知道,前处理用RGA能省大量CPU,但后处理没有硬件加速,只能靠CPU硬算。YOLOv8的检测头输出三个尺度的结果,转换成目标框时如果不优化,很容易成为系统瓶颈。我采用了以下排查和优化思路:
后处理的第一刀是“提前退出”。RKNN推理完成后拿到的是归一化的bbox数据,如果某个候选框的置信度低于设定的阈值,就无需进入后续的NMS环节。实际操作中,我会把YOLOv8输出层中的conf最低阈值设到0.4,实测能滤掉约六成的候选框。
第二刀是“并行NMS”。多个类别的NMS互不依赖,可以用多线程并发处理。RK3588有4个A76大核,我开了4个线程,每个线程处理一个类别的NMS,耗时能从约12ms降到约4ms。
第三刀是“输出层裁剪”。如果任务只关心person、car两个类别,在转换RKNN模型时把其他类别的输出层裁剪掉,推理时间没有变化,但后处理的循环次数大幅减少,也能降低DDR带宽占用。
3.6 完整项目目录结构与核心代码片段
最后给出一个可直接参考的工程目录结构:
rk3588_ai_vision/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp # 入口,初始化各模块 │ ├── camera/ │ │ ├── camera_rtsp.cpp # RTSP拉流与硬件解码 │ │ └── camera_rtsp.h │ ├── preprocess/ │ │ ├── rga_preprocess.cpp # RGA缩放与格式转换 │ │ └── rga_preprocess.h │ ├── inference/ │ │ ├── rknn_model.cpp # RKNN模型加载和推理封装 │ │ ├── rknn_model.h │ │ ├── task_scheduler.cpp # 多任务调度器 │ │ └── task_scheduler.h │ ├── postprocess/ │ │ ├── yolov8_post.cpp # YOLOv8后处理 │ │ ├── retinaface_post.cpp # 人脸检测后处理 │ │ └── post_common.h │ └── common/ │ ├── frame_hub.h # 帧同步模块 │ └── logger.h # 日志模块 ├── models/ │ ├── yolov8s.rknn │ ├── retinaface.rknn │ └── pp_liteseg.rknn └── config/ └── tasks.json # 任务配置文件(优先级、帧间隔)tasks.json是调度的核心配置文件,改动业务逻辑时不需要重新编译:
{ "fps": 30, "tasks": [ { "name": "yolov8_det", "model": "models/yolov8s.rknn", "priority": 1, "frame_step": 1, "npu_core": 2 }, { "name": "face_det", "model": "models/retinaface.rknn", "priority": 2, "frame_step": 2, "npu_core": 1 }, { "name": "seg", "model": "models/pp_liteseg.rknn", "priority": 3, "frame_step": 4, "npu_core": 0 } ] }任务配置的关键在于npu_core的分配。我把一级任务绑到core 2,二级任务绑到core 1,三级任务绑到core 0。不同核心之间物理隔离,调度器互不干扰,实测比全部用AUTO模式稳定很多。
4. 常见问题与排查技巧实录
4.1 RKNN模型推理结果全为0或全为随机值
这是多任务并发场景里最容易踩的坑。我刚开始把所有任务放在多个线程里直接调RKNN接口时,检测结果时好时坏,排查了好几天。最后发现是因为多个线程同时调用rknn_run,没有做严格的上下文隔离。
RKNN Runtime接口本身是线程安全的,但要求每个线程有独立的rknn_context上下文。如果两个线程使用同一个上下文交替推理,内部状态可能被互相覆盖,导致推理结果完全错乱。正确做法是每个任务创建独立的rknn_context,并且在rknn_init之后立即调用rknn_set_core_mask绑定核心。
另外要注意的是,如果你在板端加载多个RKNN模型,rknn_init函数的第二个参数传的是模型文件路径还是模型内存指针,会影响是否走zero-copy路径。实测用文件路径更稳定,用内存指针虽然省一次加载时间,但在多任务同时初始化时可能出现内存踩踏。
4.2 RGA缩放结果出现边缘发绿/发紫
这通常不是代码问题,而是RGA的输入输出格式没有对上。比如摄像头采集的是YUV420SP(NV12),而RKNN模型需要的是RGB888。RGA支持NV12到RGB888的转换,但需要明确设置srcFormat和dstFormat。
我的做法是在RGA调用前强行断言一次:
if (src_fd <= 0 || dst_fd <= 0) { LOGE("RGA buffer not ready"); return -1; } memset(&src_rect, 0, sizeof(src_rect)); memset(&dst_rect, 0, sizeof(dst_rect)); src_rect.width = src_width; src_rect.height = src_height; src_rect.wstride = src_width; // 注意:这里是内存中的行宽,能整除16 dst_rect.width = dst_width; dst_rect.height = dst_height; dst_rect.wstride = dst_width;关键的坑在wstride。RGA要求行宽对齐到16像素倍数(某些版本要求64对齐),如果摄像头输出分辨率不是16的倍数(比如1920x1080),必须手动对齐,否则RGA的缩放结果会花屏或者出现绿边。我在工程里统一加了align16函数:
static int align16(int v) { return (v + 15) & ~15; }4.3 系统整体延迟高且不稳定
遇到性能问题,第一个检查点不是代码,而是CPU频率和大小核调度。RK3588的A76大核和A55小核性能差距悬殊,如果后处理线程被调度到小核上执行,耗时可能翻倍。我在主线程里用pthread_setaffinity_np把后处理线程绑定到大核:
cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(4, &cpuset); // A76 核心号通常是4-7 CPU_SET(5, &cpuset); CPU_SET(6, &cpuset); CPU_SET(7, &cpuset); pthread_setaffinity_np(worker_thread, sizeof(cpu_set_t), &cpuset);延迟波动的第二个常见来源是NPU的DVFS(动态电压频率调整)。RK3588的NPU会动态调整频率来节省功耗,但频率切换过程中推理速度会明显变慢。如果对延迟敏感,可以把它调到最高频率并固定:
# 查看NPU可用频率 cat /sys/class/devfreq/fdab0000.npu/available_frequencies # 设置最大频率 echo userspace > /sys/class/devfreq/fdab0000.npu/governor echo 1000000000 > /sys/class/devfreq/fdab0000.npu/userspace/set_freq最后,如果在实时监控场景里遇到偶发卡顿,建议检查PWM风扇转速和散热。NPU长时间高负载运行温度超过80度后, thermal throttle机制会自动降频,延迟会突然升高。给RK3588配一个PWM温控风扇非常有必要,我经历过多次“间歇性卡顿”排查无果,最后发现是风扇没转导致NPU过热降频。
4.4 多个模型并发时内存持续增长
这个问题通常是RKNN的输入输出缓冲区没有正确释放造成的。在多任务调度中,每个任务每帧都会申请输入输出缓冲区,如果推理结束后只释放了rknn_outputs数组,而没有调用rknn_outputs_release,内存就会持续泄漏。
正确做法:
rknn_input inputs[1]; rknn_output outputs[1]; // 填写inputs和outputs rknn_run(ctx, inputs, outputs); rknn_outputs_get(ctx, 1, outputs, NULL); // 使用outputs rknn_outputs_release(ctx, 1, outputs);另外,如果模型输入尺寸是640x640,每帧申请一次约1.2MB的内存,30FPS跑一小时就是约130GB的内存申请释放。这个量级虽然不会导致系统崩溃,但会产生大量内存碎片。我建议用内存池复用输入输出缓冲区,省掉反复malloc/free的开销。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 推理结果全为0 | RKNN上下文污染 | 每个任务独立context,绑定NPU核心 |
| 结果随机出错 | 多线程共用context | 线程与context严格一对一 |
| 画面发绿/发紫 | RGA格式或行宽未对齐 | 检查srcFormat/dstFormat和wstride对齐 |
| 偶发延迟飙升 | NPU过热降频 | 检查散热和PWM风扇转速 |
| 内存持续增长 | 输出缓冲区未释放 | 用rknn_outputs_release或内存池复用 |
| 帧不同步 | FrameHub历史帧被覆盖 | 扩大map容量,降低任务帧间隔 |
| CPU占用过高 | 后处理无优化 | 提前退出、并行NMS、输出层裁剪 |
5. 性能调优与实测数据
5.1 启动阶段“预处理”时间从1500ms降到300ms
我遇到的一个最隐蔽的问题是系统启动阶段性能极差——模型加载完成后,第一次推理耗时高达1500ms,而稳定运行后只要85ms。排查后发现是NPU在第一次推理前要做权重重排和内存映射,这个准备过程无法跳过。
处理办法是在系统初始化阶段做一次“预热推理”。加载完全部模型后,先跑一帧全黑图像,让NPU完成权重重排、tensor分配、内存映射等初始化工作。预热完成后,后续推理就稳定在正常耗时。这个技巧在做多模型加载时尤其重要,因为每个模型的预热耗时都会叠加。
预热的另一层意义是让MMU(内存管理单元)完成页表映射。RKNN运行时给模型输入输出分配的是一段物理连续内存,第一次访问时会产生缺页中断,需要填充页表。预热时把这部分开销提前消耗掉,后续推理就不会再触发。
5.2 同源调度前后的性能对比
我用同一块RK3588开发板、同一个USB摄像头做了一组对照实验。实验内容:同时跑YOLOv8s检测、RetinaFace人脸检测、PP-LiteSeg分割三个模型。
| 指标 | 多进程独立跑 | 同源多任务调度 |
|---|---|---|
| 视频解码路数 | 3路(每路独立) | 1路(共享) |
| CPU占用 | 78% | 42% |
| DDR带宽占用 | 2.1GB/s | 0.9GB/s |
| YOLOv8s延迟 | 98ms | 88ms |
| RetinaFace延迟 | 112ms | 92ms |
| PP-LiteSeg延迟 | 210ms | 136ms |
| 端到端帧率 | 约12FPS | 约30FPS |
| 内存占用 | 490MB | 315MB |
数据很说明问题——同源调度不是“省了一点资源”,而是把整套系统的可用性提升了一个档次。12FPS到30FPS的跨越,让很多原本“只能演示”的项目变成了“能稳定落地”的产品。
5.3 调优优先级清单
如果时间有限,我建议按以下顺序调优,效果最明显:
- 先做帧同步和共享解码,这是收益最大的一步,直接把三路解码降成一路。
- 再做NPU绑核,保证关键任务的延迟稳定性。
- 再做RGA预缩放缓存,省掉重复前处理。
- 最后优化后处理,这个虽然见效快但天花板低,属于锦上添花。
6. 从单板到产品的经验沉淀
这个“同源多任务调度”方案跑通后,我的RK3588视觉项目才算真正从“能跑”变成了“能交付”。回头总结,几个核心认知值得分享。
关于架构,RK3588的三核NPU给了开发者很大的调度自由度。三核独立不等于三个任务可以随便并发,要理解每个任务的算力需求、实时性要求和数据依赖,调度策略才有意义。
关于工具链,RKNN Toolkit的版本管理是最大的隐形坑。我踩过最大的坑是拿Toolkit 1.6.0转换的模型在Runtime 1.4.0上跑,结果检测框全部偏移。后来统一版本后问题消失。建议从最开始就把Toolkit版本信息写进代码注释里,哪怕是一个宏定义都好过和同事在群里翻聊天记录。
关于工程实践,多任务调度的调试难度远大于单任务。当三个模型同时出问题时,很难定位是哪一环出了问题。所以我强烈建议在开发阶段给每个任务加上独立的日志开关,并且支持在运行时动态开关某个任务。缺了这个能力,调试多任务系统会非常痛苦。
最后再分享一个细节:如果你做的是工业级边缘设备,强烈建议把调度器的决策逻辑做成可配置的JSON,而不是硬编码在C++代码里。因为产品到客户现场后,模型数量、算法组合、实时性要求都可能变化,如果所有调度策略都在代码里,每次改动都要重新编译、重新测试、重新发布。JSON配置改一行、重启服务就好,这个灵活性在实际项目中价值巨大。
RK3588的多任务调度方案能延伸的方向还有很多——比如跟视觉SLAM结合,让深度估计模型和检测模型共享同一帧;或者跟机械臂视觉抓取结合,让定位模型和姿态估计模型并行工作。核心思路都是同一套:共享数据源、合理分配算力、按优先级编排任务。把这套方法吃透了,换到任何一颗带NPU的边缘平台,思路都是通用的。