RK3588 NPU 多模型并发部署实战:目标检测与识别的单板调度方案
2026/9/8 18:06:09 网站建设 项目流程

导语

去年接到一个挺典型的边缘项目:园区周界要识别人员闯入,配电房和仓库区域要盯烟火,垃圾分类督导点还要能自动判断垃圾桶里投进去的是哪类垃圾。现场条件很直接——不给云服务器,不允许再塞一台主机,只有一台已经放在弱电间里的 RK3588 盒子,外加几路普通枪机摄像头。所有算法都得落到这一块板子上。

拿到需求的第一反应是有点慌。看了一些 AI 边缘设备的方案,大多是一个模型占一块板,三个业务就得上三台盒子。但项目预算和机柜空间都不允许这么干。RK3588 那颗 NPU 算力标称 6 TOPS,真要同时塞下目标检测、烟火识别、分类这三个任务,关键不在模型能不能跑,而在怎么让它们在同一块 NPU 上排队、错峰、不互相踩脚。这篇文章就把我最终跑通的完整方案捋一遍,从模型转换、量化、并发调度到实测踩坑记录,希望能给同样准备在 RK3588 上一机多用的朋友一点参照。

1. 项目背景:一块 RK3588 到底能同时干多少活

1.1 现场硬件与任务拆解

先看清楚手里的牌。RK3588 是瑞芯微的旗舰级 SoC,8 核 CPU 是大核 A76 加小核 A55 的 big.LITTLE 架构,内存最大支持 32GB LPDDR4/LPDDR5,但绝大多数开发板和边缘盒子常见是 8GB 或 16GB 版本。NPU 部分是三个核心,官方标称总算力 6 TOPS,支持 INT4、INT8、INT16 以及 FP16 等精度,模型一般通过 RKNN-Toolkit2 转换后部署。

本次项目实际需要运行的 AI 任务有三个:

  • 人员入侵检测:对监控画面中的行人目标进行定位,判断是否进入布防区域,输出目标框和置信度。该任务对空间定位精度要求不高,但要求漏报率尽量低。
  • 烟火检测:实时识别画面中的明火和烟雾。烟雾目标形状不固定、边缘模糊,且对小目标相对不敏感,在算力分配上要适当给足模型容量。
  • 垃圾分类识别:对指定投放点画面中出现的垃圾袋、饮料瓶、纸箱等物体进行分类,输出类别标签。大多数时候属于高频率但低算力消耗的任务。

这三个任务对模型输入分辨率要求也完全不同。人员检测可以用 640×640 输入,烟火检测为了保证小目标召回也用 640×640 比较稳,垃圾分类则直接用 224×224 左右的轻量分类模型即可,甚至不需要做检测框。

1.2 初始方案评估:为什么没有选择三台设备

一开始评审会里确实有人提出:三台 RK3588 盒子各跑一个模型,稳定且互不影响。这个方案从技术风险角度看最省事,但现场条件不答应。首先是成本翻了接近三倍,其次是机柜容量和供电线路已经预留好,无法再增加设备数量。

另一个替代方案是全部丢到云端识别,但这涉及园区视频流外发合规问题,尤其是针对人员行为识别这类带有管理属性的业务,客户的 IT 部门明确要求数据不出园区。所以最终只能走单板多任务这条路。

单板多任务在理想情况下似乎是三个模型轮流跑就能实现,但实际没那么简单。每个模型都要先做视频解码、帧预处理、NPU 推理、后处理、业务逻辑上报,如果简单把所有环节串起来跑一遍,那总耗时等于三个模型耗时的叠加,烟火检测的实时性会变得完全不可接受。所以核心问题从一开始就不是“模型能不能跑”,而是“计算流水线怎么设计”。

1.3 三个模型的关键约束指标

为了让后续调度有明确依据,我在项目启动第一周就和业务方确认了各项性能指标:

任务模型输入最低分析频率最大可接受延迟优先级
人员入侵检测640×6405 FPS500ms
烟火检测640×6402 FPS800ms最高
垃圾分类识别224×2241 FPS1500ms

