开篇先讲个我自己的真实经历。去年接了一个边缘AI视觉盒子的项目,硬件选型定了RK3588,8核A76+A55,6 TOPS NPU,板子看起来性能很充裕。需求也不复杂,一路摄像头进来,要同时做目标检测、人脸检测、车牌识别、区域入侵报警,最多再加上一个属性分类。最开始我按最直觉的方式做,每个任务独立开一个线程,各自拉流、各自预处理、各自调NPU、各自后处理。结果跑起来之后CPU占用直接飙到70%以上,NPU经常撞车,内存峰值逼近系统上限,整体帧率从预期的25帧掉到不到12帧,而且高负载的时候还偶发卡顿、丢帧。
后来我把问题翻来覆去捋了一圈,发现根子不在单模型性能,而在于多个任务之间完全没有协作,各抢各的资源。RK3588再强,也架不住这么内耗。最后我重新设计了一套调度框架,核心思路就六个字:同源、共享、串行。同一路视频源,所有视觉任务吃同一份数据,预处理只做一次,NPU推理按优先级排队,后处理异步分发。改完之后帧率稳定在25帧以上,CPU占用降到35%左右,整个系统跑一周不重启也不崩。这篇文章就把这套方案的设计思路、代码骨架和踩坑记录完整写出来,给正在RK3588上做边缘视觉多任务的朋友一个可以直接参考的路线。
1. 同源多任务调度,解决的是哪一类问题
先说清楚“同源多任务调度”到底是个什么东西。它不是一个算法,也不是某个固定框架,而是一套针对边缘视觉设备的多任务资源组织方式。核心前提是:多个视觉任务的数据来源是同一个输入源,比如同一路摄像头、同一个视频文件、同一条RTSP流。所谓“同源”,就是所有任务共享这次输入的数据和中间结果;“多任务调度”,则是对这些共享数据的任务做统一仲裁,决定谁先跑、谁后跑、谁可以少跑、谁的结果最紧急。
1.1 边缘视觉项目里最常见的“各跑各的”陷阱
很多第一次在RK3588上做多任务视觉的人,包括当时的我,都会不自觉地陷入一个设计误区:把模块当水管子,一根进水,多头出水。每个业务模块为了保持“高内聚低耦合”,各自抓取视频流、各自解码、各自缩放、各自算推理。比如一个盒子要同时做人形检测和车牌识别,你可能会写成视觉库A只管检测人,视觉库B只管识别车牌,两个库互不通信,各开各的线程,各调各的模型。这样做的优点看起来是代码隔离好、职责清晰,但在RK3588这类嵌入式平台上,代价非常致命。
首先是CPU和DDR带宽被预处理操作反复轰炸。一路1080p视频,解码出来是YUV原始帧,每个任务都要对它做一次resize、一次cvtColor、一次归一化。四个任务就是四套一模一样的操作,每帧多出几十毫秒的纯CPU开销和几倍的DDR流量。其次是NPU资源被无谓地争抢。RK3588的NPU只有一个,同一时刻只能跑一个推理请求(严格说支持多核并发,但单模型串行更稳),多个线程同时往里塞任务,底层驱动只能排队等待或者直接报错。第三个问题更隐蔽:不同任务对延迟的容忍度不一样——区域入侵必须毫秒级响应,定期统计人数可以慢一点,但如果你把所有任务同等对待,最终结果就是轻量任务被重型检测拖后腿,整个系统帧率被最慢的任务拉低。
1.2 同源调度和传统方案的本质区别
传统多路独占式的思路,是把有限资源切分成多份,每个任务觉得自己“拥有一块专属资源”。而同源多任务调度恰恰相反,它强调从数据到算力的全链路复用和统一切换。二者对比看这张表:
| 维度 | 各跑各的多任务 | 同源多任务调度 |
|---|---|---|
| 数据入口 | 每任务独立拉流解码 | 统一取流,单路分发 |
| 预处理 | 每任务重复resize/归一化 | 一次预处理,全任务共享 |
| NPU使用 | 多线程抢占,随机排队 | 调度器统一排队,按优先级推理 |
| 内存占用 | 每份输入各存一份副本 | 共享帧缓冲,零拷贝提供 |
| 延迟稳定性 | 高负载时抖动严重 | 优先级保证,关键任务延迟可控 |
| 代码耦合 | 任务间完全独立 | 调度器居中协调,业务仍可解耦 |
从工程角度看,同源调度的本质是引入了“共享层”。数据共享、预处理共享、推理时序共享,三层共享叠加的效果是:同样的硬件配置,能承载的视觉任务数量差不多能翻一倍。而且它并不牺牲任务的独立性——业务模块之间依然可以零通信,只是它们不再各自抢占底层资源,而是统一向调度器申请执行窗口。
1.3 RK3588的资源底账:能省的,必须省的
在动手写调度代码之前,必须把RK3588的资源底账算明白。这芯片的CPU部分有4个Cortex-A76大核和4个Cortex-A55小核,大核主频能跑到2.4GHz,小核1.8GHz;NPU是6 TOPS算力,加上2个RGA 2D硬件加速器、8K VPU编解码,一个Mali-G610 GPU。看起来资源很豪华,但边缘视觉的真实约束从来不是“单点性能”,而是“持续多任务下的带宽与功耗”。
我在实际项目中抓到的主要瓶颈有三个。第一个是NPU的串行特性:rknn的推理请求一次只跑一个,多核并行看起来美,实际上调度复杂度高、内存翻倍,实用场景里很少能稳定跑满两核。第二个是DDR带宽:4K/1080p图像数据动不动就是几十MB一帧,所有任务共享同一条内存总线,任何重复搬数据的行为都是巨量浪费。第三个是CPU与NPU的协同:NPU跑推理的时候,CPU并不能完全闲着,它要做前处理的喂数据和后处理的取结果,这两段开销如果叠加在关键路径上,CPU占用率立刻上去。同源调度的所有设计,本质上都是在绕开这三个瓶颈。
2. 调度器整体架构与核心设计
现在进入正题,直接讲我最终落地的调度器结构。这个方案不是凭空拍脑袋想的,是我在RK3588上反复压测、断断续续调了一个多月才稳定下来的,踩过的坑我会在后面的章节专门列出来。这里先把框架讲清楚,大家可以拿它当蓝图直接改造。
2.1 数据入口统一:从多路采集到单路分发
同源调度的第一步,是砍掉所有重复的取流逻辑。不管你是用V4L2抓USB摄像头,还是用海康/大华的SDK跑RTSP,或者读取本地视频文件,都只保留一路采集线程。采集线程取到一帧原始数据后,编码成一个Frame对象,里面包含了图像数据指针、时间戳、帧序号、分辨率信息,然后投递到调度器的输入队列。
这一步看起来简单,但有一个很关键的效率优化点:图像数据指针必须是“零拷贝”流转。含义是,采集线程把帧数据放到一块预先分配好的内存池里,后面的所有任务都只能引用这块内存,谁也不能私自再copy一份。RK3588上内存带宽是稀缺资源,1080p NV12数据一帧大概3MB,如果每个任务都复制一份,4个任务就是12MB,一秒25帧就是300MB的无谓搬运。我见过最夸张的案例,有人在任务线程里用cv::Mat浅拷贝理解错了,写成了clone(),导致dma压力直接拉满,系统内存带宽成了瓶颈,NPU反而在空转。
用代码来描述这个统一入口,大概是这样的:
struct Frame { uint8_t* data; // 指向内存池中的图像数据 size_t size; // 数据大小 int width; // 图像宽 int height; // 图像高 int format; // 像素格式,一般是V4L2_PIX_FMT_NV12 uint64_t timestamp; // 采集时间戳 uint32_t seq; // 帧序号 };所有下游任务拿到的不是“自己的帧”,而是这个Frame的只读引用。从设计上就杜绝了重复拷贝和灾备不一致。
2.2 预处理结果复用:一次RGA缩放,多个任务共用
统一数据入口只是第一步,接下来步入成本最高的环节——预处理。很多视觉任务对输入尺寸的要求不一样:目标检测模型可能吃640x640,人脸识别模型要吃112x112,车牌识别要吃416x416。如果按老思路,每个任务自己去缩放,就等于同一帧画面被RGA或CPU反复缩放多次,纯属浪费。
同源调度的做法是预先把“常用尺寸”的缩放结果一次算好,缓存起来。比如输入是1080p,任务要求640和416,调度器就在收到原帧后,立刻用RGA硬件加速器做两次缩放,把640和416两个分辨率的图像各备一份,挂在Frame的附加数据区里。后续所有需要640输入的任务直接从Frame上拿现成的缩放图,不需要再触发任何缩放操作。
这里有一个参数选择的小技巧。不要为每个任务单独生成一个独有的分辨率,而是“就近合并”:把任务要求的分辨率聚合成几档通用尺寸。例如多个模型都要求608x608到672x672之间的输入,统一生成640x640一份就够了,每个模型推理前只做极轻量的中心裁剪或letterbox补齐。这样能用最少的缩放次数覆盖最多的任务。
RGA是瑞芯微平台很重要的硬件加速器,你把缩放操作从CPU上挪到RGA上,CPU的占用率能立刻降下来一大截。在常规情况下,1080p缩放到640x640,用RGA大概只需要1-2ms,而用CPU的opencv resize,大概要8-12ms。这个差距在20帧以上的实时系统里就是天壤之别。
2.3 任务注册与调度策略设计
数据层准备好之后,就到了调度器的核心:任务管理与调度仲裁。我是把每一个视觉任务抽象成一个TASK节点,它只向调度器暴露几个固定接口:采集输入尺寸、执行的优先级、模型推理函数、后处理回调。调度器不关心具体任务内部跑了什么算法,只负责给它分配执行时机和算力配额。
优先级的定义要严格按照业务容忍度来定。我给三类典型任务定义了三个等级:
- L0实时告警类:如区域入侵、跌倒检测、火焰识别,必须在50ms内响应,优先级最高,每帧都推理。
- L1常规识别类:如车牌识别、人脸抓拍,要求100-200ms内出结果,可以每帧推理,但允许排队。
- L2统计类:如人流量统计、聚集检测,300ms以上延迟无所谓,可以每隔3-5帧才推理一次,大幅降低算力消耗。
调度器维护一个“就绪任务链表”,按优先级排序,每收到一帧信号就从高到低依次触发任务。高优先级任务立刻执行,低优先级任务如果检测到NPU忙,可以选择跳帧(skip frame)而不是阻塞。这就是“同源调度”在时序层面的核心思路:不是每个任务都跑满帧率,而是保证关键任务实时、非关键任务尽力而为。
对于L2统计类任务,我还会做一个动态抽帧策略。比如人流量统计,25帧/秒的视频流,实际上每5帧执行一次检测就已经绰绰有余,捕获率误差小于2%。抽帧逻辑很简单,调度器维护一个计数器,只有当计数对某个任务设定的步长取模为0时才投递该任务到执行队列。这个策略在后面的性能数据里是省NPU算力的大功臣。
2.4 后处理异步化:别让解码和识别堵住采集
最后一个重要设计,是后处理异步化。什么是后处理?就是模型推理输出张量之后,你要做的NMS解码、坐标映射、分类打分、结果推送,这些逻辑全部走CPU。如果这些操作都放在推理线程里同步执行,CPU的峰值负载会非常高,而且一旦NMS逻辑写得重,下一帧的采集就会被堵住。
我的做法是把后处理从推理线程里完全拆出去,放到一个独立的“结果工作池”里。调度器一旦拿到模型输出张量,就把张量和对应的Frame信息封装成ResultTask,扔进工作池。工作池里有2-4个线程并行消费,各自处理NMS、映射坐标、推送到业务回调。这样NPU和CPU能并行工作:NPU在跑第N帧的推理,CPU在同步处理第N-1帧的后处理。
这个拆分的收益很大。实测在没有异步化之前,单帧端到端时延大约是65ms,其中后处理占了20ms;异步化之后,单帧端到端时延降到45ms,而且CPU峰值的毛刺明显减少。你要是后处理里还有深度学习分类器,比如对检测框二次做属性分类,异步化的收益会更大。
3. RK3588上的调度器落地实现
架构层面的设计说完了,下面进入硬核实操环节。我会上真实的代码骨架,覆盖调度循环、RKNN模型初始化、预处理共享三个关键部分。代码我尽量精简,去掉业务细节,只保留调度框架本身的逻辑,大家可以直接抄到自己的项目里改。
3.1 代码结构与关键模块
先看整个工程怎么组织。我的建议是哪怕你用的是Python快速原型,也按这个模块边界来拆,后面换C++或者加功能会顺畅很多。
edge_ai_scheduler/ ├── capture/ # 采集模块,统一取流 │ ├── v4l2_capture.c │ └── rtsp_capture.c ├── common/ # 公共数据结构和工具 │ ├── frame.h │ ├── ring_buffer.h │ └── thread_pool.h ├── preprocess/ # 预处理模块,RGA缩放封装 │ └── rga_preprocess.c ├── scheduler/ # 核心调度器 │ ├── scheduler.h │ ├── scheduler.c │ └── task_api.h ├── tasks/ # 具体业务任务 │ ├── detect_task.c │ ├── face_task.c │ └── plate_task.c └── main.c核心的调度循环在scheduler.c里,整个循环是单线程的,所有任务只由这一个循环触发生效。为什么用单线程?因为多线程再加锁会导致逻辑复杂而且难以排查时序问题,RK3588的大核跑单线程调度循环开销很低,瓶颈本来就不在调度器本身。
3.2 调度循环核心代码与分析
下面这个片段是调度循环最核心的部分,完整逻辑包括:取帧、更新共享缩放、执行任务、回收帧引用。
void scheduler_run(Scheduler* sch) { while (sch->running) { // 1. 采集线程取一帧,阻塞最多100ms Frame* frame = capture_acquire_frame(sch->cap, 100); if (!frame) continue; // 2. 标记帧被使用,禁止缓存释放 frame_retain(frame); // 3. 按需生成共享缩放结果 preprocess_generate_common_sizes(frame); // 4. 遍历就绪任务链表,依次调度 uint64_t now = get_time_ms(); for (int i = 0; i < sch->task_count; i++) { Task* task = sch->tasks[i]; // 抽帧策略 if (task->frame_skip > 0 && (frame->seq % task->frame_skip) != 0) { continue; } // 超时保护:低优先级任务不能在上一帧拖太久 if (now - task->last_start > task->timeout_ms) { continue; } // 执行推理并异步后处理 if (task->is_ready) { task->last_start = now; task->async_infer(task, frame); } } // 5. 释放帧引用,归还内存池 frame_release(frame); } }这段代码看起来平淡,但我用上了几个在真实场景里救过命的细节。
第一,帧序号seq用于抽帧和丢帧判断,它是全局单调递增的,这保证了就算推理时间抖动,抽帧节奏也不会乱。第二,每个任务维护一个last_start时间戳,这是“延后调和”的关键:如果某个低优先级任务上一轮已经占用了大量时间,调度器会主动跳过它当前轮次,避免影响高优先级任务的下一轮。第三,task->async_infer是异步接口,推理请求发出后立即返回,真正的等待发生在内部维护的NPU请求队列里,这样调度循环本身不会被单个推理卡死。
3.3 RKNN模型初始化与共享推理
RKNN模型怎么初始化和共享推理,是RK3588上最需要讲究的一环。我这里直接用C API给出核心代码。首先,要初始化多个模型,必须在同一个rknn_context上下文里依次加载,而不是每个任务自己凭空创建一个context。
// 初始化阶段:一次性加载所有模型到上下文 rknn_context ctx; rknn_init(&ctx, model1_path, 0, 0, NULL); rknn_load_rknn(&ctx, model2_path, 0, 0, NULL); rknn_load_rknn(&ctx, model3_path, 0, 0, NULL); // 对每个模型设置输入 for (int i = 0; i < nn_model_count; i++) { rknn_query(&ctx, RKNN_QUERY_IN_OUT_NUM, &io_num[i], sizeof(io_num[i])); rknn_set_io_mem(&ctx, input_mem[i], &input_attr[i]); // 每个模型独立输入 }实际推理时,共享同一个context的最大好处是底层驱动可以对多个模型的推理请求做更好的合并与排序,而不是直接打架。代码上,每个任务的推理函数大致长这样:
int task_detect_infer(Task* task, Frame* frame) { // 从frame的共享缩放区直接拿640x640输入 void* input_data = frame_get_scaled(frame, 640); memcpy(task->input_buf, input_data, task->input_size); // 封装输入输出 rknn_input inputs[1]; inputs[0].buf = task->input_buf; inputs[0].size = task->input_size; inputs[0].pass_through = 0; inputs[0].type = RKNN_TENSOR_UINT8; rknn_inputs_set(task->ctx, 1, inputs); // 查询输出 rknn_output outputs[task->output_count]; memset(outputs, 0, sizeof(outputs)); for (int i = 0; i < task->output_count; i++) { outputs[i].want_float = 0; } rknn_outputs_get(task->ctx, 1, outputs, NULL); // 封装成异步后处理任务投递到工作池 post_process_submit(task, outputs, frame->timestamp, frame->seq); return 0; }看到没有,同一个ctx的不同模型,每个模型有自己独立的rknn_tensor_mem输入输出缓冲区,但要确保输入缓冲区在init阶段就已经分配好。RKNN的底层的输入输出内存管理很讲究,你要是频繁在推理循环中分配和释放内存,DDR压力会非常大。所以我的做法是在模型初始化时一次性为每个模型分配好输入输出缓冲区,之后循环复用,推理期间只做memcpy和数据搬运。
这里还要提一点:对于多个模型共享同一个context,实测中要注意的是算子兼容性。如果某个模型包含context中其它模型不支持的算子,初始化就可能失败,或者推理结果错误。因此我建议所有的模型先单独用rknn-toolkit2的精度工具验证一遍,确保每个模型都能在RK3588上通过,再合入同一个context。
3.4 实测数据与瓶颈分析
直接放一组我在真实项目里跑出来的数据。测试环境是RK3588 8GB内存开发板,一路1080p RTSP摄像机输入,同一时间跑四个任务:YOLOv8s行人检测(L0)、人脸关键点检测(L1)、车牌识别(L1)、人流量统计(L2,每5帧推理一次)。模型全部转为RKNN格式,INT8量化。
| 方案 | 端到端总时延 | CPU占用 | 内存峰值 | 备注 |
|---|---|---|---|---|
| 各跑各的(4路独立) | 78ms | 68% | 1.9GB | 帧率只能到13fps,偶发卡顿 |
| 同源调度+CPU预处理 | 51ms | 48% | 1.2GB | 帧率20fps,CPU仍然偏高 |
| 同源调度+RGA预处理 | 36ms | 33% | 0.9GB | 帧率稳定27fps |
| 同源调度+RGA+异步后处理 | 32ms | 28% | 0.8GB | 帧率稳定30fps,CPU无毛刺 |
这个表是我多次压测后取的中位数。从中可以看出压力优化是逐层累积的:共享数据省掉了重复拷贝,RGA把缩放分流到硬件,异步后处理把CPU的毛刺抹平了。到最后一档配置,系统的CPU占用只有28%,还能剩出不少资源给业务逻辑和网络通信用。
4. 平台调优与常见坑
做RK3588边缘视觉,光有调度器还不够,平台层面的坑和调优占据了一半工作量。这一节专门整理我在RK3588实测中遇到的问题和解决办法,有些是RKNN工具链的,有些是系统层面的。
4.1 NPU、CPU、RGA的协同配置
调度器搭起来之后,还有一个重要工作是把线程绑定到合适的CPU核心上。RK3588的4个大核是Cortex-A76,小核是A55。调度循环和采集线程要绑大核,后处理工作池可以绑大核但优先级降低,而一些低负载任务比如内存回收、日志上传可以绑小核。
具体用pthread设置亲和性:
pthread_t thread = pthread_self(); cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(3, &cpuset); // 绑定到第4个大核 pthread_setaffinity_np(thread, sizeof(cpu_set_t), &cpuset);这种固定绑核能有效避免系统自身的调度器把脑子搞乱。尤其在NPU和CPU一起高负载运行时,如果线程不断在大小核之间迁移,缓存命中率会掉得厉害,你经常会看到CPU占用没变,但推理时延加了10ms,就是迁移导致的cache miss。
另外,RGA的调用次数要控制好。RGA2虽然快,但它和NPU、DDR控制器共享带宽,如果你在一个帧周期内调用RGA超过3次,带宽可能就会成为瓶颈。所以我在前面提到“共享尺寸聚合”一定要做好,把多个任务的分辨率合并成1-2档,让RGA一帧最多压两次。
4.2 高频报错与排查速查表
下面是几个我在开发过程中频繁遇到的报错,每个都是真实踩过的坑。
| 报错信息/现象 | 根因分析 | 解决办法 |
|---|---|---|
| rknn_inputs_set failed: EINVAL | 输入缓冲区大小和模型要求的size不匹配 | 检查input_attr给出的size,分配后用memset清零再memcpy |
| EACCES: Cannot open rknn model | 模型路径不对,或者运行用户没有文件读取权限 | 用完整路径并检查文件存在,确保运行在root或有读权限的用户下 |
| Inference result all zeros | 输入图像的通道顺序/色彩空间和模型训练时不一致 | 检查training时用的输入顺序,使用cvtColor统一转成模型期望格式 |
| can't find suitable delayline | 这是显示模块的报错,如果你用无头模式跑SDK,不影响推理 | 如果你接HDMI做调试,换一个支持的分辨率或刷新率;不接屏可忽略 |
| 推理偶尔超时,一帧卡40ms | 内存动态分配导致DDR高负载或heap碎片化 | 在初始化时预分配所有大块内存,不要在推理循环里new/malloc |
| 系统运行几个小时后内存逐渐上涨 | 某些任务缓存了输出结果,或RGA分配的内存未释放 | 排查frame_release和RGA buffer释放逻辑,可以用malloc_trim或监控/proc/meminfo |
这里面最坑的是“all zeros”问题。有一次我把BGR/RGB顺序写反了,模型输出的所有类别分数都是0,而且不会报任何错误,你只能通过打印输出张量数值才能察觉。所以调试推理结果时,第一时间把输出张量的stats打出来,哪怕只是一个最小值最大值,也能省半天时间。
4.3 长时间运行的稳定性与散热联动
边缘视觉设备往往是7x24小时连续运行的,不像开发板玩一会儿就关机,稳定性要求天然高。我在做完功能后专门做了72小时压测,发现了一个很隐蔽的问题:高负载持续半小时后,RK3588会触发温控降频。如果不处理,NPU和CPU的主频掉下来,端到端时延会逐渐变大,从32ms一路涨到50ms以上,看起来像是系统“变慢了”。
排查下来发现需要做两件事。第一是散热要跟上,我是加了一个5010风扇,通过PWM控制。第二是软件层面读取芯片温度做联动:当温度超过65度,风扇全速转;低于50度风扇低速。这个逻辑很基础,但必须在项目里落地。读取温度很简单,标准sysfs接口就行:
cat /sys/class/thermal/thermal_zone0/temp # 单位是毫摄氏度,比如63000表示63度风扇转速可以通过PWM风扇的hwmon节点读取,例如:
cat /sys/class/hwmon/hwmon1/fan1_input如果你的板子风扇不支持转速反馈,也可以用PWM占空比估算转速,但最好还是选带测速线的三线风扇。
长时间运行的另一个重点是内存碎片化。RGA和RKNN驱动在内核态会分配连续物理内存,如果用户态代码频繁分配释放大块内存,碎片化会导致某些内存分配失败,表现就是跑着跑着突然推理失败。第二章节里说到的“内存池+帧复用”就是针对这个问题的核心解法,务必在工程里落地。
4.4 周边小坑:摄像头掉线、时间戳与日志策略
除了核心算力调度,周边细节也会影响整体稳定性。摄像头掉线是边缘设备最常见的故障,尤其是IPC通过RTSP取流时。我的做法是采集线程增加自动重连逻辑,如果连续3秒取不到新帧,就释放旧连接,重新建立RTSP会话。同时调度器必须感知到输入源异常,暂停所有推理任务,防止NPU空转和无效计算。
时间戳也很重要。边缘视觉设备经常要回传事件给服务端,如果事件的时间戳是按“系统获取时间”而不是帧采集时间,在系统时钟因为NTP校时而发生跳变时,会产生逻辑混乱。我在Frame结构里强制使用单调时钟(CLOCK_MONOTONIC)标记采集时间,同时记录一个wall clock时间对应关系,业务事件统一用采集时间戳。
最后是日志。调度器在运行过程中,我建议把关键事件打点,比如每次NPU推理的耗时分布、延迟超时次数、抽帧率等,用ring buffer缓存起来,统一由低优先级线程异步写盘。绝不能把日志写在推理关键路径上,否则日志IO本身会变成性能瓶颈。
5. 这套方案还能往哪个方向扩展
这套同源多任务调度框架,在我自己的项目里已经稳定运行了几个月。如果后续想在它基础上继续迭代,有几个方向非常值得做。
第一个是引入动态模型切换。现在所有的任务模型都是静态加载的,运行时不可变。实际业务中经常有“夜晚切换红外检测模型”、“白天切换遮阳帽检测模型”这类需求。改动思路是:调度器增加模型热插拔接口,当一个新模型被加载进context后,任务节点自动切换到新模型的输入输出缓冲区。注意热切换前要暂停该任务一轮推理,等当前推理释放后才能替换,否则会读写到旧内存造成崩溃。
第二个是异构算力分摊。RK3588上除了NPU,Mali GPU的通用计算能力也可以被利用起来。像NMS这类后处理逻辑,本质上就是大量并行比较操作,很适合用GPU跑。目前后处理还占着CPU资源,如果能把NMS移到GPU或者RGA上做,CPU占用还能再降5%到10%。不过这需要引入OpenCL开发,工作量会增加不少。
第三个方向是模型压缩蒸馏。同源调度框架解决的是“算力分配”问题,但如果你把单个模型换成更小的蒸馏版本,整体推理时延还能进一步下降。比如行人检测原本用YOLOv8s,换成蒸馏后的YOLOv6-tiny或者定制的小head模型,量化后推理时间可以从18ms降到8ms。省下来的算力又可以支撑更多同源任务,边际收益很可观。
回到事先给出的话——我在这个项目里最大的体会是:边缘AI视觉的系统设计,真正拉开差距的往往不是某个模型有多厉害,而是你如何把这些模型组合进一个完整的、有序的流水线里。单点再强,资源打架就全完了;单点平平,但调度得当,系统反而能稳定高效地跑下去。所以如果你正在RK3588上做多任务视觉,先别急着优化模型精度,把同源多任务调度这层地基打扎实,你的回报率比调优任何单个模型都高。