单块 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 最终的硬件与软件栈组成我的实际硬件环境如下组件选型备注CPURK3588 八核4×A76 4×A55big.LITTLE 架构天然适合异构调度NPU3×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绑 Core078%18% 波动中等多 ctx 绑核每模型独立 ctx绑不同 Core82%~85%5%~8% 波动基本无多 ctx 绑核是最终方案。三个模型各创建独立的rknn_context分别在初始化时指定 Core maskCORE_0、CORE_1、CORE_2。这样 NPU 调度器就不用在模型之间频繁切换每个模型独享一个 Core推理延迟的稳定性大幅度提升。这里有个细节值得注意不是模型越大就必须绑编号越大的 Core。我在实测中发现 Core0 和 Core1 在某些固件版本上存在微小主频差异所以把计算量最大的垃圾分类模型绑在实测表现最快的 Core0 上烟火检测绑 Core1人员入侵绑 Core2。2.2 算力与带宽的双重约束估算在真正部署前我做了个算力预算表把自己心里的账算明白RGB 1080P 图像输入到 YOLOv8s640×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 的两倍三个模型如果都上 FP16DDR 压力会陡增。量化的代价是精度但在多任务场景下可以接受。实测下来人员入侵检测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_platformrk3588, quantized_dtypew8a8, quantized_algorithmnormal, optimization_level3 )这里有两个坑。第一个是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 APIPython 只做工具链转换推理主循环完全用 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 CoreC1/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 的resizecvtColor去处理每一帧再做一次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你需要先用 RGARockchip 的 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 单模型单独跑和多模型并发时的性能差异有一个非常有意思的现象单模型单独跑时速度很快但三个模型同时跑时每个模型的速度都会有轻微下降。我特意做了对照实验模型单独跑耗时三模型并发耗时性能衰减人员入侵 YOLOv8n19ms24ms约 21%烟火检测 YOLOv8sCBAM43ms54ms约 20%垃圾分类 YOLOv8s分类头58ms76ms约 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.2C 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 的你有用。这块板子潜力很大只要掌握正确的姿势多任务边缘视觉系统的性能和稳定性可以做得很好。