这三个指标直接决定了一个关键事实:烟火检测单帧跑起来最慢,但频率要求其实很低,人员检测更看重响应速度,垃圾分类帧间隔最长。如果能设计好采样节奏,单块 NPU 的 6 TOPS 算力远远够用。这也是为什么我不建议一上来就把所有模型都调到最高帧率,那样内耗很大,实际业务收益却几乎为零。

2. 模型选型与端到端架构设计

2.1 三个模型分别用什么结构

模型选型的时候我参考了不少同类项目。人员入侵检测是最成熟的场景,直接选了 YOLOv8n,参数量小、推理速度快、对行人这类中大型目标表现不错,转换成 RKNN 后算子支持也完整。烟火检测用 YOLOv5s 做了一次微调训练,原因有两个:一是 YOLOv5s 的 Neck 结构对于烟雾这类边缘模糊目标有更好的语义融合能力,二是烟火样本本身少,YOLOv5s 相对好收敛,不容易过拟合。

垃圾分类没有采用目标检测,因为投放点摄像头通常是固定角度,垃圾物体基本在画面中央区域,直接把画面裁剪后交给一个分类模型就可以了。最终用的是 MobileNetV3-Small,224×224 输入,模型权重只有几 MB,量化为 INT8 后对 NPU 来说几乎不占时间。

这里有一个重要的经验:不同任务不要盲目用同一套模型架构。有人图省事,把垃圾分类也用一个 YOLO 检测模型去跑,结果就是明明只需要输出一个类别,却白白计算了边界框回归损失和大量 anchor 分支,推理时间多了好几倍。在算力受限的边缘设备上,任务复杂度要和模型容量严格匹配。

2.2 端到端数据流,不能只盯着 NPU

整条端到端链路的瓶颈往往不在 NPU,而在视频解码和预处理。板端视频流通过 RTSP 拉流后,一般先用 FFmpeg 或 Rockchip 的 MPP 硬解码模块转成 YUV 帧,再调用 RGA 做缩放和格式转换。这三个任务其实可以复用同一路解码结果:一帧原始图像拉出来后,分别缩放到 640×640 和 224×224 再喂给不同模型。

最初团队里有人设计成“每路摄像头解码一次,再按 AI 任务复制三份 YUV 数据”,这种做法非常浪费 DDR 带宽。正确的做法是共用一份解码帧,用 RGA 分别做缩放。RK3588 的 RGA 是专门做图像转置缩放的硬件模块,不占 NPU 算力,同时缩放多份小图非常快,基本在 1ms 到 2ms 内完成。

2.3 边缘一体机的优势,以及可复用的其他场景

这次选择单板三重任务的另一层原因是后续还要扩展。项目里如果后续加入车辆违停识别或垃圾桶满溢检测,也就是再增加一两个轻量模型的问题。因为架构从第一天就按“共享输入帧 + 互相独立的分析任务”来设计,新增任务只需要加一个推理线程和一份后处理逻辑。

这种玩法很常见:商场监控要同时跑客流统计、口罩检测、电梯困人识别;工厂产线要同时跑安全帽检测、工服检测、违规操作识别。本质上都是“环境感知是同一个摄像头,业务目标是多侧并行的”。如果只盯着“模型文件数量”而不是“计算流水线”,大概率会把每一路视频流当成独立的完整链路来处理,最后把板卡资源白白消耗在重复解码上。

3. 模型转换、量化与运行时参数,这些坑要提前避开

3.1 RKNN-Toolkit2 的标准转换流程

选好模型后,第一件实操就是转换 RKNN 格式。RKNN-Toolkit2 跑在 PC 上,它本身不依赖开发板,只需安装对应版本的 Python 环境即可。以 ONNX 格式为例:

from rknn.api import RKNN rknn = RKNN() # 第一步:加载 ONNX 模型 ret = rknn.load_onnx(model='yolov8n_person.onnx') if ret != 0: print('模型加载失败,先检查算子是否全部支持') # 第二步:配置量化参数 rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588', quantized_dtype='w8a8' ) # 第三步:构建 RKNN 模型 ret = rknn.build(do_quantization=True, dataset='calib_person.txt') if ret != 0: print('构建失败') # 第四步:导出模型 rknn.export_rknn('person.rknn')

