ARTICLE DETAIL

建站实战干货

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

香橙派RK3588双路视觉:线程池与NPU推理优化实战

2026/9/30 22:02:40 拓冰建站 浏览量
香橙派RK3588双路视觉:线程池与NPU推理优化实战 1. 双路视觉方案的整体设计思路1.1 为什么要在香橙派RK3588上做双路视觉单路摄像头跑yolov5s在RK3588上其实已经能跑得挺舒服了。RK3588这颗芯片有6TOPS的NPU算力yolov5s量化成INT8之后单路1080p推理做到30fps以上问题不大。但实际项目里单路往往不够用——比如做立体视觉、双目测距、多角度监控、或者前后双摄的避障方案都需要同时处理两路视频流。问题就出在这里如果你把两路视频流塞进同一个线程里串行处理帧率直接腰斩不说还会因为采集和推理互相阻塞导致延迟飙升。我最早做双路的时候就踩过这个坑两路各跑15fps合在一起变成7fps而且画面卡顿感非常明显。后来改成两路各一个线程池才把性能拉回到每路25fps以上。这个方案的核心思路很简单每一路视觉任务独立成一个处理单元每个单元内部维护自己的线程池。采集、预处理、推理、后处理这些环节拆开用线程池来调度避免单线程串行等待。这样做的好处是两路之间互不干扰一路卡了不会拖累另一路同时每路内部还能通过多线程把NPU和CPU的利用率拉满。1.2 线程池方案选型为什么不用裸线程有人可能会问直接开几个std::thread不就行了为什么要搞线程池我一开始也是这么想的结果发现几个问题第一线程创建销毁的开销。视频采集是持续性的如果每帧都创建线程光是线程创建的开销就够你喝一壶的。线程池的核心价值就是线程复用任务来了丢进队列线程池里的工作线程直接取任务执行省掉了反复创建销毁的成本。第二任务队列的管理。双路视觉里采集、推理、后处理这些任务的生产和消费速度不一样。采集快、推理慢如果没有队列缓冲采集线程就会被推理阻塞。线程池自带阻塞队列天然解决这个问题。第三资源控制。裸线程开多了会把CPU吃满线程池可以限制最大并发数避免两路视觉互相抢资源。RK3588是8核CPU4个A764个A55你得留出核心给系统和其他任务不能全给视觉用。所以这个方案里每一路视觉任务对应一个独立的线程池实例。线程池的线程数量、队列长度、拒绝策略都需要根据实际负载来调。下面我会详细讲怎么配。1.3 两路视觉的任务划分与数据流在动手写代码之前先把任务流理清楚。每一路视觉任务我把它拆成四个阶段采集阶段从MIPI CSI或者USB摄像头拿帧通常是阻塞式读取这一阶段放在独立线程里跑不占用线程池。预处理阶段把采集到的帧做resize、归一化、颜色空间转换比如BGR转RGB然后拷贝到NPU能访问的内存。这一步是CPU密集型适合丢进线程池。推理阶段调用RKNN的推理接口把预处理好的数据喂给NPU。这一步是NPU密集型但调用是同步阻塞的所以也需要独立线程来跑避免阻塞CPU任务。后处理阶段解析推理输出做NMS、坐标还原、画框。这一步又是CPU密集型丢回线程池。数据流是这样的采集线程拿到帧 → 丢进线程池做预处理 → 预处理完丢给推理线程 → 推理完丢回线程池做后处理 → 后处理完输出结果。整个链路里线程池负责CPU密集的预处理和后处理推理线程单独跑采集线程单独跑。注意预处理和后处理虽然都走线程池但它们是两个不同的任务类型。如果你的线程池只有一个队列后处理任务可能会被预处理任务堵住。我建议要么用两个线程池要么在任务里加优先级标记。2. 线程池的核心实现与参数配置2.1 线程池的基本结构线程池说白了就三样东西工作线程数组、任务队列、同步机制。工作线程循环从队列里取任务执行队列空的时候阻塞等待队列满的时候要么阻塞生产者要么拒绝任务。我用C11的标准库来实现核心成员大概是这样class ThreadPool { public: ThreadPool(size_t threads, size_t max_queue_size); templateclass F void enqueue(F f); ~ThreadPool(); private: std::vectorstd::thread workers_; std::queuestd::functionvoid() tasks_; std::mutex mutex_; std::condition_variable cond_; std::condition_variable not_full_cond_; size_t max_queue_size_; bool stop_; };这里有几个关键点。max_queue_size_是队列上限超过这个值的时候enqueue会阻塞防止内存无限增长。not_full_cond_是用来通知生产者队列有空位的。stop_是退出标志析构的时候要保证所有线程都能正常退出。2.2 线程数量的确定不是越多越好线程池开多少线程这是最容易被拍脑袋决定的事情。我见过有人直接开16个线程结果CPU上下文切换开销比实际计算还大。正确的做法是根据任务类型和CPU核心数来算。RK3588是8核4个A76大核4个A55小核。视觉任务里预处理和后处理都是CPU密集型理论上线程数等于核心数最合适。但你不能把8个核全占了系统本身、采集线程、推理线程都要占核。我的经验值是单路视觉的线程池2到3个线程双路视觉每路一个线程池每路2个线程为什么是2个因为预处理和后处理可以并行但同一时刻通常只有一个任务在跑。2个线程足够覆盖突发负载又不会造成太多上下文切换。如果你发现CPU利用率上不去可以加到3个但超过4个基本就是浪费了。还有一个细节线程亲和性。你可以把线程池的线程绑到A76大核上把采集和推理线程绑到A55小核上这样能减少核间迁移的开销。不过这个优化在RK3588上效果不是特别明显因为调度器本身已经做得不错了除非你发现明显的性能抖动否则不用折腾。2.3 阻塞队列的选择与容量计算阻塞队列的容量直接影响到系统的吞吐和延迟。队列太小生产者频繁阻塞采集帧率上不去队列太大内存占用高而且延迟会累积——你处理的是几秒前的帧这在实时视觉里是致命的。队列容量的计算公式大概是队列容量 目标帧率 × 最大可接受延迟 × 安全系数假设目标帧率25fps最大可接受延迟200ms安全系数1.5那么队列容量 25 × 0.2 × 1.5 ≈ 7.5取整为8。但实际中还要考虑任务的处理时间。如果预处理单帧耗时20ms后处理耗时15ms那么队列里积压的任务会以每帧35ms的速度消耗。8个任务大概需要280ms清空这个延迟是可以接受的。我实测下来双路视觉每路的队列容量设在6到10之间比较合适。低于6容易丢帧高于10延迟明显。你可以根据实际帧率和处理耗时微调。提示如果你的应用对延迟极度敏感比如避障队列容量要设小一点宁可丢帧也不要处理旧帧。如果对帧率敏感但对延迟不敏感比如录像分析队列可以设大一点。2.4 任务拒绝策略丢帧还是阻塞当队列满了新任务来了怎么办两种策略阻塞生产者或者直接丢弃任务。阻塞生产者的意思是采集线程会被卡住直到队列有空位。这样做不会丢帧但会导致采集帧率下降而且如果推理一直很慢采集会一直阻塞摄像头缓冲区可能会溢出。丢弃任务的意思是直接扔掉新帧采集线程继续跑。这样做帧率稳定但会丢帧。我的选择是预处理任务用阻塞策略后处理任务用丢弃策略。为什么因为预处理是推理的前置丢了预处理任务等于丢了这一帧不如阻塞一下等队列空位。而后处理只是画框输出丢一帧不影响整体流程而且后处理通常比预处理快不容易积压。具体实现上enqueue函数加一个bool block参数阻塞策略用not_full_cond_.wait()丢弃策略直接返回false。3. 双路视觉的完整实操流程3.1 环境准备与依赖安装先把基础环境搭好。香橙派RK3588官方推荐Ubuntu 20.04或者Debian 11我用的是Ubuntu 20.04内核版本5.10。系统烧写用RKDevTool或者balenaEtcher都行这里不展开。装完系统之后需要装这些依赖sudo apt update sudo apt install -y cmake g libopencv-dev python3-opencv sudo apt install -y librknnrt-dev librknn-api-devRKNN的运行时库和开发库要单独装香橙派官方有提供deb包也可以从GitHub上拉源码编译。注意版本要匹配RKNN Toolkit2的版本和runtime的版本不一致会报错。OpenCV建议用系统自带的4.2版本够用了。如果你要用GStreamer做采集还要装libgstreamer1.0-dev和libgstreamer-plugins-base1.0-dev。3.2 摄像头采集线程的实现采集线程独立于线程池每个摄像头一个线程。用V4L2或者OpenCV的VideoCapture都行我推荐V4L2因为延迟更低而且能直接控制缓冲区数量。void capture_thread(int cam_id, ThreadPool pool, std::atomicbool running) { cv::VideoCapture cap(cam_id, cv::CAP_V4L2); cap.set(cv::CAP_PROP_FRAME_WIDTH, 1920); cap.set(cv::CAP_PROP_FRAME_HEIGHT, 1080); cap.set(cv::CAP_PROP_FPS, 30); cap.set(cv::CAP_PROP_BUFFERSIZE, 2); // 缓冲区设小降低延迟 cv::Mat frame; while (running) { cap frame; if (frame.empty()) continue; // 拷贝一份避免下一帧覆盖 cv::Mat frame_copy frame.clone(); pool.enqueue([frame_copy, pool]() { preprocess_and_infer(frame_copy, pool); }, true); // 阻塞策略 } }这里有个坑cap frame拿到的frame是复用的如果你直接把引用传进线程池下一帧会覆盖上一帧的数据。所以必须clone()一份。这个拷贝开销不小1080p的帧大概6MB25fps就是150MB/s的拷贝量。如果嫌开销大可以用环形缓冲区来管理帧内存但实现复杂度会高不少。CAP_PROP_BUFFERSIZE设成2意思是驱动层只缓冲2帧。设大了延迟会累积设成1又容易丢帧2是比较平衡的值。3.3 预处理与推理的衔接预处理和推理的衔接是双路视觉里最容易出问题的地方。预处理完的数据要喂给NPU但NPU推理是同步阻塞的如果你在预处理线程里直接调推理那这个线程就被占住了没法处理下一个预处理任务。我的做法是预处理完把数据丢给一个专门的推理线程。每一路视觉有一个推理线程它从预处理结果队列里取数据调RKNN推理然后把结果丢回线程池做后处理。void inference_thread(rknn_context ctx, ThreadPool pool, std::queuecv::Mat preprocessed_queue, std::mutex queue_mutex, std::condition_variable queue_cond, std::atomicbool running) { while (running) { cv::Mat input; { std::unique_lockstd::mutex lock(queue_mutex); queue_cond.wait(lock, []() { return !preprocessed_queue.empty() || !running; }); if (!running) break; input preprocessed_queue.front(); preprocessed_queue.pop(); } // RKNN推理 rknn_input inputs[1]; inputs[0].index 0; inputs[0].buf input.data; inputs[0].size input.total() * input.elemSize(); inputs[0].type RKNN_TENSOR_UINT8; inputs[0].fmt RKNN_TENSOR_NHWC; rknn_inputs_set(ctx, 1, inputs); rknn_run(ctx, nullptr); rknn_output outputs[3]; // ... 设置output ... rknn_outputs_get(ctx, 3, outputs, nullptr); // 把输出拷贝出来丢回线程池做后处理 std::vectorrknn_output output_copy(outputs, outputs 3); pool.enqueue([output_copy, pool]() { postprocess(output_copy, pool); }, false); // 丢弃策略 rknn_outputs_release(ctx, 3, outputs); } }这里的关键是推理线程和线程池的分工。推理线程只负责调NPU不做任何CPU密集的计算。后处理NMS、坐标还原、画框全部丢回线程池。这样NPU和CPU能并行工作吞吐量最大化。3.4 后处理与结果输出后处理包括解析YOLOv5的输出张量、做NMS、把坐标映射回原图、画框。这部分是纯CPU计算丢进线程池跑就行。YOLOv5s的输出是三个尺度的特征图分别是80x80、40x40、20x20输入640x640的情况下。每个格点有3个anchor每个anchor有85个值4个坐标1个置信度80个类别概率。解析的时候要注意RKNN的输出格式可能和原始模型不一样需要根据实际输出来调整。NMS的阈值我一般设0.45置信度阈值设0.25。这两个值可以根据实际场景调检测小目标的时候置信度阈值可以降到0.15但误检会变多。画框用OpenCV的rectangle和putText就行注意颜色要区分两路一路用绿色一路用蓝色方便调试。4. 常见问题与排查技巧实录4.1 帧率上不去怎么办这是最常见的问题。双路视觉跑起来发现每路只有10fps远低于预期。排查思路是这样的先看CPU利用率。用htop看如果8个核都在80%以上说明CPU是瓶颈。这时候要检查预处理和后处理是不是有优化空间。比如resize能不能用cv::INTER_NEAREST代替cv::INTER_LINEAR颜色空间转换能不能用查表法这些都能省不少CPU。如果CPU利用率不高但帧率还是上不去那可能是NPU瓶颈。用rknn_query查一下推理耗时如果单帧推理超过30ms说明模型或者输入尺寸有问题。yolov5s在RK3588上跑640x640正常应该在15到20ms左右。如果超过30ms检查一下是不是用了FP16而不是INT8或者NPU频率没拉满。还有一种可能是锁竞争。两路视觉如果共享了某个全局锁会互相阻塞。检查一下线程池的mutex是不是每路独立的RKNN的context是不是每路独立的。我见过有人两路共用一个RKNN context结果推理串行化帧率直接减半。4.2 内存泄漏与帧拷贝优化跑久了发现内存一直涨大概率是帧拷贝没管理好。cv::Mat的clone()会分配新内存如果后处理里又clone了一次内存就翻倍了。我的做法是采集的时候clone一次后面全程用引用或者移动语义不再拷贝。RKNN的输入输出也有内存管理的问题。rknn_outputs_get拿到的output需要手动rknn_outputs_release忘了release就会泄漏。我建议用RAII封装一下确保异常安全。还有一个隐蔽的坑线程池任务里的lambda捕获。如果你捕获了cv::Mat的引用但任务执行的时候原Mat已经析构了就会访问野指针。所以要么捕获值拷贝要么用shared_ptr管理生命周期。4.3 两路互相干扰的排查两路视觉跑起来之后发现一路帧率高一路帧率低或者两路都时快时慢。这种互相干扰通常来自三个地方CPU缓存竞争。两路的预处理数据如果放在同一块内存区域会互相踢缓存。解决办法是给每路分配独立的内存池物理地址尽量隔开。NPU调度竞争。RK3588的NPU是共享的两路同时调推理会排队。如果一路的推理时间波动很大另一路也会受影响。可以在推理线程里加一个简单的令牌桶控制两路的推理请求速率。中断亲和性。两个摄像头的中断如果都绑在同一个CPU核上会互相抢占。用/proc/interrupts看一下中断分布然后用echo mask /proc/irq/irq_num/smp_affinity把中断绑到不同的核上。4.4 常见问题速查表问题现象可能原因排查方法解决方案帧率低于预期CPU瓶颈htop看CPU利用率优化预处理减少拷贝帧率低于预期NPU瓶颈rknn_query看推理耗时检查量化精度拉高NPU频率帧率波动大锁竞争检查共享锁每路独立线程池和context内存持续增长内存泄漏valgrind或massif检查clone和release一路卡一路正常中断亲和性/proc/interrupts绑定中断到不同核画面延迟大队列积压打印队列长度减小队列容量丢帧策略推理结果错乱数据竞争检查帧拷贝采集时clone避免引用捕获提示调试双路视觉的时候建议先跑单路确认单路稳定在25fps以上再开第二路。如果单路都跑不到20fps先别折腾双路把单路优化好再说。5. 性能调优与扩展思路5.1 NPU频率与CPU调频RK3588的NPU默认频率是1GHz可以超到1.2GHz。CPU的A76大核默认1.8GHz可以拉到2.4GHz。调频命令# 查看当前频率 cat /sys/class/devfreq/fdab0000.npu/cur_freq # 设置NPU为性能模式 echo performance /sys/class/devfreq/fdab0000.npu/governor # 设置CPU为性能模式 echo performance /sys/devices/system/cpu/cpufreq/policy0/governor echo performance /sys/devices/system/cpu/cpufreq/policy4/governor调频之后NPU推理能快10%到15%CPU预处理能快20%左右。但功耗和发热会上去如果板子没有散热片长时间跑可能会降频。我建议加个小风扇或者至少贴个散热片。5.2 模型轻量化与输入尺寸权衡yolov5s在640x640输入下单帧推理大概18ms。如果你把输入降到416x416推理能降到10ms左右但小目标检测精度会下降。如果场景里目标比较大416完全够用帧率能提升不少。模型轻量化还有几个方向把Backbone换成MobileNetV3参数量能降一半用通道剪枝去掉冗余通道用知识蒸馏让小模型学大模型。这些都需要重新训练和量化工作量不小但效果明显。5.3 从双路扩展到多路的思路双路跑通之后扩展到四路其实不难核心思路是一样的每路一个线程池每路一个推理线程。但要注意几个问题NPU只有一颗四路同时推理会排队。如果每路推理18ms四路串行就是72ms帧率直接掉到14fps。解决办法是分时复用四路轮流推理每路分配固定的时间片。或者用批处理把四路的输入拼成一个batch一次推理出四路结果。RKNN支持batch推理但需要模型支持动态batch。CPU方面四路的预处理和后处理会吃掉更多核心。8核可能不够用需要考虑把一些任务卸载到GPU或者DSP上。RK3588的GPU是Mali-G610支持OpenCL可以用来做resize和颜色转换。内存带宽也是瓶颈。四路1080p的帧拷贝带宽需求是600MB/s加上NPU和CPU的访问DDR带宽可能会吃紧。建议用NV12格式代替BGR能省一半带宽。5.4 线程池的监控与动态调整跑起来之后你肯定想知道线程池的负载情况。我一般会加几个统计变量队列当前长度、任务平均等待时间、任务平均执行时间。这些数据可以定期打印或者用共享内存暴露给监控程序。如果发现队列长度经常接近上限说明线程数不够可以动态增加线程。如果发现线程大部分时间在等待说明线程数过多可以减少。动态调整的实现稍微复杂一点但能适应不同的负载场景。我个人的经验是双路视觉的线程池每路2个线程、队列容量8这个配置能覆盖80%的场景。剩下的20%要么是极端负载要么是特殊需求需要单独调。最后分享一个小技巧如果你发现两路视觉的帧率总是差那么一两帧可以在采集线程里加一个微秒级的sleep让两路的采集节奏错开。这样能减少NPU和CPU的瞬时竞争帧率会更稳定。我实测下来加200微秒的错峰两路帧率波动能从±3fps降到±1fps。