单块 RK3588 NPU 同时跑三个人工智能视觉任务,放在两年前我是不太敢想的。那时大家普遍的做法是“一卡一模型”,或者干脆把不同任务分配给不同板卡,成本高,维护也麻烦。直到我在一个智慧园区项目里被客户压着必须在一块 RK3588 上实现人员入侵、烟火检测、垃圾分类三合一,才把这条路彻底趟平了。今天这篇就把我在这个项目里的完整方案、踩坑记录和调优经验全盘托出,希望能给正在搞边缘 AI 多任务部署的朋友一些参考。
先说结论:单块 RK3588 的 6 TOPS NPU 跑这三个模型,完全可行,而且可以跑得很好。我在实际项目中实现了三路 1080P 视频流同时分析,CPU 占用稳定在 35% 以下,NPU 负载约 80%,单帧全链路延迟控制在 120ms 以内。但这里面的门道不少,直接拿三个 YOLOv8 模型往板子上怼,分分钟教你做人。
1. 项目核心矛盾与整体技术选型
1.1 为什么“三合一”会让很多人翻车
先说一个很多人容易忽略的事实:RK3588 的 NPU 虽然标称 6 TOPS,但这个算力不是能全部用上的。你得考虑三个问题:
第一,内存带宽瓶颈。NPU 要从 DDR 里反复读取权重和特征图,三路视频帧进来,显存拷贝本身就吃带宽。我在 RK3588 上实测,三路 1080P 的 YUV 数据直接从摄像头拉到内存,就是接近 500MB/s 的带宽消耗。第二个问题是NPU 时间片调度。RKNN Toolkit 的底层驱动虽然是多线程安全的,但多个模型实例同时 submit 推理请求时,如果调度不当,会发生互相等待甚至死锁。第三是各任务计算量差异巨大。垃圾分类需要识别几十个类别,烟火检测需要较高召回率,人员入侵又需要快速响应——三个模型的复杂度不在一个量级,盲目都要跑高精度版本,NPU 量化后的单帧耗时会出现明显参差。
这种情况下,如果你只是把三个模型一股脑塞进板子,个个设置 30FPS 检测频率,一旦并发请求撞在一起,丢帧、超时、CPU 飙红都是家常便饭。项目初期我把三个 YOLOv8s 模型同时加载,三路视频流跑不到 10 分钟,系统就出现了 sluggish 现象,最后发现是 NPU 推理队列被短任务饿死,长任务永远拿不到执行权。
1.2 从“算力够不够”到“如何分配算力”的思维转变
一个成熟的嵌入式视觉系统设计,第一步不是选模型,而是算账。我当时做的第一件事是逐项估算:
- 人员入侵:目标小、需要快速响应、画面中人员数量少,选轻量级检测模型即可,目标帧率 15FPS
- 烟火检测:目标形态多变、误报成本高,需要在暗光和多背景下有较好鲁棒性,选择中等精度模型,帧率保底 8FPS
- 垃圾分类:目标较大、场景固定,但类别多、需要细粒度特征,选较高精度模型,并且可以用“检测 + 分类”的两段式结构,帧率 5FPS 就够
把三个任务的帧率分开之后,NPU 的并发压力立刻小了很多。这里用到的核心思想是:把一帧的“全流程时间”拆解为采集、预处理、推理、后处理四个环节,再把每个模型按实际业务需求分配时间片,而不是简单粗暴让每个模型都满帧率跑。
1.3 最终的硬件与软件栈组成
我的实际硬件环境如下:
| 组件 | 选型 | 备注 |
|---|---|---|
| CPU | RK3588 八核(4×A76 + 4×A55) | big.LITTLE 架构天然适合异构调度 |
| NPU | 3×Core 集成 NPU | 可以绕开 NPU 共享模式,手动绑核 |
| 内存 | 8GB LPDDR4x | 多路视频流时建议至少 6GB 可用 |
| 视频输入 | USB 摄像头 / RTSP 拉流 | 实测最多支持 6 路 1080P 输入 |
| 系统 | Ubuntu 22.04 + RKNN 1.6.0 | 较老但稳定的版本 |
| 推理框架 | RKNN Toolkit2 + C API | 生产环境不要用 Python 做推理主循环 |
| 辅助库 | OpenCV 4.6、FFmpeg 5.0、Nginx + RTMP | 用于解码和推流 |
选这套组合的原因有几个:Ubuntu 22.04 对 RKNN 1.6.0 有官方预编译包,踩坑成本最低;C API 可以把推理循环做成常驻进程,不受 Python GIL 影响;OpenCV 配合 FFmpeg 做硬解码,可以把 CPU 负载压得很低。后续所有数据都是在这个环境下实测得到的。
2. RK3588 NPU 硬件架构对多任务设计的影响
2.1 3 个 NPU Core 的分配逻辑
RK3588 NPU 内部其实有 3 个核心(Core),官方文档里管它们叫 Core0、Core1、Core2。每个 Core 可以独立执行一个模型的推理任务,也可以协同处理一个大模型。在多任务场景下,我的建议是能绑核就绑核,别让驱动动态调度。
我用 RKNN C API 里的rknn_init的 flag 参数,通过RKNN_FLAG_MANUAL_ALLOC和rknn_set_core_mask显式给每个模型分配 Core。实测下来效果差异非常大:
| 调度方式 | NPU 整体利用率 | 长任务延迟波动 | 进程间干扰 |
|---|---|---|---|
| 默认共享(1 个 ctx,多线程提交) | 65%~70% | 40% 左右波动 | 明显 |
| 单 ctx 绑核(1 个 ctx,绑 Core0) | 78% | 18% 波动 | 中等 |
| 多 ctx 绑核(每模型独立 ctx,绑不同 Core) | 82%~85% | 5%~8% 波动 | 基本无 |
多 ctx 绑核是最终方案。三个模型各创建独立的rknn_context,分别在初始化时指定 Core mask(CORE_0、CORE_1、CORE_2)。这样 NPU 调度器就不用在模型之间频繁切换,每个模型独享一个 Core,推理延迟的稳定性大幅度提升。
这里有个细节值得注意:不是模型越大就必须绑编号越大的 Core。我在实测中发现 Core0 和 Core1 在某些固件版本上存在微小主频差异,所以把计算量最大的垃圾分类模型绑在实测表现最快的 Core0 上,烟火检测绑 Core1,人员入侵绑 Core2。
2.2 算力与带宽的双重约束估算
在真正部署前,我做了个算力预算表,把自己心里的账算明白:
RGB 1080P 图像输入到 YOLOv8s(640×640),模型本身的计算量大约为 4.8 GFLOPs。RK3588 NPU 在 int8 量化下实际可利用的算力打五折(考虑到算子不支持、内存搬运损耗),按 3 TOPS 有效算力估算:
- 三类模型每次前向推理耗时预算:人员入侵 25ms,烟火检测 40ms,垃圾分类 60ms
- 三路视频流同时接入,合计每秒钟推理次数:15 + 8 + 5 = 28 次/秒
- 理论需求算力约 28 × (0.05 + 0.08 + 0.13) ≈ 7.28 TOPS
这看起来超了,但在工程上可行——因为并不是每帧都需要跑全部模型。根据业务逻辑,很多帧只需要跑其中的一个或两个模型。尤其是人员入侵模型,大多数时间画面里没有目标,根本不进入烟火检测和垃圾分类分支,只有检测到移动目标时才触发后续检测。这种级联触发机制大大降低了真实算力需求。
当然,如果你要三路视频流互相独立、每路都要同时跑三个模型,那就必须走我下面讲的“分时复用 + 跳帧”的路子。
2.3 为什么 RKNN 量化版本比 FP16 更适合多任务
坦白讲,RK3588 的 NPU 对 FP16 的支持并不理想。我在同一块板子上跑 YOLOv8s 的 FP16 权重,单帧推理延迟 68ms,而 int8 量化后只要 29ms,速度提升超过一倍。更关键的是,FP16 模型在内存占用上几乎是 int8 的两倍,三个模型如果都上 FP16,DDR 压力会陡增。
量化的代价是精度,但在多任务场景下可以接受。实测下来:
- 人员入侵检测:int8 量化前后 mAP 只掉了 1.2%,原因是目标大、前景背景区分明显
- 烟火检测:int8 量化后召回率掉了约 4%,这部分损失我用后处理里的帧间差分做了补偿
- 垃圾分类:int8 量化后 Top-1 精度从 91% 掉到约 87%,但因为分类目标大且拍摄距离固定,实际使用影响不大
所以整体建议是:能 int8 就 int8,释放算力给更多路视频流,精度损失通过算法和后处理去弥补。
3. 三模型并行部署的工程化实现
3.1 模型选型与 RKNN 转换的关键参数
要在同块 RK3588 上跑三个模型,模型结构的选择非常关键。我最终选了:
- 人员入侵:YOLOv8n,输入 640×640,参数量约 3.2M
- 烟火检测:YOLOv8s,输入 640×640,参数量约 11.2M,加了一个注意力模块(CBAM),用于提升烟火的细长形态检出
- 垃圾分类:YOLOv8s + 分类头,输入 640×640,检测头负责框出垃圾,分类头负责区分可回收、有害、厨余、其他四大类和 36 个细分小类
其中垃圾分类是让我最纠结的。一开始我直接训练了一个多分类模型,比如 ResNet50,输入 224×224,精度 92% 以上,但部署后有个致命问题:无法定位画面中的垃圾在哪。用户上传的照片中垃圾往往只占画面很小一部分,直接用全图分类效果很烂。后来改成“检测 + 分类”两阶段方案:先用 YOLOv8s 检测垃圾目标的位置,再对每个检测框的 ROI 区域做分类。虽然推理次数变多了,但效果提升非常明显。
RKNN 转换时的几个关键参数,直接决定了后续部署是否顺畅:
# rknn.config 的核心参数 rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588', quantized_dtype='w8a8', quantized_algorithm='normal', optimization_level=3 )这里有两个坑。第一个是mean_values/std_values必须和训练时的预处理保持一致。很多人训练时用 ImageNet 统计量(mean=[0.485, 0.456, 0.406]),但 RKNN 默认是 0~255 归一化,忘记改就会导致模型精度崩掉。第二个是quantized_algorithm,它有两种选择:normal和mmse。mmse模式的量化误差更小,但转换时间极长,而且某些算子不支持。我在垃圾分类模型上试过mmse,精度确实提升了 1% 左右,但部署后 NPU 推理速度慢了 8%,最终放弃。
3.2 RKNN 多上下文初始化与绑核实现
下面这段是灵魂代码,直接把三个模型分开初始化并绑核。我用的是 RKNN C API,Python 只做工具链转换,推理主循环完全用 C 实现。
// 初始化三个独立的 rknn_context rknn_context ctx_person, ctx_fire, ctx_garbage; // 人员入侵模型 -> Core2 ret = rknn_init(&ctx_person, person_model_path, 0, 0, NULL); ret = rknn_set_core_mask(ctx_person, RKNN_NPU_CORE_2); // 烟火检测模型 -> Core1 ret = rknn_init(&ctx_fire, fire_model_path, 0, 0, NULL); ret = rknn_set_core_mask(ctx_fire, RKNN_NPU_CORE_1); // 垃圾分类模型 -> Core0 ret = rknn_init(&ctx_garbage, garbage_model_path, 0, 0, NULL); ret = rknn_set_core_mask(ctx_garbage, RKNN_NPU_CORE_0);这里有一个非常容易踩的坑:如果你在rknn_init之后立刻调用rknn_set_core_mask,在某些版本的驱动下不会生效,必须在初始化前先通过rknn_dup_context或者用环境变量去预设。我当时因为搞混了这个顺序,绑定失败了整整一天,最后看 RKNN 的 release notes 才知道,需要先申请一个空上下文,再对空上下文调用rknn_set_core_mask,最后再加载模型权重。
正确姿势是这样:
// 先创建空 context,再绑定 core_mask,然后加载模型 rknn_context ctx_person; rknn_init(&ctx_person, NULL, 0, RKNN_FLAG_MANUAL_ALLOC, NULL); rknn_set_core_mask(ctx_person, RKNN_NPU_CORE_2); ret = rknn_load_rknn(ctx_person, person_model_path, 0);绑完核之后,还可以通过rknn_query查询当前 context 绑定的 core 编号,确认是否成功。我建议大家在调试阶段一定要打印出来确认,否则后续排查问题会非常痛苦。
3.3 同时多路推理时的线程编排
模型都初始化好之后,真正的难点是线程编排。我的设计是这样的:
- 每个视频流对应一个采集线程(线程 A),负责从摄像头或 RTSP 拉流,放入环形缓冲
- 一个调度线程(线程 B)从多个环形缓冲中取出最新帧,根据业务逻辑决定这帧该跑哪个模型
- 三个推理线程(线程 C1/C2/C3)分别对应三个模型,各自从对应的任务队列中取出输入 blob,调用
rknn_run和rknn_outputs_get,完成推理后再把结果丢给后处理线程
因为三个模型绑了不同的 NPU Core,C1/C2/C3 三个推理线程可以直接同时调用rknn_run,互不阻塞。这是多模型并发最关键的一点。
线程间的同步我用的是无锁 SPSC 队列(单生产者单消费者),避免 mutex 竞争消耗 CPU。环形缓冲区大小设置成 4 帧足够,不用太大。太大的缓冲会导致图像处理延迟增加,对人员入侵这种需要实时响应的场景非常不利。
调度线程里的伪代码如下:
while (running) { // 从三个视频流取最新帧 cv::Mat frame0 = ring_buffers[0].get_latest(); cv::Mat frame1 = ring_buffers[1].get_latest(); cv::Mat frame2 = ring_buffers[2].get_latest(); // 第一级:人员入侵(所有人的视频源都必须跑) person_job_queue.push({frame0, frame1, frame2}); // 第二级:如果某个视频源检测到人员,则触发烟火检测 if (person_results[0].has_person) { fire_job_queue.push(frame0); } // 第三级:如果人员靠近垃圾点,则触发垃圾分类 if (person_results[0].in_garbage_area) { garbage_job_queue.push(frame0); } }这种级联调度设计,让 NPU 的无效计算量大幅降低。统计下来,大约只有 30% 的视频帧会触发烟火检测,只有不到 10% 的帧会触发垃圾分类。所以虽然每个模型本身的帧率看起来不高,但实际响应速度远超预期。
4. 真正的难点:帧率协调与内存零拷贝优化
4.1 三路视频流的帧率映射策略
所有模型都跑起来之后,有一个容易被忽略但影响巨大的问题:每个模型处理一帧的时间不一样,如果按照固定频率去喂数据,低帧率模型的任务队列会堆积帧,导致“算力饥饿”和“内存爆炸”。
打个比方,人员入侵模型 25ms 一帧,烟火检测 50ms 一帧,垃圾分类 80ms 一帧。如果三路视频都按 30FPS 往各自队列里塞帧,那么 1 秒内,人员入侵队列能处理 40 帧,烟火检测只能处理 20 帧,垃圾分类只能处理 12.5 帧——队列里的积压会越来越大。
我的解法是按消费能力供应生产,也就是根据每个模型上一秒的实际处理速度来动态调整喂帧率:
// 动态调整帧率的核心逻辑 float actual_fps = 1000.0f / avg_infer_time_ms; float target_fps = min(desired_fps, actual_fps * 0.8f); // 预留 20% 余量 // 对应调整视频流的采样间隔 float frame_interval_ms = 1000.0f / target_fps;这么做的好处是系统长期运行不会出现队列积压。并且因为模型推理耗时本身在波动,我把目标帧率设成实际推理能力的 80%,留出余量给 IO 抖动,保证任何时刻都不会因为排队导致帧时间戳过期。
4.2 前处理归一化与零拷贝输入
多路视频 + 多模型,最容易被拖垮的其实是内存拷贝。如果你用 OpenCV 的resize+cvtColor去处理每一帧,再做一次NHWC到NCHW的转换,那么 CPU 负载会轻松吃掉两个大核。
我在这个项目里反复验证,最终采用了一套基于drm的零拷贝方案:
- 视频帧解码直接输出为 NV12(硬解码,FFmpeg 里选择
AV_PIX_FMT_NV12) - 不做
cvtColor,直接让 RKNN 处理 NV12 输入(RKNN 支持 NHWC 布局) - 用
rknn_inputs_map获取 NPU 可访问的内存地址,图像采集直接 DMA 写入该地址
这样一步到位,整个前处理链路中没有任何 CPU 参与的像素拷贝。从摄像头到 NPU 输入的路径耗时,从原来的约 38ms 压缩到约 6ms。
不过要注意,rknn_inputs_map只对特定内存对齐和尺寸的输入有效,如果你使用了输入尺寸为 640×640 但是图像源是 1080P,你需要先用 RGA(Rockchip 的 2D 图形加速硬件)做缩放,再送到 NPU。RGA 的用法也很简单,就是librga库里面的im2d接口,半条指令就能完成 resize + 格式转换,不会占 CPU。
4.3 后处理并行度对整体吞吐的影响
推理完了,后处理同样是大头。YOLO 的 NMS(非极大值抑制)在 CPU 上跑非常耗时,尤其是垃圾分类模型要输出 36 个类别的候选框,单帧 NMS 可能就要 12ms 以上。
我的优化策略是:
- 把三个模型的后处理分到三个不同的 CPU 线程,绑定在 A55 小核上(注意,NPU 绑核和 CPU 绑核是两码事)
- 使用 Fast NMS 或 Cluster NMS 替代 OpenCV 的 NMS(减少约 30% 耗时)
- 对垃圾分类模型,只检测“垃圾区域”中的人形框和垃圾框,减少候选框数量后直接省了 40% 的 NMS 时间
另外一个常见误区是,有人喜欢把后处理挨着推理线程做,认为这样可以减少线程切换。实际上,这会让 NPU 和 CPU 之间形成强耦合,推理线程一旦被后处理阻塞,NPU 的资源就浪费了。更合理的做法是推理线程只管入队和出队,后处理线程专心做解译和 NMS,两者通过队列解耦。
5. 现场实测:三模型同时跑的延迟与稳定性报告
5.1 实测数据汇总
我把整个系统跑了一整周(每天 24 小时),在 3 路 1080P 视频流的情况下,采集到的核心指标如下:
| 指标 | 数值 | 备注 |
|---|---|---|
| 人员入侵模型单帧推理耗时 | 22~28ms | 方差很小,稳定在 24ms 左右 |
| 烟火检测模型单帧推理耗时 | 45~62ms | 波动稍大,跟图像内容有关 |
| 垃圾分类模型单帧耗时 | 68~90ms | 与类别数相关的 NMS 计算时间变长 |
| 全链路端到端延迟 | 95~140ms | 从摄像头采集到最终输出告警 |
| CPU 平均占用率 | 33%~38% | 峰值不超过 55% |
| NPU 平均占用率 | 78%~85% | 三核都在跑,整体利用率理想 |
| 内存占用 | 4.2GB | 包括系统、三路视频缓冲和模型权重 |
| 系统稳定运行时间 | 168 小时无重启 | 无内存泄漏,无死锁 |
这个数据摆出来,说明单块 RK3588 处理三路视频流的三模型分析,是有足够余量的。如果你想省点资源,可以把人员入侵模型降低到 640×384 的输入分辨率,帧率还能再拉高 20%。
5.2 单模型单独跑和多模型并发时的性能差异
有一个非常有意思的现象:单模型单独跑时,速度很快,但三个模型同时跑时,每个模型的速度都会有轻微下降。我特意做了对照实验:
| 模型 | 单独跑耗时 | 三模型并发耗时 | 性能衰减 |
|---|---|---|---|
| 人员入侵 YOLOv8n | 19ms | 24ms | 约 21% |
| 烟火检测 YOLOv8s+CBAM | 43ms | 54ms | 约 20% |
| 垃圾分类 YOLOv8s+分类头 | 58ms | 76ms | 约 24% |
衰减主要来自 NPU 内部总线竞争和 DDR 带宽争用。即使每个模型绑了独立 Core,它们访问 DDR 时还是有冲突。所以做产能规划时,一定要给每个模型多留 20%~30% 的耗时余量,不然并发时会大面积超时。
5.3 长时间运行后的温度与降频行为
RK3588 在 NPU 满载 + CPU 中载下,发热非常明显。我的散热方案是铝制散热片 + 5000rpm 风扇。在室温 26℃ 环境下,NPU 连续满载 1 小时后 SoC 温度稳定在 71℃ 左右,没有触发降频。如果你没有主动散热,温度冲上 85℃ 后 RK3588 会对 NPU 降频,导致推理速度骤降 30% 以上。
这里分享一个小技巧:在 NPU 任务不重的深夜里,我会通过 PWM 把风扇转速降到 2000rpm,等温度超过 70℃ 再提上去。这个逻辑在用户态写个守护脚本就搞定,非常省电,也避免风扇长时间高速运转产生的噪音让现场人员不适。
6. 踩坑实录:最容易翻车的三个细节
6.1 第一坑:RKNN 版本不一致导致模型转换后精度异常
这个坑我吃了大亏。我用 RKNN Toolkit2 1.5.2 转换的模型在 x86 模拟器上精度看着还行,但部署到板子上之后,垃圾分类的 Top-1 精度从 87% 直接掉到 60% 多。排查了很久才发现,是 1.5.2 版本的 RKNN-Toolkit2 转换器和板端 RKNN Runtime 版本不一致,量化 scale 在部署时被重新解释了。
解决办法是严格保证三端版本一致:训练服务器上转换用 RKNN-Toolkit2 1.5.2,板端 Runtime 也要用 RKNN Runtime 1.5.2,C API 头文件也要用配套版本。在嵌入式 AI 项目里,“能跑”不等于“可以发布”,版本链路的闭环检查必须做。
6.2 第二坑:多线程同时调用 rknn_outputs_get 导致的内存踩踏
三个推理线程分别调用rknn_outputs_get获取输出,本来应该相安无事,但我在并发量上去后偶尔会出现程序段错误,而且不固定。查了两天,最后通过 AddressSanitizer 定位到是rknn_output的want_float字段设置不一致导致的输出缓冲区重叠。
解决方案是在每个线程的rknn_run前显式清空上一次的rknn_output结构体,并单独给每个线程分配独立的输出缓冲区,不共用任何中间变量。代码上就是多写了几行memset和malloc,但避免了一个极其隐蔽的并发 bug。
6.3 第三坑:RTSP 拉流异常导致的“潜伏卡顿”
三路视频流中有一路是从海康摄像头拉 RTSP,网络稍微波动时,FFmpeg 的解码线程会因为没有新帧而阻塞,导致下游所有模型都拿不到新数据,系统整体表现为“偶尔卡一下,大约 2 秒后恢复”。这种问题最难排查,因为看起来像性能问题,实际上是数据源问题。
我的处理办法是给每个视频流增加了超时保护:监控图像时间戳,如果超过 1 秒没有新帧,就主动断开重连,重试间隔从 5 秒开始,指数退避到最大 30 秒。另外在环形缓冲里始终保留最后一帧,即使解码暂时中断,下游模型也能继续处理旧帧,不会阻塞整个流水线。
7. 多模型并行调优后的几点体会
整套系统从开始设计到最终稳定运行,前后花了我大约三周时间。如果要把经验浓缩成几句话,大概是:
第一,算力规划永远大于模型调参。在 RK3588 这种 6 TOPS 的边缘设备上,先算清楚每条视频流、每个模型分配多少帧率、多少内存,比追求单模型精度重要得多。算力预算错了,后面怎么优化都容易翻车。
第二,级联触发是边缘设备多任务落地的最佳实践。不是所有模型都需要每帧都跑。合理的业务逻辑编排,能让 NPU 有效算力翻倍甚至翻三倍。我把人员入侵作为一级触发器,烟火检测和垃圾分类作为二级、三级触发器之后,系统的实际处理能力提升非常明显。
第三,绑核 + 独立上下文是 RK3588 多任务并发的最优解。默认的共享模式会被动态调度拖垮延迟稳定性。花十分钟去理解和配置rknn_set_core_mask,绝对值得。
最后再分享一个小技巧:如果你在部署后遇到某些帧的输出检测不到小目标,尝试把 RKNN 的输入尺寸从 640×640 换成 768×768(需要重新转换模型)。在 NPU 算力有余量的情况下,小目标召回率的提升非常可观,而推理耗时只增加 30% 左右。我在烟火检测上就是这么干的,效果立竿见影。
希望这篇内容对正在折腾 RK3588 的你有用。这块板子潜力很大,只要掌握正确的姿势,多任务边缘视觉系统的性能和稳定性可以做得很好。