这里参数看起来简单,但有一个很容易踩的坑:mean_values 和 std_values 必须和你训练模型时做预处理的方式一致。比如训练时你用 ImageNet 标准归一化(均值 0.485,0.456,0.406,方差 0.229,0.224,0.225),那配置里就不能简单写成 0 和 255。尤其是 YOLO 系模型,很多代码是在训练时用 0~1 进行归一化并由后处理自行恢复,这种情况下如果忘掉 mean/std 会导致量化后精度崩掉。

3.2 检测模型和分类模型的量化不能一锅端

在量化环节,三个模型要分开处理。人员检测和烟火检测属于回归加分类任务,输出目标框坐标和类别,对边界框的回归精确度比较敏感,如果量化校准集覆盖不充分,很容易出现目标框偏移、置信度下降的问题。垃圾分类模型是纯分类任务,类别概率输出对量化误差相对鲁棒,只要校准集里每个类别都有一定数量的样本就能保持精度。

给每个模型建独立的校准集非常重要。RKNN 量化需要一个校准数据文件,里面写的是图片路径清单,每一行一张图片,通常准备 100 到 300 张足够。关键是对应场景的多样性:人员检测的校准集要包含远距离小人、弯腰、逆光等画面;烟火检测的校准集要把烟雾和白色云朵的近似样本都放进;垃圾分类的校准集要保证覆盖所有类别,而不是只找容易分类的样本。

三份校准集在转换时要分别传给对应的rknn.build()调用。我见过有同学为了让流程简单,把三个模型用同一份校准集做量化,结果烟火检测的烟雾置信度掉了一半以上。虽然最后可以通过降低置信度阈值来兜底,但误报也会大幅上升,完全得不偿失。

3.3 运行时参数:core_mask 到底该怎么理解

RK3588 的 NPU 有三个核,RKNN 运行时支持通过 core_mask 指定使用哪些核。官方在rknn.init_runtime()接口里提供了RKNN.NPU_CORE_0RKNN.NPU_CORE_1RKNN.NPU_CORE_2以及组合值。

有的资料会建议把三个模型分别绑定到三个核上,看起来像是每个模型独占一个 NPU 核。实际在项目早期我也这么试过,代码长这样:

rknn_person.init_runtime(core_mask=RKNN.NPU_CORE_0) rknn_fire.init_runtime(core_mask=RKNN.NPU_CORE_1) rknn_waste.init_runtime(core_mask=RKNN.NPU_CORE_2)

但实测后发现,core_mask 更准确的理解是“当前模型优先在指定核心上调度”,并非严格独占。因为本质上三个模型仍然共享 NPU 的指令队列和 DDR 带宽,如果某个模型的输入帧宽度很大,数据搬移依然会影响另外两个核。这一点是理解 RK3588 多模型并发的关键。

所以我最终推荐的做法是:在低并发负载下用默认的RKNN.NPU_CORE_AUTO,让驱动自己调度核心,真正靠上层任务帧率策略来减少冲突;只有当长期 CPU 占用偏高或者发现某个大型模型频繁抢占时,才利用 core_mask 做人工隔离。理由后面在并发调度部分会展开说明。

4. “同时跑”的正确姿势:不是多核并行,而是时间片错峰

4.1 NPU 调度的本质,一条排队队列

在进入编码阶段前,我先做了一个模型延迟单测,用同一台板子分别跑三个模型,得到大致耗时数据后,很多人都会拿这些数据做加法,算出“总共需要多少 TOPS”,然后说 NPU 算力不够。但这个加法本身是错的。

RK3588 的 NPU 工作方式更接近于“接收推理请求 - 进入执行队列 - 驱动程序分配到空闲核心 - 计算结果返回”。模型 A 推理时,模型 B 完全可以在等待队列里等待,当 A 完成前一个 batch,B 就可以立刻补上。真正要优化的不是让每个模型都“同时”占着 NPU,而是让每个模型在时间轴上尽量均匀错峰,避免出现三个任务同时把请求塞进队列的高峰期。

