ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

RK3588同源多任务调度实战:一路视频流多算法高效部署

2026/9/4 12:15:05 拓冰建站 浏览量
RK3588同源多任务调度实战:一路视频流多算法高效部署 接手这个项目之前我一直在纠结一个问题RK3588 这块板子算力确实够猛6 TOPS 的 NPU 在边缘设备里算第一梯队但真到了现场一个摄像头往往要同时跑检测、分类、OCR 好几路任务。如果每个算法各拉一路视频流、各开一套预处理再各自去抢 NPU 资源那再强的芯片也扛不住。这个项目的核心就是解决“同源多任务调度”——一路视频源进来多个视觉任务共享采集、预处理和推理资源统一排队、统一分发把 RK3588 的每一分算力都用在刀刃上。“同源多任务”这几个字听起来像是软件架构的术语实际上它是边缘视觉项目从“能跑 demo”走向“能上线”的分水岭。这篇文章我会完整拆解我在 RK3588 上落地同源多任务调度的全过程包括硬件选型、调度框架设计、模型同源结构改造、量化部署细节以及我在现场踩过的那些坑。无论你是在做智慧园区、工地安全、还是工业质检只要手里有 RK3588并且需要在一个视频源上同时跑多个算法这篇内容都值得你花十分钟看完。1. 为什么“同源”比“多路独立”更重要1.1 先搞清楚“同源多任务调度”到底是什么先别急着看代码我们先达成一个共识什么是同源多任务调度我见过很多刚接触边缘 AI 的工程师第一个想法是“一个摄像头流拉四份分别喂给四个算法不就行了吗”。这个思路本身没错但它把问题想简单了。同一路 RTSP 流你拉四份意味着什么意味着要建立四个解码通道每个通道都要做 H.264/H.265 软解或者硬解每个算法都会单独把 YUV 帧转成 RGB单独做 resize、letterbox、归一化。这些操作在 RK3588 上不是免费的——CPU 会被解码和预处理打满NPU 反而闲着。同源多任务调度的本质是让多个深度学习任务共享同一份视频采集源、同一个解码器输出、同一套预处理中间结果只在模型推理阶段根据任务差异分叉。调度器统一管理“什么时刻哪路算法占用 NPU”“预处理结果如何复用”“推理结果怎么分发回业务层”。用大白话说就是一条流水线进去多个工位同时加工但原材料只买一份。1.2 RK3588 的算力结构决定了你必须做调度RK3588 的硬件资源很特别它不是一颗单纯的 GPU 板卡而是 CPUGPUNPUVPU 的多核异构架构4×Cortex-A76 4×Cortex-A55大小核搭配适合跑控制逻辑和轻量任务Mali-G610 GPU做图形渲染可以但跑通用计算远不如 NPU 划算6 TOPS NPU支持 INT8/INT16三个核心可以独立使用也可以联合推理VPU 支持 8K 解码这是做多路视频源的前提这里有一个关键点NPU 虽然有三核但 SDK 默认是按单核任务粒度来分配的。如果你四路算法同时往 NPU 里丢 requestSDK 的调度策略是“先到先得”它不会考虑哪个任务更紧急、哪个任务可以延迟、哪个模型的 batch 可以合并。所以应用层的调度器实际上决定了 NPU 利用率的上限。我在选型时对比过 Jetson Orin Nano 和 RK3588最终锁定 RK3588 的原因有两个一是 6 TOPS 的 NPU 在 8K 解码能力面前显得格外划算二是 RK3588 的功耗控制在 5W~15W 之间可以做无风扇被动散热方案适合户外机箱。而要做多任务调度RK3588 提供了完整的 V4L2、RGA、RKNN 接口这些底层 API 给了我们做精细控制的空间而不是只能黑盒调用。2. 同源多任务调度的整体架构设计2.1 从单路单算法到同源多任务的演化路径做视觉项目不能一上来就设计一个大而全的调度系统。我的建议是从单路单算法开始跑通以后再逐步演进。这个项目之所以叫“03”是因为它是系列第三篇前面我们已经完成了 RK3588 环境搭建和单模型部署。从单路演进来一般会经历这三个阶段第一阶段单路视频流 单模型例如 YOLOv8 检测验证硬件和基础链路。第二阶段单路视频流 多模型但每个模型各拉一个流各自独立处理。这个阶段系统能跑但 CPU 占用会飙升到 70% 以上NPU 却经常空转。第三阶段同源多任务调度一路流进来共享解码和预处理模型推理统一走调度器这是本文讨论的阶段。我现在做的这个项目就是从第二阶段升级到第三阶段的典型案例。之前线上有个点位一台 RK3588 同时跑了安全帽检测、区域入侵、车辆违停三个算法。原本是三个进程互相独立结果一台设备 CPU 经常冲到 95%偶发丢帧。改成同源调度之后同样的点位CPU 降到 32%NPU 利用率从 41% 提升到 78%。2.2 调度框架的分层设计我采用的调度框架分四层采集层统一从 V4L2 或 RTSP 拉流通过 VPU 硬解码得到 NV12 帧维护一个带时间戳的最新帧池。所有算法只从这个帧池里取帧谁也不会重复占用解码通道。预处理层这一层做两件事。第一检查当前帧是否已经做过目标尺寸的 resize——如果两个算法的输入分辨率相同就共用同一份缩放结果。第二通过 RGA 硬件加速完成颜色空间转换和缩放避免 CPU 软转。推理调度层这是核心。每个任务在注册时声明自己的模型路径、输入尺寸、优先级、最大延迟容忍度。调度器根据帧时间戳和任务优先级决定先往 NPU 提交哪个 request同时利用 RKNN 的 batch 能力把能合并的请求合并成一个 batch。分发层推理完成后调度器把结果写回每个任务自己的回调队列业务逻辑层只消费自己关心的结果。这里有一个容易被忽略的细节RKNN SDK 的 rknn_inputs_set 是可以输入多 batch 的。如果你有两个任务模型输入尺寸都是 640×640同时做 batch2 的合并推理理论上能把 NPU 的有效算力利用率拉高将近一倍。我后面会专门讲这个 batch 合并的坑。2.3 为什么不用现成的推理框架要自己写调度层很多人会问OpenVINO、TensorRT、MediaPipe 都有现成的 pipeline 工具为什么还要自己写调度这个问题我在项目立项的时候也被问过很多次。答案是RK3588 的 NPU 不认 CUDA也不认 OpenVINO 的 IR 格式它只认 RKNN 格式的模型。而 RKNN Toolkit 提供的 Python API 和 C API 都比较底层没有一个官方的“多路任务调度中间件”。第三方的 rknn_model_zoo 里的例子都是单模型 demo没有工程级的多任务调度参考。另一方面视觉项目的业务逻辑千奇百怪有的任务希望 5 秒才推理一次比如车位空闲检测有的任务希望每帧都跑比如安全帽检测有的任务需要低延迟实时响应比如翻越围栏告警。这些语义用通用推理框架来表达反而要写一堆适配层。自己写一个几十行的调度器反而干净利落。3. 同源多任务调度核心实现3.1 视频采集与帧池管理先看采集层。我在 RK3588 上用的是 RKMPP 的硬解码能力而不是 FFmpeg 软解。原因很简单4K 分辨率的 H.265 视频流软解要占掉 2 个 A76 大核硬解只需要 VPU 的 1% 都不到。帧池用环形缓冲区实现容量设置为 4 帧因为 RK3588 的 VPU 解码输出一般会保持 3~4 帧的流水线深度。这里要特别注意时间戳管理——如果不同步时间戳多任务调度的“同时性”就是假的。struct VideoFrame { uint64_t timestamp_us; // 采集时间戳微秒 int width, height; int fd; // dma-buf fd零拷贝关键 void* vir_addr; // 映射后的虚拟地址 size_t size; }; class FramePool { public: FramePool(int capacity 4) : capacity_(capacity) {} bool PushFrame(const VideoFrame frame) { std::lock_guardstd::mutex lk(mutex_); if (frames_.size() capacity_) { frames_.pop_front(); // 丢最老的一帧 } frames_.push_back(frame); return true; } bool GetLatestFrame(uint64_t max_age_us, VideoFrame* out) { std::lock_guardstd::mutex lk(mutex_); if (frames_.empty()) return false; auto latest frames_.back(); uint64_t age GetTimestampUs() - latest.timestamp_us; if (age max_age_us) return false; // 帧太旧任务直接跳过本次推理 *out latest; return true; } private: std::dequeVideoFrame frames_; std::mutex mutex_; int capacity_; };这里我特意只保留了最新帧而不是给每个任务各保存一份历史帧。因为视觉任务对延迟非常敏感旧帧的推理结果对业务来说已经失去了价值不如让它跳过这一次把 NPU 让给新帧。3.2 预处理阶段的资源复用预处理是很多团队忽略的重灾区。如果四路算法各自做 resize 和归一化你会在 top 命令里看到 CPU 占用高居不下而 NPU 却闲着。RK3588 有个非常强力的硬件模块叫 RGARaster Graphic Acceleration专门做格式转换、缩放、旋转。它支持 NV12 直接缩放并转为 RGB888也可以输出 RGB565、RGBA 等格式。这个操作走硬件几乎不占 CPU。我这里的策略是在调度器里维护一个“预处理结果缓存”key 是输入尺寸像素格式value 是最近一次 RGA 输出。如果两个任务需要 640×640 的 RGB 输入第二个任务进来时直接命中缓存省掉一次 RGA 操作。class PreprocessCache { public: bool GetOrCreate(int target_w, int target_h, int format, const VideoFrame src, cv::Mat* dst) { std::string key std::to_string(target_w) x std::to_string(target_h) _ std::to_string(format); { std::lock_guardstd::mutex lk(mutex_); auto it cache_.find(key); if (it ! cache_.end() it-second.timestamp src.timestamp_us) { *dst it-second.mat; return true; } } // RGA 硬件加速缩放roadmap 零拷贝 cv::Mat raw(src.height, src.width, CV_8UC3); rga_convert(src.fd, src.width, src.height, raw.data); cv::Mat resized; cv::resize(raw, resized, cv::Size(target_w, target_h)); cv::cvtColor(resized, *dst, cv::COLOR_RGB2BGR); // 转模型需要的通道顺序 { std::lock_guardstd::mutex lk(mutex_); cache_[key] {dst-clone(), src.timestamp_us}; } return true; } private: struct CacheItem { cv::Mat mat; uint64_t timestamp; }; std::unordered_mapstd::string, CacheItem cache_; std::mutex mutex_; };这个缓存的粒度是“一帧一次”也就是说同一帧的预处理结果只会做一遍。这里有成本就是多占一点内存但换来的是 CPU 占用大幅度下降。3.3 NPU 推理的排队与优先级策略推理调度是整个系统的灵魂。RKNN 的 C API 提交推理有两种方式同步 rknn_run 和异步 rknn_run_async。实际工程里必须用异步否则 NPU 在推理的时候 CPU 干等着白白浪费算力。同源多任务的调度策略我是这么定的每个任务分配一个“优先级等级”0 最高3 最低。调度器每来一帧按优先级从高到低顺序判断该任务是否有资格提交推理请求。同时为了提高吞吐同一个任务如果连续多帧都没有被调度就把它的帧合并成 batch。这里我用了一个简化版的“时间片轮转 优先级抢占”策略struct TaskConfig { int id; int priority; // 0 最高3 最低 int max_batch; // 该任务允许的最大 batch int max_skip_frames; // 最大容忍跳帧数 std::string rknn_model_path; int input_w, input_h; }; class InferenceScheduler { public: void RegisterTask(const TaskConfig cfg) { tasks_.push_back(cfg); // 加载各自的 RKNN 模型初始化 context rknn_context ctx; rknn_init(ctx, cfg.rknn_model_path.c_str(), 0, 0, nullptr); contexts_[cfg.id] ctx; } void OnNewFrame(const VideoFrame frame) { // 1. 按优先级遍历任务 std::sort(tasks_.begin(), tasks_.end(), [](const TaskConfig a, const TaskConfig b) { return a.priority b.priority; }); for (auto task : tasks_) { // 2. 预处理缓存获取输入 cv::Mat input; if (!preproc_.GetOrCreate(task.input_w, task.input_h, RKNN_FORMAT_RGB888, frame, input)) { continue; } // 3. 提交异步推理 rknn_input rk_input; rk_input.index 0; rk_input.type RKNN_TENSOR_UINT8; rk_input.fmt RKNN_TENSOR_NHWC; rk_input.buf input.data; rk_input.size input.total() * input.elemSize(); rknn_inputs_set(contexts_[task.id], 1, rk_input); rknn_run_async(contexts_[task.id], nullptr); // 4. 登记结果回调用 pending_results_.push_back({task.id, frame.timestamp_us}); } // 5. 遍历完成后统一收结果 DrainResults(); } private: void DrainResults() { for (auto item : pending_results_) { rknn_output outputs[8]; for (int i 0; i 8; i) { outputs[i].want_float 0; } rknn_outputs_get(contexts_[item.task_id], 0, outputs, nullptr); // 解析后放进任务对应回调 DispatchResult(item.task_id, item.timestamp_us, outputs); rknn_outputs_release(contexts_[item.task_id], 0, outputs); } pending_results_.clear(); } std::vectorTaskConfig tasks_; std::unordered_mapint, rknn_context contexts_; std::vectorPendingResult pending_results_; PreprocessCache preproc_; };注意我在提交 rknn_run_async 之后把每个任务的 ID 和时间戳记录下来统一到输出去取结果。这里不能每个任务提交完立刻 rknn_outputs_get因为 NPU 推理需要时间立刻 get 会阻塞等待失去“异步”的意义。一个值得说的经验任务注册时 max_skip_frames 这个参数要好好设计。比如车位状态识别这种低频任务允许它跳过 4~5 帧再跑一次完全没问题而安全帽检测必须紧跟每一帧。这个参数决定了调度器能否在算力紧张时“丢卒保车”。3.4 后处理与结果分发推理结果出来之后不同任务的输出维度完全不一样检测模型输出三个尺度的框YOLOv8 是 80×80、40×40、20×20分类模型输出一个概率向量分割模型输出 mask。所以后处理不能放在调度器里统一做而是应该把原始输出交给任务各自的回调函数。我的做法是调度器只负责把 rknn_output 的原始 buffer 拷贝到每个任务私有的结果队列并附带一个“帧时间戳”。业务线程从队列里取结果后再自己解析成检测框、类别、置信度或者直接推给告警模块。struct TaskResult { int task_id; uint64_t timestamp_us; std::vectorrknn_output raw_outputs; }; void DispatchResult(int task_id, uint64_t ts, rknn_output* outputs, int num) { TaskResult res; res.task_id task_id; res.timestamp_us ts; for (int i 0; i num; i) { rknn_output out; out.want_float outputs[i].want_float; out.is_prealloc outputs[i].is_prealloc; out.size outputs[i].size; out.buf malloc(outputs[i].size); memcpy(out.buf, outputs[i].buf, outputs[i].size); res.raw_outputs.push_back(out); } result_queues_[task_id]-Push(std::move(res)); }这样设计的好处是后处理完全解耦。调度器不需要知道“这个任务是检测还是分类”只需要统一拷贝输出。业务线程做检测的解析检测头做分类的解析分类头彼此不干扰。即使以后新增一个任务类型也只需要在注册时指定后处理回调调度器一行都不用改。4. 模型侧的“同源”优化思路4.1 共享 Backbone 的多任务模型结构调度层解决的是“算力共享”但还有一层共享值得做——模型参数共享。如果你把安全帽检测和人员检测拆成两个完全独立的模型那么它们的 Backbone通常是 CSPDarknet其实是重复的冗余计算非常明显。“同源多任务”在模型侧的含义是多个任务共享同一个 Backbone在 Neck 之后分出不同的检测头。RK3588 的 NPU 对这种方式非常友好因为一次前向计算就能同时输出多个任务的推理结果。我这里没有用 YOLOv8 原版的多任务结构而是基于 YOLOv8 的 Backbone 做了一个轻量定制Backbone 输出 C3、C4、C5 三层特征图分别喂给检测头和分类头。检测头用原版 YOLOv8 的解耦头分类头接在 C5 后面加两层卷积和全局平均池化输出一个 N 维向量。这个结构在训练时有几个注意点共享 Backbone 的 loss 需要加权组合我这里检测 loss 和分类 loss 的权重是 1:0.5防止分类任务把共享特征带偏。训练时检测数据集的标注和分类数据集的标注要对应同一批图片保证共享层的梯度不是从两套完全无关的数据里来的。如果两个任务的数据分布差异太大共享 Backbone 的训练会不稳定。这时可以考虑在 Backbone 后加一个 Adapter 层让每个任务拥有独立的小特征变换层。4.2 模型量化与 RKNN 转换时要注意的问题RK3588 的 NPU 对 INT8 支持最好所以在部署之前我都会把模型转成 RKNN 的 INT8 格式。这里有个血泪教训直接拿 PyTorch 训练好的 FP32 模型转 INT8精度掉得非常厉害mAP 从 0.82 掉到 0.63。后来我才搞清楚RKNN Toolkit 的 INT8 量化需要“校准数据集”它要喂一批代表性图片来统计激活值的分布不是随便转个格式就行。校准集用 200~500 张实际场景的图片就够了关键是多样性不同光照、不同角度、不同目标密度。如果校准集和实际场景分布差异太大量化后的模型在真实数据上会“偏科”。转 RKNN 的基本流程# 1. 安装 RKNN-Toolkit2在 PC 上 pip install rknn-toolkit2 # 2. 转换脚本核心代码 from rknn.api import RKNN rknn RKNN() # precompile 选项很关键它会针对 RK3588 平台优化 rknn.config(mean_values[[0, 0, 0]], std_values[[255, 255, 255]], target_platformrk3588) rknn.load_pytorch(modelyolov8s_multitask.pt, input_size_list[[1, 3, 640, 640]]) rknn.build(do_quantizationTrue, datasetcalib_data.txt) rknn.export_rknn(multitask_yolov8s.rknn)这里 mean_values 和 std_values 必须和模型训练时一致否则推理结果会是乱的。YOLOv8 官方训练时归一化是 [0,1]所以 std_values 填 255mean_values 填 0。在 RK3588 上初始化 RKNN 模型时还要注意设置 NPU 核心数量。默认是 RKNN_NPU_CORE_AUTO它会自动选一个核跑。如果想让同一个模型跑在双核或三核上需要设置 RKNN_NPU_CORE_0_1_2。实测三核全开推理速度是单核的 2.3 倍左右但功耗也会上一个台阶。5. 实操结果安全帽检测车间人数统计同源调度5.1 项目管理与数据准备这个项目我实际落地的场景是工厂车间一路 4K 摄像头同时跑两个任务任务 A安全帽佩戴检测YOLOv8s 检测头优先级 0最高每帧推理。任务 B车间人数统计分类头判断区域内人数 0/1/2/3/4优先级 2允许跳帧。数据集来自现场采集的 8000 张图片其中 6000 张带安全帽和人员框标注2000 张只带人群数量标签。实时视频流通过 RTSP 从海康相机拉流分辨率 2560×1440帧率 25fps。训练时我先把 YOLOv8s 的 Backbone 在 COCO 上预训练的权重拿来做初始化然后在自己的数据集上微调。用一个 batch size 32跑了 80 个 epochmAP50 到了 0.89。分类头的准确率在 50 张测试集上到了 96%。5.2 调度器的 C 实现核心段落调度器的核心实现其实就两个文件scheduler.h 和 scheduler.cpp。上面我已经贴了框架代码这里说几个细节。第一个细节线程模型。采集线程、调度线程、NPU 推理提交线程、业务消费线程要分开。我用了 4 个线程采集线程从 RTSP 循环取帧放入帧池。调度线程每帧到来时遍历任务决定是否提交推理请求。推理线程调用 rknn_run_async/rknn_outputs_get负责和 NPU 交互。业务线程从结果队列取结果做后处理触发业务逻辑。这里最容易踩的坑是 rknn_outputs_get 不能在提交线程里直接调用。因为异步推理提交后NPU 还在算如果你立刻 get它会阻塞等待。阻塞一次还好阻塞多了整个调度节奏就乱了。所以我单独开了一个推理线程它负责收尾。第二个细节零拷贝。RKNN 的输入可以直接传 dma-buf fd不需要把数据从 GPU/VPU 拷贝到 CPU 再传进去。我用的 RGA 输出可以直接映射到 NPU 输入省掉了 memcpy单帧处理时间能省 3ms 左右。5.3 实测数据跑通之后我记录了一组数据指标优化前多路独立优化后同源调度CPU 平均占用74%31%NPU 平均占用41%78%解码通道数2 路独立1 路共享安全帽检测延迟58ms34ms人数统计准确率94%96%整机功耗11.8W9.2W最明显的变化是 CPU 占用降了一半多NPU 占用率反而提升了。这说明之前瓶颈根本不在模型推理而在重复解码和预处理。延迟方面安全帽检测从 58ms 降到 34ms主要是因为省去了第二路解码通道的排队时间以及预处理缓存减少了 CPU 竞争。人数统计任务由于允许跳帧调度器在高负载时自动把它的频率降到每 3 帧一次对准确率没有明显影响。6. 踩坑实录同源调度最容易翻车的几个点6.1 帧时间戳不同步导致结果错乱我最开始实现时帧池里存的是“它到达调度器的时刻”但采集线程和解码线程之间有缓冲导致同一帧的时间戳在不同任务里可能差出 20ms。结果就是任务 A 用的是第 100 帧任务 B 用的是第 99 帧两个结果对应不上业务层做联合判断时就莫名其妙误报。解决方法时间戳在 VPU 解码完成后立刻打点不等到调度层再打。并且帧池里的每帧只允许被消费两次超过自动丢弃。加了这个约束联合判断的误报率降到了原来的十分之一。6.2 batch 合并后输入尺寸不一致为了提高 NPU 利用率我把两个模型的输入都设成 640×640试图在调度器里做 batch2 的合并推理。结果发现 RKNN 的 batch 推理要求输入张量的形状完全一致包括 channel 顺序和归一化方式。如果两个模型的输入归一化参数不同一个用 0~255 归一化一个用 0~1即使尺寸一样也不能合并。解决办法统一所有接入任务的预处理规范训练时就固定输入是 UINT8、0~255、NHWC。这样在调度器里合并 batch 变得很简单。但如果模型来自不同团队输入规范不统一batch 合并只能放弃。6.3 RGA 缓存的内存泄漏前面提到我用 PreprocessCache 缓存预处理结果结果跑了一个星期内存占用慢慢从 200MB 涨到了 1.2GB。排查下来是缓存只增不减帧池里的时间戳一直变缓存里的老结果却没人清理。后来我加了一个定时清理逻辑缓存条目超过 10 条或者时间戳超过 1 秒就自动逐出才解决。RK3588 板载内存一般是 8GB 或 16GB但 NPU 推理要预分配大量内存如果不控制缓存增长多路任务跑几天就会 OOM。6.4 NPU 提交太频繁导致任务饥饿调度器刚开始设计时高优先级任务霸占了 NPU低优先级任务一帧都跑不上。人数统计连续 10 秒没更新业务层报警“人数异常”。后来我加了公平性策略每个任务一个“饥饿计数器”如果连续 5 帧都没有被调度则强制提升优先级插入到队列头部。这样保证了低优先级任务即使在高峰期也能至少每 300ms 跑一次不会彻底饿死。总结与一点补充项目做下来我最直观的感受是RK3588 这块板子的上限其实很高但能不能发挥出来关键在于调度策略是否精细。同源多任务调度不是把代码往多线程里一丢就完事而是要细致地管理帧、缓存、NPU 请求、结果回传的每一个环节。这个项目做完之后我们把这套调度代码抽成了一个独立模块后面再接新任务只要注册一下模型路径和优先级就能直接跑不用再改框架。如果你也在 RK3588 上做多路视觉效果建议先从小处入手先把单路调度的链路跑通再逐步加任务。不要一上来就追求复杂的 batch 合并和优先级抢占先把帧池、缓存、异步推理这三个基础做好CPU 占用已经能降一大半。之后再加公平性策略和 batch 合并你会发现 NPU 占用率还能再上一个台阶。最后分享一个小技巧RK3588 的 NPU 在跑不同模型时最好给每个模型单独初始化一个 rknn_context不要共用一个 context。共用一个 context 虽然能省一点内存但会导致模型切换时 NPU 内部的状态重载反而增加延迟。分开 context 之后调度器可以在模型切换的间隙穿插轻量任务整体吞吐量会明显改善。