1. “同源”这两个字,才是RK3588多任务调度的核心
我最早看到“同源多任务调度”这个词的时候,第一反应是:这不就是多线程吗?RK3588有8个CPU核,再配上NPU、GPU、RGA这些协处理器,把任务丢给不同的核去跑就是调度,有什么好单独拿出来讲的?直到真正在板子上做视觉应用,被NPU算力瓶颈、GPU管线冲突、以及多路视频流同时进来时的资源争抢反复打脸之后,我才明白“同源”这两个字的份量。
先解释一下我理解的“同源”。在RK3588上做边缘AI视觉,同一个模型文件(比如yolov8的rknn版本)可以被加载到三种不同的执行后端:NPU、GPU、CPU。所谓同源,指的是模型权重、模型结构来自同一个来源,但推理任务可以分发到不同的算力单元上执行。这跟“异源”是两回事,异源往往是不同模型跑在不同芯片上,比如用NPU跑检测、用GPU跑分割、用CPU跑分类;而同源是同一份rknn模型,在同一个系统里被多路任务共用,NPU跑不过来的时候,GPU兜底,CPU再兜底,并且这些推理请求由一个统一的调度器来分发。
这里就有一个很多人会忽略的问题:RK3588的NPU虽然是6 TOPS算力,但它不是无限并行的。NPU内部有3个核心(Core),官方文档里写的“3 TOPs per core”,但在实际使用中,3个核心不是随便就能同时被不同进程打满的,尤其是用了RKNN Toolkit生成的模型,默认情况下一个应用只会占用一个NPU core。如果同时跑多路YOLOv8检测,NPU core 0可能已经100%满载,core 1和core 2还在闲着,但你的代码就是没让它们干活。这时候,同源多任务调度要解决的第一个问题就是:怎么把模型的一路推理请求,分发到不同的NPU核心上,并让它们真正并发执行。
我在开发板上做过一次很直接的对比:同一份yolov8s.rknn,用单核连续跑4路RTSP视频流,每路画面对应一个独立线程,结果NPU占用率直接顶到100%,GPU和CPU几乎没参与,平均每帧推理耗时从16ms飙到45ms,视频开始卡顿;改成同源调度,把4路推理请求通过调度器拆到NPU双核 + GPU,CPU做预处理兜底,同样4路视频流,每帧推理耗时稳定在20ms左右,虽然单帧没有单核单路那么快,但整机吞吐量高了一倍以上。这就是我写这篇文章的起因。
这篇文章面向的是已经跑通过RK3588基础环境、准备做多路视觉任务的开发者,或者正在为“模型在开发板上跑得太慢”“多路视频流一来就掉帧”头疼的朋友。我会把同源多任务调度的完整思路、调度器设计、以及我在RK3588上实测的数据和踩过的坑都整理出来,重点讲清楚三个问题:为什么同源能提高整体吞吐,调度器的优先级和队列怎么设计,以及双核NPU并发和GPU接入时最容易被忽略的坑。
2. 先把四路算力盘清楚:NPU双核、GPU与CPU的实际承担角色
在写调度代码之前,必须先把RK3588的算力资源摸清楚。我见过不少人犯同一个错误:把所有AI推理全部丢给NPU,然后把NPU跑满之后剩下的活儿丢给CPU,GPU完全闲置。这样不算调度,只能叫“让NPU硬扛”。
2.1 RK3588算力架构里,真正能跑模型的单元有哪些
RK3588的关键算力单元是这几块:
- 6 TOPS NPU(3核,单核约2 TOPS)
- Mali-G610 MP4 GPU(4核,支持部分通用计算但生态不如NPU成熟)
- 4个Cortex-A76大核 + 4个Cortex-A55小核
- 2个RGA(图像处理加速单元,可以做缩放、格式转换、镜像等)
这里要特别说明一下RGA。它不是跑AI模型的,但它承担了预处理图像resize、crop、颜色空间转换这些脏活。我们做视觉任务时,模型输入往往是640x640或320x320,而摄像头输入是1920x1080或2688x1520,如果不经过RGA直接用CPU做resize,CPU占用率会高得离谱。同源多任务调度中,我把RGA当作独立的“第5路算力”来规划,它不参与推理,但参与整个pipeline,调度器必须给RGA任务预留带宽。
GPU在RK3588上跑AI模型的选择不多。RKNN Toolkit生成的模型可以直接跑NPU,但GPU推理需要走OpenCL。我实测过用GPU跑yolov8s的rknn转onnx再转OpenCL的方案,单帧推理时延大概在35~50ms,比NPU单核(16~25ms)慢不少,但在NPU满载时,GPU可以作为次级执行端把积压的任务消化掉。这里要提醒一点:GPU在RK3588上和显示共用一套资源,如果上面还跑着QT界面或HDMI输出,GPU做AI推理会拖累显示性能,所以在调度器里,GPU执行端的优先级必须低于NPU,并且要设置任务超时。
CPU的A76大核就更好理解了。RKNN推理在CPU模式下其实不是直接跑模型,而是用RKNN的模拟层去执行,速度很慢,我实测yolov8s在A76上单帧推理要150~250ms,基本只能做兜底。但CPU在调度器里的角色不止推理,它还负责视频解码、图像预处理、调度逻辑、以及部分后处理(比如NMS、目标跟踪关联)。所以调度器给CPU分配任务时,要区分“推理任务”和“非推理任务”。
2.2 算力分工的指导原则:慢设备不能拖垮快设备
设计调度器之前,我给自己定了一条原则:每一类任务都有一个“主执行端”和一个“备执行端”,主执行端跑不过来时才降级到备执行端。这就像一条高速公路,NPU是主车道,GPU是应急车道,CPU是辅路,不能一上来就把所有车都堵在辅路上。
我的分配策略如下:
| 任务类型 | 主执行端 | 备执行端 | 适用场景 |
|---|---|---|---|
| YOLOv5s/v8s检测 | NPU Core 0/1 | GPU / CPU | 多路RTSP实时检测 |
| 轻量分类模型(小于20MB) | NPU Core 2 | CPU A76 | 人体属性、车辆颜色 |
| 图像缩放/格式转换 | RGA | CPU NEON | 所有视频帧预处理 |
| 后处理(NMS/跟踪) | CPU A76 | CPU A55 | 所有检测结果过滤 |
| 分割类模型 | GPU(OpenCL) | NPU Core 2 | 慢速场景,不要求实时 |
我一再强调“同源”,就是因为同一个模型在NPU、GPU、CPU上都有一份执行上下文,调度器才能在这三者之间自由切换任务。如果模型只部署到NPU,那GPU和CPU根本没资格接任务,说什么调度都是空话。
3. 一个真正能跑起来的同源调度器:请求队列、优先级与线程池
我最初想得很简单:写一个全局任务队列,NPU空闲就丢给NPU,GPU空闲就丢给GPU。但跑起来之后发现,判断“空闲”本身就很困难,而且RKNN推理是阻塞式API,一个线程在调用rknn_run的时候,其他线程看不到它的“忙闲”状态,只能通过互斥锁硬等。这会导致一个严重问题:一个线程在NPU上跑推理时,另一个线程即使想用NPU的另一个核,也被同一把锁挡住了。
3.1 RLS调度器的核心设计
后来我设计了一个轻量级的RLS(Request-Level Scheduler)模块,它不修改RKNN底层驱动,只在应用层做请求分发。核心数据结构是这样:
typedef struct rk_aiot_request { uint32_t req_id; // 请求ID,自增 const char *model_name; // 模型标识,同源模型共用 int task_type; // 0=detect 1=classify 2=segment int priority; // 0最高,3最低 uint64_t arrival_ts; // 请求到达时间 uint64_t deadline_us; // 期望完成的时间上限 int exec_target; // 分配结果:0=NPU0 1=NPU1 2=GPU 3=CPU void *input_data; // 输入帧指针 void *private_ctx; // 模型上下文指针 struct rk_aiot_request *next; } rk_aiot_request_t;每个请求从视频流线程进来之后,先进入全局请求队列,调度线程每隔5ms做一次批量分发。这里的优化点是:批量分发比逐个分发吞吐高得多。我实测过,调度线程每5ms轮询一次,4路视频流ping-pong式发请求,比每来一帧就立刻调度一次,CPU占用率降低了12%,因为减少了线程切换和锁竞争。
分发逻辑不复杂,但优先级策略值得展开说:
- priority 0:人机交互类,比如触摸屏点击框选目标,必须立刻响应,直接插队到NPU Core 0。
- priority 1:实时检测类,比如视频流里的入侵检测,要求帧率大于15fps,分配到当前负载最低的执行端。
- priority 2:上报类,比如每隔几秒做一次车流统计,不要求实时,放到低优先级队列,等NPU或GPU闲下来再执行。
- priority 3:后台离线分析,比如抓拍图片入库前的属性分析,直接放到CPU A55执行,几乎不影响主流程。
3.2 任务动态分发:其实最难的是“下次别走错门”
分发到哪个执行端,不能只看当前负载,还得看“上一次这个模型在哪个端执行”。这里面有个性能陷阱:RKNN模型上下文在NPU上需要8~30ms的初始化时间,但上下文建好之后,同一份上下文的重复推理非常快。如果你的调度器每次都把一个任务分发到不同的执行端,那就等于每次都要重新做上下文初始化,性能不升反降。
所以我在调度器里加了亲和性策略:每个模型维护一个“最近执行端”的字段,新请求到达时,优先尝试最近执行端;只有当最近执行端的待处理队列超过阈值(比如队列里积压了超过3个请求),才把新请求分发给其他执行端。这就好像一个人干活,手里的活儿没堆积完就别打断他,换了别人来又要重新熟悉工具。
我在4路视频流上实测时,这个亲和性策略对吞吐的贡献非常大。没有亲和性时,系统每秒钟能处理约20帧(4路每路5fps);加上亲和性,每秒能处理28~32帧。原因就是上下文初始化的开销被摊薄了,NPU执行端大部分时间在做纯推理,而不是重建上下文。
4. 双核NPU并发与RKNN异步标志:跑yolov8实测的数据变化
要说RK3588同源多任务调度里最核心的实战环节,就是把同一份yolov8模型加载到NPU的两个核上并发执行。RKNN官方SDK从RKNN Toolkit 1.6开始支持NPU Core的分配控制,很多人不知道这个特性,把两个NPU核当成一颗用,等于浪费了一半算力。
4.1 RKNN_FLAG_ASYNC_MASK 与 zero-copy 的配合
加载模型时,需要设置RKNN的flag和核心掩码。下面是我在C++环境里用的关键配置:
// 创建RKNN上下文 rknn_context ctx_npu0; rknn_context ctx_npu1; // 分别加载同一份rknn模型到两个上下文 rknn_init(&ctx_npu0, model_data, model_size, RKNN_FLAG_ASYNC_MASK, nullptr); rknn_init(&ctx_npu1, model_data, model_size, RKNN_FLAG_ASYNC_MASK, nullptr); // 通过set_core_mask把这两个上下文绑定到不同NPU核 uint32_t core_mask_0 = 1; // core 0 uint32_t core_mask_1 = 2; // core 1 rknn_set_core_mask(ctx_npu0, core_mask_0); rknn_set_core_mask(ctx_npu1, core_mask_1); // 推理时使用异步标志 rknn_run(ctx_npu0, &inputs, &outputs, RKNN_FLAG_ASYNC_MASK); // 同步等待结果 rknn_wait(ctx_npu0, &outputs);注意这里有个大坑:rknn_init同一个模型文件两次,等于在NPU上创建了两份权重副本。如果你的模型文件是80MB,两份就是160MB,DDR要能扛得住。yolov8s的rknn模型大约40~50MB,两份大概100MB,对RK3588的8GB内存版本没问题,但4GB版本就会紧张。后面我会单独讲权重复用的问题。
4.2 yolov8s在四种执行模式下的实测数据
我使用baseline rknn的yolov8s模型,输入640x640,做4路RTSP视频流的人体检测,对比了四种执行方案。这里把实测数据放出来:
| 执行方案 | 平均单帧推理耗时 | 4路总帧率 | NPU占用率 | 整机CPU占用率 | 备注 |
|---|---|---|---|---|---|
| 单上下文,单核,串行 | 42ms | 约10fps | core0 100% | 35% | 最容易写的代码,但掉帧严重 |
| 双上下文,双核,无调度器 | 26ms | 约15fps | core0/core1各60~70% | 48% | 提升明显,但有任务积压 |
| 双上下文,双核+调度器 | 20ms | 约20fps | core0/core1各75% | 41% | 调度器把负载压得非常平 |
| 双核NPU + GPU兜底 | 18ms | 约24fps | core0/core1各80%,GPU 30% | 52% | 整体吞吐最高,但功耗也最高 |
需要说明:这里的“平均单帧推理耗时”是4路视频流里的平均单帧延迟,不是单路独占时的推理延迟。单路独占时yolov8s在RK3588 NPU上大约16~18ms,这个大家应该都比较熟悉。
从数据能看出,双核并发不是简单地把单核翻倍,因为还有输入排队、内存拷贝、后处理这些开销。再加上调度器之后,收益的一部分来自负载均衡,另一部分来自亲和性策略减少了上下文切换。GPU兜底带来的吞吐提升只多了4fps,但CPU占用率直接从41%涨到52%,因为OpenCL路径需要CPU做大量内存搬运。于是我在实际部署时默认关掉GPU兜底,只在NPU过载时动态开启。
4.3 一个容易误判的现象:异步标志不是开了就“真异步”
RKNN的RKNN_FLAG_ASYNC_MASK这个标志,名字里有“ASYNC”,但我用了很久才摸清它的脾气。它解决的不是“调用线程不阻塞”,而是“NPU执行过程中CPU可以继续做别的事”。如果你在rknn_run之后立刻调用rknn_wait,那其实和同步执行没区别,CPU还是会等着。真正的异步用法是:发起rknn_run之后,CPU马上去处理上一批已经完成的推理结果,然后再回来rknn_wait当前这一批。
我在调度器里把这种“流水线”思路落实成三块缓冲区:缓冲A正在NPU上推理,缓冲B正在做后处理,缓冲C正在被视频流线程填充输入数据。三个区互不重叠,CPU、NPU、解码器才能并行起来。这也是我觉得“同源多任务调度”里最有工程价值的部分:不是调度器本身多复杂,而是它把RK3588的每个算力单元都当成一个独立的流水线级来用,同一份模型在算力链路上从头到尾没有真正的空闲等待。
5. 上下文复用时最容易踩的坑:权重拷贝、NPU句柄与CPU线程绑定
这部分是实打实的经验汇总。同源多任务调度最大的优势是“同一份模型多端复用”,但也正因为复用,会踩到一些单模型部署时永远不会出现的问题。
5.1 两次rknn_init等于双倍权重拷贝,4GB板子怎么办
如果你的开发板内存是4GB版本,一定要谨慎使用“创建两套上下文”的方式。我在8GB板子上做双核NPU并发没觉得内存紧张,后来换到4GB板子测试,一个yolov8s.rknn加载两份,系统可用内存直接从900MB掉到300MB,再叠加4路视频流的缓冲,直接触发OOM,进程被杀掉重启。
解决这个问题有两个方向。第一个方向是改用RKNN Toolkit Framework的Zero-Copy功能,让两个上下文尽可能共享模型权重段,但RKNN底层对零拷贝的支持和模型结构相关,不是所有模型都能吃到红利。第二个方向更通用:4GB板子上,不要做“两份NPU上下文”,改成一个上下文+调度器强制串行,另一个模型跑GPU或CPU。实验数据告诉我,4GB板子上双NPU并发的内存压力,远大于吞吐收益,不如混合执行端方案划算。
5.2 npu句柄的线程安全问题:别跨线程乱调rknn_api
RKNN官方文档不太强调这件事,但我把血泪史写在这里:同一个rknn_context的rknn_run和rknn_wait不能在不同的线程里并发调用。我最初想用“一个线程把输入丢进rknn_run,另一个线程立刻rknn_wait”的方式提高吞吐,结果跑了一个多小时,系统随机报RKNN_ERR_PARAM_INVALID,有时候直接段错误。后来查了SDK源码才发现,rknn_context内部的命令缓冲区不是线程安全的。
正确做法是:每个执行上下文绑定一个专属工作线程,比如NPU0上下文只被线程A调用,NPU1上下文只被线程B调用,调度器通过队列把请求投递给对应线程,而不是直接跨线程调API。这样一来,RKNN调用全部单线程化,吞吐靠多个执行端并行撑起来,而不是靠单执行端内多线程并发。
5.3 CPU侧的线程绑核,实时性提升比想象中大
我在跑调度器时,把后处理线程绑到A76大核(CPU 4~7),把视频解码线程绑到A55小核(CPU 0~3),这样大核不会被解码中断打搅,小核也不会因为跑后处理而卡顿。用pthread_setaffinity_np做绑核,代码很简单:
cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(4, &cpuset); // 绑定到CPU4(A76大核) pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);实测效果:调度线程的响应波动从±8ms下降到±2ms。这个优化其实不复杂,但在多任务调度系统里特别管用,因为调度器如果自身抖动过大,后面所有执行端的负载估计都会失真。
6. 路由策略与内存占用:同源多任务比“一模型一进程”到底省在哪
最后聊一个很多人忽略的问题:既然多任务调度这么麻烦,我能不能简单粗暴一点,每路视频流起一个独立进程,每个进程独立加载一份模型,互不干扰?答案是可以,但代价非常大。
我做过对比测试:4路视频流,每路一个进程,每个进程各加载一份yolov8s.rknn,总内存占用从单进程调度方案的约650MB,涨到约1.6GB。多出来的这1GB,就是四个进程各自持有的模型权重、RKNN内部缓冲区、以及重复的预处理中间结果。在同源多任务调度方案里,模型权重只保留一份公共副本,各路任务通过调度器共享同一份模型上下文,只有输入输出缓冲区和后处理结果是各自独立的。这在大内存场景下不算致命,但在边缘设备上,内存每多出1GB,就意味着散热、功耗、稳定性全面受影响。
调度器的路由时机也非常重要。我的原则是:调度决策要在预处理之前,而不是推理之前。帧从摄像头抓进来后,先根据当前各执行端的负载,决定这帧交给NPU、GPU还是CPU,然后再做对应的输入格式转换。如果等预处理完再决定,一旦发现NPU忙,要转给GPU,就得重新做一次格式转换,白费功夫。这个细节让我的整体pipeline延迟降了约3ms/帧。
再补充一个我自己觉得很有用的收尾技巧:把调度器的状态导出来,通过串口或者日志定期打印一张“各执行端任务分布图”,包含每个端已经处理的总请求数、当前队列积压数、平均处理时延。初期调优时,我在RK3588上同时跑两路模型任务,一张4路视频流的调度拓扑图,就能很快看出哪一路在偷懒、哪一路过载。这类诊断信息,用RKNN自己的profiler很难直接拿到,因为profiler只关注单模型的NPU执行细节,不管应用层的请求分发。
7. 关于RK3588调试流程和固件版本的一些提醒
调度器写完能不能跑,其实还取决于你板子上的环境和固件。我调试RK3588的过程中,遇到过几次特别困惑的事:代码没问题,但NPU推理结果一直不对,后来发现是固件里的NPU驱动版本太老,和RKNN Toolkit生成的模型版本不匹配。这类问题很难靠看日志定位,因为各家开发板的系统镜像版本五花八门,有时候同一个RKNN版本在不同板子上表现都不一样。
我现在的做法是上车第一件事先确认四样东西:
- NPU驱动版本:
dmesg | grep rknpu,或者直接cat /sys/kernel/debug/rknpu/version - RKNN Toolkit版本:跟模型转换环境严格对应,不要在PC上用了新版转换,板子上还跑老版runtime
- 系统镜像版本:强烈建议用开发板厂商提供的最新官方固件,自己裁剪的内核有时会把RKNPU的dts节点配置错
- 内存带宽压力:跑业务时用
perf或top盯着CPU使用率,如果整机CPU一直处于80%以上,先查是不是解码和RGA挤占了NPU的DDR带宽
电源也是个大坑。RK3588在NPU满载+GPU满载+4路视频解码同时工作时,功耗上得很快。我用的开发板在12V 2A供电下,整机高负载跑10分钟就会触发降频,NPU频率从1.0GHz掉到0.7GHz,推理时延大幅增加。后来换成12V 3A电源,稳态功耗才稳住。电源不足导致的“假性卡顿”,容易被误判成调度器问题,排查时要先排除。
从我的经验看,RK3588作为一块边缘AI开发板,性能和灵活性在同类设备里是第一梯队。但“算力”和“实际能用的算力”之间,隔着系统、驱动、调度器、内存管理这一堆工程问题。同源多任务调度不是银弹,它解决的是多路任务共享同一份模型资源时的调度效率问题;如果你的业务只有一路视频流,老老实实单核NPU跑就是最优解。但只要有超过两路的视觉任务进来,这个调度器思路的价值就会立刻体现出来。希望这篇文章能帮你少走我之前走过的弯路。