一句话总结:NPU 不是一台带三个独立通道的交换机,更像一个食堂,窗口只有一个,三份订单要想不打架,得学会分批下。

4.2 三级任务调度策略是怎么定的

我把任务调度拆成三层:

  • 帧采样层:各自规定任务多久跑一次。由于垃圾桶分类对实时性要求最低,我设置为每 1 秒执行一次;烟火检测每 500ms 一次;人员检测每 200ms 一次。
  • 推理触发器:用一个定时器或循环间隔,触发对应的推理线程。三路触发时间尽量错开,比如人员检测在 0ms、200ms、400ms 触发;烟火检测在 100ms、600ms 触发;垃圾分类在 300ms 触发。这样从宏观时间轴上几乎不会出现两个线程同时发起大模型推理。
  • 结果发布层:模型推理完成后,各自独立做后处理和结果上报。后处理在 CPU 上执行,与 NPU 推理天然异步,不会阻塞后续帧。

用代码框架来理解就是:

import threading import time def person_loop(): while True: frame = latest_frame() results = rknn_person.inference(frame) handle_person_results(results) time.sleep(0.2) def fire_loop(): while True: frame = latest_frame() results = rknn_fire.inference(frame) handle_fire_results(results) time.sleep(0.5) threading.Thread(target=person_loop, daemon=True).start() threading.Thread(target=fire_loop, daemon=True).start()

这里最容易被忽略的是latest_frame()去拿最新帧的时机。如果某次 NPU 排队时间较长,模型拿到的是 100ms 前的老帧,这不一定是问题,前提是后端业务能接受这个延迟。但如果你拿最新帧后发现上一帧还没算完,建议直接丢弃旧帧,而不是继续往队列里压帧,因为堆积只会让画面越来越卡。

4.3 为什么绑定单核不一定比自动调度快

项目后期我专门做过对比测试。第一种方案是三个模型分别用 core_mask 绑定到 NPU_CORE_0、1、2;第二种方案是都用NPU_CORE_AUTO,统一交给驱动调度。

实测结果很有意思:在单人单卡的空载场景下,两种方案差距很小。但把 CPU 后处理加上,再叠加多路视频解码后,绑定单核的方案反而偶尔出现 15% 的性能抖动,因为某些核心在排队,而另一些核心空闲,驱动无法跨核做任务重排。

所以后来我把代码改成了默认NPU_CORE_AUTO,只把最重的烟火检测模型单独通过配置项允许手工指定核心,作为调试开关保留。对多数项目来说,自动调度加任务错峰,远比手动绑定核心更可靠。

4.4 CPU 端要做的事,别让后处理拖了后腿

NPU 推理只占整个耗时的一部分。以 YOLOv8n 640 输入为例,NPU 推理大约 25ms,解码和缩放大约 4ms,后处理——包括解码模型输出、NMS 过滤、阈值判断——反而可能占到 15ms 以上。如果三个任务的后处理都在同一个线程里做串行处理,整体延迟照样会被拖垮。

我是这样安排的:三个推理线程各自持有独立的后处理线程,推理完成后把原始输出放入一个很小的结果队列,后处理线程取走异步计算。这样即使垃圾分类模型后处理中出现小概率阻塞,也不会影响烟火检测的下一帧推理。实际在 Python 里使用threading就够了,但如果对性能极限有要求,最好换成 C++ 加线程池,或者直接用 RKNN 的 C API。

5. 实测性能数据与调优记录

5.1 单模型独立跑测出的基线数据

整个系统搭建完成后,我先关掉调度,分别独立测了三份模型的实际性能。测试条件:RK3588 开发板 8GB 内存,系统 Ubuntu 20.04 桌面版关闭桌面环境以节省显存,摄像头输入为 1080p 25fps 的 RTSP 流,测试运行在同一路视频流之上。

各模型独立运行数据如下:

