RK3588双路视觉方案:共享线程池与NPU并行推理优化实践

发布时间:2026/10/31 10:38:15
RK3588双路视觉方案:共享线程池与NPU并行推理优化实践 1. 双路视觉方案的整体设计思路1.1 为什么要在RK3588上做双路视觉香橙派5搭载的RK3588芯片最被低估的能力之一就是它的多路摄像头输入与NPU并行推理能力。很多人拿到板子之后跑通一路YOLOv5s就收工了觉得6TOPS的NPU算力不过如此。但实际上单路推理时NPU的利用率往往只有30%到40%剩下的算力全在空转。双路视觉方案的核心目的就是把这部分闲置算力榨出来。我最初做双路方案的动机很直接一个摄像头看前方一个摄像头看下方用于小型移动平台的视觉感知。两路画面都需要实时跑YOLOv5s做目标检测如果串行处理帧率直接砍半根本没法用。所以必须让两路推理并行起来同时还要保证CPU不会因为线程调度而爆掉。这里就引出了一个关键问题并行不等于开两个线程各跑各的。如果只是简单地创建两个独立线程每个线程各自持有自己的RKNN上下文你会发现内存占用翻倍模型加载时间翻倍而且两个线程会互相抢NPU资源实际帧率提升非常有限。正确的做法是引入一个共享线程池让两路视觉任务共用同一组推理线程和同一批RKNN上下文。1.2 共享线程池方案的核心优势共享线程池这个思路说白了就是“任务排队、线程复用”。两路摄像头各自采集帧把帧封装成任务丢进同一个队列线程池里的工作线程从队列里取任务、执行推理、返回结果。这样做的好处有几个第一RKNN上下文只需要初始化一次。每个RKNN上下文都占用一定的NPU内存和驱动资源如果每个线程都初始化一份不仅浪费内存还可能导致NPU驱动层面的资源冲突。共享线程池下每个工作线程持有自己的RKNN上下文但线程数量是固定的不会随摄像头路数增加而膨胀。第二负载自动均衡。假设A摄像头画面复杂、推理耗时长B摄像头画面简单、推理耗时短共享队列的情况下空闲的工作线程会自动去取B的任务不会出现A线程忙死、B线程闲死的情况。第三便于统一管理生命周期。线程的创建、销毁、暂停、恢复都在一个地方控制调试和排查问题时不用在多个线程之间来回跳。我实测下来在香橙派5上双路1080p输入、YOLOv5s模型、共享4个工作线程的配置下两路合计能稳定跑到25到30FPS而单路独占时也就18到22FPS。这个提升对于实际应用来说是非常可观的。1.3 方案的整体架构拆解整个方案可以分成四层采集层两路MIPI摄像头或USB摄像头各自独立采集帧通过V4L2接口获取图像数据。预处理层将采集到的帧做缩放、格式转换比如从NV12转RGB、归一化整理成RKNN输入需要的格式。推理层共享线程池 任务队列 多个RKNN上下文这是整个方案的核心。后处理与输出层对推理结果做NMS、坐标映射然后送显示或通过网络发送。这四层之间通过队列和回调衔接采集层不直接调用推理层而是把任务投递到队列里实现了解耦。这样做的好处是如果推理暂时跟不上采集层不会阻塞而是丢弃旧帧或等待具体策略可以配置。注意采集层和推理层之间的队列长度一定要设上限否则内存会无限增长。我一般设为2到4帧超过就丢最旧的帧保证实时性优先。2. 线程池的核心细节与RKNN上下文管理2.1 线程池的基本结构设计线程池这个东西网上有大量通用实现但用在RK3588的NPU推理场景下有几个特殊点需要处理。通用的线程池通常只关心任务队列和线程数量但在这里每个工作线程还需要绑定一个RKNN上下文。我的做法是线程池在初始化时创建N个工作线程每个线程在启动时调用rknn_init加载模型持有自己的rknn_context。线程的主循环是从阻塞队列取任务 - 执行推理 - 将结果放入结果队列 - 继续取下一个任务。这里有一个容易踩的坑RKNN上下文的初始化必须在工作线程内部完成不能在主线程初始化好之后传给工作线程。原因是RKNN的上下文和线程有绑定关系跨线程使用会导致不可预期的行为。我一开始就是主线程初始化了4个上下文然后分给4个工作线程结果跑起来偶尔会崩溃查了很久才发现是这个原因。// 工作线程的入口函数示意 void worker_thread(int thread_id) { rknn_context ctx; int ret rknn_init(ctx, model_data, model_size, 0, nullptr); if (ret ! RKNN_SUCC) { printf(thread %d rknn_init failed\n, thread_id); return; } // 绑定NPU核心后面会详细说 rknn_set_core_mask(ctx, RKNN_NPU_CORE_AUTO); while (running) { Task task; if (!task_queue.pop(task)) break; // 队列关闭时退出 process_task(ctx, task); result_queue.push(task.result); } rknn_destroy(ctx); }2.2 阻塞队列的选择与实现线程池的阻塞队列选择直接影响到推理的延迟和吞吐。常见的做法有三种std::queuemutexcondition_variable、无锁队列、以及std::deque配合条件变量。我推荐用有界阻塞队列基于std::dequemutex 两个条件变量一个用于通知非空一个用于通知非满。为什么不用无锁队列因为在RK3588这种ARM平台上无锁队列的原子操作开销并不比互斥锁小多少而且无锁队列的实现复杂度高容易出bug。在有界队列下互斥锁的竞争其实很小因为生产者和消费者的速度差距不会特别大。队列的长度设置很关键。设得太小采集线程会频繁阻塞设得太大延迟会累积。我的经验值是队列长度 工作线程数 × 2。比如4个工作线程队列长度设为8。这样既能保证工作线程不会饿死又不会让延迟超过两帧。templatetypename T class BoundedQueue { public: explicit BoundedQueue(size_t cap) : capacity_(cap), closed_(false) {} bool push(const T item) { std::unique_lockstd::mutex lock(mtx_); not_full_.wait(lock, [this] { return queue_.size() capacity_ || closed_; }); if (closed_) return false; queue_.push_back(item); not_empty_.notify_one(); return true; } bool pop(T item) { std::unique_lockstd::mutex lock(mtx_); not_empty_.wait(lock, [this] { return !queue_.empty() || closed_; }); if (queue_.empty() closed_) return false; item queue_.front(); queue_.pop_front(); not_full_.notify_one(); return true; } void close() { std::unique_lockstd::mutex lock(mtx_); closed_ true; not_empty_.notify_all(); not_full_.notify_all(); } private: std::dequeT queue_; size_t capacity_; bool closed_; std::mutex mtx_; std::condition_variable not_empty_; std::condition_variable not_full_; };2.3 RKNN上下文与NPU核心绑定策略RK3588的NPU有三个核心可以单独使用也可以组合使用。在双路视觉方案中核心绑定策略直接影响推理速度。我试过几种方案绑定策略单路延迟双路总吞吐适用场景全部AUTO28ms22FPS简单场景省心每线程固定单核35ms28FPS双路并行推荐双核绑定单核绑定25ms/38ms26FPS主次分明三核全绑22ms18FPS单路极致性能实测下来每个工作线程绑定一个独立的NPU核心在双路场景下总吞吐最高。因为两个线程同时推理时如果都走AUTONPU驱动会在核心之间做调度反而增加了开销。固定绑定后每个线程独占一个核心互不干扰。具体做法是在rknn_init之后调用rknn_set_core_mask传入RKNN_NPU_CORE_0、RKNN_NPU_CORE_1、RKNN_NPU_CORE_2分别给不同的线程。如果线程数超过3个多出来的线程就共享核心或者走AUTO。提示RK3588的NPU核心0和核心1是独立的核心2在某些型号上与核心0共享部分资源。如果做三路以上建议先查一下具体型号的数据手册。2.4 任务粒度与批处理权衡任务粒度是指每次投递到队列的任务包含多少帧。最细的粒度是一帧一个任务最粗的是多帧打包成一个任务。粒度选择影响延迟和吞吐。一帧一任务的好处是延迟低采集到一帧就立刻投递工作线程马上处理。坏处是任务切换开销大每次都要加锁、通知、取任务。多帧打包的好处是吞吐高一次处理多帧NPU的利用率更高。坏处是延迟增加要等攒够帧才能处理。在双路视觉场景下我建议一帧一任务因为实时性优先。如果做的是离线视频分析可以打包成4帧或8帧一个任务吞吐能提升15%左右。3. 实操过程与核心环节实现3.1 环境准备与依赖安装在开始写代码之前先把环境搭好。香橙派5的官方系统是基于Ubuntu 20.04或22.04的RKNN Toolkit2的版本要用1.5.0以上低版本对RK3588的支持不完善。# 更新系统 sudo apt update sudo apt upgrade -y # 安装基础依赖 sudo apt install -y cmake g libopencv-dev python3-opencv \ libv4l-dev v4l-utils pkg-config # 检查NPU驱动版本 cat /sys/kernel/debug/rknpu/version # 应该输出类似RKNPU driver version: 0.9.6RKNN Toolkit2的安装官方提供了whl包注意要选对Python版本。香橙派5上默认是Python 3.8或3.10对应下载不同的whl。# 以Python 3.8为例 pip3 install rknn_toolkit2-1.5.0-cp38-cp38-linux_aarch64.whl # 验证安装 python3 -c from rknn.api import RKNN; print(ok)模型转换这一步YOLOv5s的onnx模型转rknn有几个参数必须注意。mean_values和std_values要和训练时一致target_platform要设为rk3588量化数据集要用真实场景的图片不要用COCO的通用图片否则量化精度会掉。# 模型转换脚本关键部分 rknn RKNN() rknn.config( mean_values[[0, 0, 0]], std_values[[255, 255, 255]], target_platformrk3588, quantized_dtypeasymmetric_quantized-8, optimization_level3 ) rknn.load_onnx(modelyolov5s.onnx) rknn.build(do_quantizationTrue, dataset./quant_dataset.txt) rknn.export_rknn(./yolov5s.rknn)3.2 双路摄像头采集与帧同步双路采集有两种方式一种是两个独立的采集线程各自打开一个摄像头设备另一种是用一个采集线程轮询两个设备。我推荐前者因为V4L2的read或mmap操作是阻塞的单线程轮询会导致一路等待时另一路无法采集。每个采集线程的流程是打开设备 - 设置格式分辨率、像素格式- 申请缓冲区 - 循环取帧 - 投递任务。void capture_thread(int cam_id, const std::string dev, BoundedQueueFrame queue) { int fd open(dev.c_str(), O_RDWR); // 设置格式为NV121920x1080 struct v4l2_format fmt {0}; fmt.type V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width 1920; fmt.fmt.pix.height 1080; fmt.fmt.pix.pixelformat V4L2_PIX_FMT_NV12; ioctl(fd, VIDIOC_S_FMT, fmt); // 申请缓冲区并映射... while (running) { // 取帧 struct v4l2_buffer buf {0}; buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; ioctl(fd, VIDIOC_DQBUF, buf); Frame frame; frame.cam_id cam_id; frame.data buffers[buf.index]; frame.timestamp get_timestamp(); queue.push(frame); ioctl(fd, VIDIOC_QBUF, buf); } }帧同步这个问题在双路方案里其实不需要严格同步。因为两路摄像头看的是不同方向时间戳差个几毫秒完全不影响。但如果做双目测距那就需要硬件同步信号香橙派5的MIPI接口支持外部触发这个后面单独讲。3.3 预处理与推理的流水线设计预处理包括缩放、格式转换、归一化。YOLOv5s的输入是640x640的RGB图像而摄像头输出的是1920x1080的NV12。这个转换如果用CPU做一帧大概要8到12毫秒两路就是20毫秒左右会吃掉不少CPU。优化的办法是用RGARockchip Graphics Accelerator做硬件缩放和格式转换。RGA是RK3588内置的2D加速器做缩放和色彩空间转换几乎不占CPU。用librga库可以调用。// 用RGA做NV12到RGB888的转换和缩放 #include rga/RgaApi.h void preprocess_with_rga(void* src, int src_w, int src_h, void* dst, int dst_w, int dst_h) { rga_info_t src_info {0}; src_info.fd -1; src_info.virAddr src; src_info.mmuFlag 1; src_info.format RK_FORMAT_YCbCr_420_SP; // NV12 rga_info_t dst_info {0}; dst_info.fd -1; dst_info.virAddr dst; dst_info.mmuFlag 1; dst_info.format RK_FORMAT_RGB_888; im_rect src_rect {0, 0, src_w, src_h}; im_rect dst_rect {0, 0, dst_w, dst_h}; imcheck(src_rect, dst_rect, 0, 0); c_RkRgaBlit(src_info, dst_info, nullptr); }实测下来用RGA做预处理一帧的耗时从10毫秒降到1.5毫秒左右效果非常明显。而且RGA是独立硬件不占CPU和NPU可以和推理并行。推理环节就是调用rknn_run输入是预处理好的640x640 RGB数据输出是三个尺度的特征图。YOLOv5s的输出是1x25200x85需要做解码和NMS。// 推理核心调用 rknn_input inputs[1]; inputs[0].index 0; inputs[0].type RKNN_TENSOR_UINT8; inputs[0].size 640 * 640 * 3; inputs[0].fmt RKNN_TENSOR_NHWC; inputs[0].buf preprocessed_data; rknn_inputs_set(ctx, 1, inputs); rknn_run(ctx, nullptr); rknn_output outputs[3]; // 获取输出...3.4 结果回传与显示推理结果需要回传到主线程做显示或发送。这里我用了一个结果队列主线程从队列里取结果然后画框、显示。显示可以用OpenCV的imshow也可以用DRM直接写framebuffer。OpenCV方便但占CPUDRM效率高但代码复杂。如果只是调试用OpenCV就够了。如果要上产品建议用DRM或者Qt的EGLFS。// 主线程的结果处理循环 while (running) { Result result; if (result_queue.pop(result)) { // 画框 for (auto det : result.detections) { cv::rectangle(display_img, det.rect, cv::Scalar(0, 255, 0), 2); cv::putText(display_img, det.label, cv::Point(det.rect.x, det.rect.y - 5), cv::FONT_HERSHEY_SIMPLEX, 0.6, cv::Scalar(0, 255, 0), 2); } cv::imshow(result, display_img); cv::waitKey(1); } }4. 常见问题与排查技巧实录4.1 推理结果不稳定或崩溃这是最常见的问题表现是跑一段时间后程序崩溃或者推理结果突然变成乱码。原因通常有三个第一个原因是RKNN上下文跨线程使用。前面说过RKNN上下文必须在工作线程内部初始化。如果你在主线程初始化后传给工作线程短时间可能没问题但跑久了必崩。排查方法是检查rknn_init的调用位置确保它在工作线程的入口函数里。第二个原因是输入数据没有对齐。RKNN对输入数据的内存对齐有要求如果传入的指针不是64字节对齐的可能会导致推理结果异常。解决办法是用posix_memalign分配对齐内存或者用RKNN提供的rknn_create_mem。第三个原因是量化模型和输入不匹配。如果你用INT8量化模型但输入数据没有做正确的归一化结果会完全不对。检查mean_values和std_values是否和转换时一致。4.2 帧率上不去或波动大帧率上不去先看NPU利用率。用cat /sys/kernel/debug/rknpu/load可以查看NPU负载。如果负载只有50%左右说明瓶颈不在NPU而在预处理或采集。现象可能原因排查方法解决措施NPU负载低帧率低预处理耗时过长用perf或time测预处理耗时改用RGA做预处理NPU负载高帧率低模型太大或线程太多查看NPU负载和线程数减少线程数或换轻量模型帧率波动大队列长度不合理观察队列积压情况调整队列长度丢帧策略单路正常双路掉帧NPU核心冲突检查核心绑定每线程绑定独立核心我踩过的一个坑是队列长度设成了16结果延迟累积到快1秒画面严重滞后。后来改成4延迟降到100毫秒以内虽然偶尔丢帧但实时性好多了。4.3 内存泄漏与资源耗尽长时间运行后内存持续增长最后OOM。这个问题在双路方案里特别容易出现因为每帧都要分配和释放内存。排查方法是定期打印/proc/self/status里的VmRSS看内存是否持续增长。如果增长重点检查三个地方帧缓冲有没有正确释放、RKNN输出有没有rknn_outputs_release、OpenCV的Mat有没有循环引用。// 正确的输出释放 rknn_outputs_release(ctx, 3, outputs); // 帧缓冲的释放 ioctl(fd, VIDIOC_QBUF, buf); // 用完必须还回去提示RKNN的输出内存是内部管理的每次rknn_run后必须调用rknn_outputs_release否则内存会泄漏。这个在官方文档里写得不太明显但实测下来不释放的话跑几百帧后内存就爆了。4.4 摄像头采集异常排查摄像头采集的常见问题打开失败、花屏、帧率不对。打开失败先检查设备节点是否存在ls /dev/video*看看。如果设备存在但打开失败可能是权限问题把用户加到video组。花屏通常是像素格式不匹配。摄像头输出的是NV12但你按RGB解析就会花屏。用v4l2-ctl --list-formats-ext查看摄像头支持的格式。帧率不对检查VIDIOC_S_PARM设置有些摄像头默认帧率是15FPS需要手动设成30FPS。# 查看摄像头支持的格式和帧率 v4l2-ctl -d /dev/video0 --list-formats-ext # 设置帧率 v4l2-ctl -d /dev/video0 --set-parm304.5 线程池死锁与任务积压线程池死锁的典型表现是程序卡住不动所有线程都在等待。原因通常是队列的关闭逻辑有问题或者条件变量的通知丢失。我的经验是关闭队列时必须通知所有等待的线程用notify_all而不是notify_one。另外pop操作在队列关闭且为空时应该返回false让线程退出而不是继续等待。任务积压的表现是队列越来越长延迟越来越大。解决办法是设置队列上限满了就丢最旧的帧。丢帧策略要记录日志方便判断是采集太快还是推理太慢。// 带丢帧策略的push bool push_with_drop(const T item) { std::unique_lockstd::mutex lock(mtx_); if (queue_.size() capacity_) { queue_.pop_front(); // 丢最旧的 drop_count_; } queue_.push_back(item); not_empty_.notify_one(); return true; }5. 性能调优与扩展思路5.1 CPU亲和性与线程优先级设置RK3588有4个A76大核和4个A55小核。默认情况下线程会在所有核心之间调度导致缓存命中率低。把工作线程绑定到大核上采集线程绑定到小核上能提升整体性能。// 绑定线程到指定CPU核心 void bind_to_core(int core_id) { cpu_set_t cpuset; CPU_ZERO(cpuset); CPU_SET(core_id, cpuset); pthread_setaffinity_np(pthread_self(), sizeof(cpuset), cpuset); } // 工作线程绑定到大核CPU 4-7 bind_to_core(4 thread_id % 4); // 采集线程绑定到小核CPU 0-3 bind_to_core(cam_id % 4);实测下来绑定核心后双路总吞吐提升了约12%而且帧率波动明显减小。5.2 模型轻量化与量化精度权衡YOLOv5s在RK3588上跑INT8量化后单帧推理约18到22毫秒。如果觉得不够快可以考虑几个方向换更小的模型YOLOv5n的推理时间约10到12毫秒精度下降约5个百分点。降低输入分辨率从640x640降到416x416推理时间约12毫秒小目标检测能力下降。剪枝和蒸馏这个需要重新训练周期长但效果最好。我个人的建议是如果双路合计帧率能到25FPS以上就用YOLOv5s如果不到先用YOLOv5n顶一阵同时优化预处理和线程调度。5.3 从双路扩展到多路的思路双路跑通之后扩展到三路或四路核心思路是一样的但要注意几点NPU只有三个核心四路的话必然有线程共享核心。这时候核心绑定策略要调整可以两个线程绑一个核心或者部分线程走AUTO。内存带宽会成为瓶颈。四路1080p的采集和预处理内存带宽占用会很高。建议降低采集分辨率或者用MIPI摄像头的硬件缩放功能。线程池的工作线程数不是越多越好。我试过8个线程跑四路结果线程切换开销反而拖慢了整体速度。四路的话4到6个工作线程比较合适。5.4 实际部署中的稳定性经验最后分享几个部署阶段的经验。第一一定要加看门狗。NPU驱动偶尔会卡死程序本身不知道但推理不再返回。加一个看门狗线程定期检查推理是否超时超时就重启推理线程。第二日志要分级。调试阶段打详细日志部署阶段只打错误日志。日志写磁盘会占IO影响实时性。第三温度监控。RK3588跑满NPU时发热不小香橙派5的散热片如果不够大温度会到80度以上触发降频。加个风扇或者用cat /sys/class/thermal/thermal_zone*/temp监控温度超过75度就降低帧率。我在实际部署中遇到过一次因为温度过高导致NPU降频帧率从25FPS掉到12FPS查了半天才发现是散热问题。后来加了个小风扇温度稳定在60度左右帧率就稳了。这个坑希望大家别踩。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询