模型算法类型输入分辨率单帧推理耗时独立帧率配置
人员入侵检测YOLOv8n640×640约 26ms约 35 FPSINT8 量化
烟火检测YOLOv5s640×640约 48ms约 20 FPSINT8 量化
垃圾分类识别MobileNetV3-Small224×224约 5ms约 150 FPS+INT8 量化

注意这里“独立帧率”是极端情况下的数字,实际业务根本不需要这么高。YOLOv8n 如果长期跑 35 FPS,板卡功耗和发热都会明显上升,散热不好的机箱里很容易触发温度降频。

5.2 加入并发调度后的综合表现

按照上面 4.2 的调度参数,三个模型同时运行时,得到的输出节奏如下:

任务目标频率实测平均输出间隔最大单帧延迟说明
人员入侵检测5 FPS约 205ms320ms正常
烟火检测2 FPS约 505ms600ms正常
垃圾分类识别1 FPS约 1005ms1250ms正常

整机 CPU 占用率约 40% 到 65%,内存占用约 3.2GB,NPU 的平均占用率在 55% 上下。之所以 CPU 占用偏高,主要原因是后处理和 Python 运行时开销,后来我把垃圾分类模型切换到 C 接口后,CPU 降了大约 10 个百分点。

从数据能明显看出一个结论:即使总负载远超单个模型的算力需求,只要在时间轴上做错峰,排队延迟对任务自身的影响是可控的。真正要担心的不是算力不足,而是某些任务在下一次采样到来时,上一次推理还没结束,导致任务整体周期越来越长,最终形成“雪崩式延迟”。

5.3 两次比较关键的调优操作

第一次调优是减少无意义的输入缩放。最初团队里有人把每帧原始 1080p 图像送入模型之前,先缩放成一整套统一尺寸,再对不同模型各自裁剪。这个操作看起来很省事,但 RGA 在高分辨率缩放上要花费比模型推理本身还高的时间。改成各自直接缩放后,整体延迟下降了近 12%。

第二次调优聚焦在 YOLOv5s 的 NMS 阈值。烟火检测任务里烟雾和火焰通常不会是高密度小目标,但原始模型置信度偏低,把置信度阈值从 0.45 降到 0.25 后,召回明显上升。不过副作用是误报增多,于是我又额外加了一条后处理规则:同一检测框连续 3 帧都命中,才认为是一次有效烟火事件。这套“低阈值加多帧确认”的组合对动态场景非常有效。

5.4 如何判断 NPU 是否真的到瓶颈了

很多人在调优时习惯盯着htop或者/proc/loadavg看 CPU 负载,但 NPU 的使用率在标准 Linux 工具里是看不全的,RKNN 在运行日志里会输出每一次推理耗时。更直接的办法是开一个长期统计脚本,每隔 1 分钟统计一次最近 N 次推理的平均耗时。如果平均耗时相对单测数据突然翻倍,说明模型在排队或者系统降频了。

还有一个容易混淆的点:画面抖动的源头往往是内存带宽不够而不是 NPU 不够。三个模型并发运行时,每帧输入图像要从 DDR 搬到 NPU,模型参数和中间特征图也在 DDR,如果内存只有 4GB 或系统还开了图形桌面,内存交换很容易拖慢一切。项目里使用的 8GB 内存版本跑满三模型后还剩了近半内存,基本不用为内存焦虑。

6. 常见问题与排查技巧实录

6.1 模型初始化和加载阶段就报错

常见报错之一是E rockchip: failed to load rknn model或者“找不到 librknnmrt.so”。前者多半是 RKNN 模型文件损坏或版本不匹配,重新导出一次即可;后者通常是因为板端没有设置动态库路径。解决方式是确认是否用sudo ldconfig刷新过库目录,或者直接在启动脚本中写上:

export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH

还有一种情况是开发板系统刚刷完固件,NPU 驱动版本和模型转换工具版本不一致。RKNN 的模型文件向下兼容做得一般,最好让板端 NPU 驱动版本和 PC 端 RKNN-Toolkit2 版本保持同一大版本,跨了大版本很容易出现算子不支持或初始化失败。

6.2 INT8 量化后烟火检测精度下降明显

烟火检测模型独立跑 FP16 时表现不错,一量化成 INT8 后烟雾漏报率明显上升。排查下来发现最根本的原因是校准集里烟雾样本太少,而且分布不均匀,大部分样本是晴空背景下的远景烟雾,缺少中景、近景和强逆光数据。

解决办法是重新整理校准集,从训练集里分层抽样,确保近景、远景、室内、室外、夜晚各占一定比例。如果这样还不行,可以退一步为烟火模型单独设置混合精度量化,把模型最后的输出层保留为 FP16。在 RKNN-Toolkit2 中使用混合精度功能时,要手动指定敏感层的精度,操作成本并不高,能换来约 3% 的准确率提升。

6.3 多任务并发后,某些模型推理时间突然翻倍

这种问题排查优先级我先看内存。用free -h查看可用内存,如果剩余很少,先处理内存占用问题。再看任务触发时间是否发生了“同频共振”,比如原来固定 200ms 的任务和固定 400ms 的任务刚好是倍数关系,运行一段时间后两个任务会周期性撞在一起。

解决办法是在触发时间中加入随机抖动:

time.sleep(period * (0.9 + random.random() * 0.2))

设置合理范围内的随机抖动可以显著降低多路任务在同一时刻发起推理的概率。这是一种很实际但少有人写的技巧,尤其在多路摄像头和多模型同时运行的项目里格外好用。

6.4 摄像头出现码流延迟或花屏

摄像头侧的问题用系统后处理很难完全规避。我遇到过几次板端网络被其他服务占满,RTSP 拉流出现丢包,导致画面花屏。可以先单独测一下板子到摄像头的延迟:用 VLC 直接拉流看缓冲时间,如果延迟超过 300ms,说明链路本身有问题。尽量不要在系统里使用过多占用网卡的进程,也不要给每路摄像头单独设置过大的接收缓冲,加大缓冲反而会放大延迟。

6.5 垃圾分类长期运行后模型结果漂移

分类模型的输入是固定画面,摄像头长时间角度变化或者脏污遮挡会让识别率下降。业务方一开始不接受“每天定时清洗摄像头”的运维方案,所以在代码里加了一个视觉质量检测模块:判断当前帧的平均亮度、清晰度和画面偏移,如果连续多帧模糊或过曝就报警,而不是跑模型硬撑。这个模块用一个轻量级 OpenCV 函数就能完成,CPU 开销很小。

7. 一些额外的体会

如果只让我挑一句话给准备做同类项目的人,我会说:不要按“一个模型独占一台设备”的思路去想问题,边缘设备上了 NPU 以后,真正该设计的是任务调度和数据流。

好几个同行在聊 RK3588 时特别喜欢盯着算力数字看,算完就觉得 6 TOPS 不够用。其实目标检测、烟火识别、垃圾分类这些任务在实际业务中几乎不需要每秒处理 25 帧,对安防场景来说,5 FPS 甚至 2 FPS 已经是可用的水平。单块 RK3588 的并发能力被很多人低估了,但前提是你得把每个模型的触发频率、优先级、等待队列和 CPU 后处理都规划好。

另一个比较深的体会是,板端 AI 项目超过一半的时间都花在调试工具链和排队问题上,真正调模型结构的时间反而少。RKNN 工具链每年都在变,网上很多旧教程里的接口名已经失效,遇到问题最好的习惯是先看板端 SDK 目录下的官方示例,再把模型逐步缩小范围去定位算子兼容性问题。

最后再分享一个操作层面的小技巧:在 RK3588 板子上调试多模型时,尽量在后台用dmesg盯着 NPU 驱动日志,遇到 NPU 异常挂起可以直接看到是哪个模型触发的。不要盲目重启,有日志的排查效率会高得多。这套方案最终在项目现场跑了两周多,中途还顺手加了第 4 个模型,整个过程因为底座设计得够灵活,新增任务只花了一个下午就联调完毕。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询