ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

香橙派RK3588双路视觉:丢旧帧背压方案解决NPU推理延迟与内存问题

香橙派RK3588双路视觉:丢旧帧背压方案解决NPU推理延迟与内存问题 1. 双路视觉方案的核心矛盾与背压策略的由来香橙派RK3588上跑双路视觉最直观的做法就是两路摄像头各自开一个线程各自做推理互不干扰。我一开始也是这么干的结果跑起来才发现问题远比想象中复杂。两路1080p的MIPI摄像头同时出流每路30帧加起来就是每秒60帧的图像数据要过NPU。RK3588的NPU算力虽然标称6TOPS但yolov5s在640×640输入下单帧推理耗时大概在25到35毫秒之间浮动具体取决于模型量化程度和NPU频率调度。这意味着单路满打满算也就跑个30帧左右双路同时跑NPU根本吃不下。这时候就出现了一个经典的生产者-消费者问题。摄像头采集是生产者NPU推理是消费者。生产者的速度是固定的摄像头出流不会等你它按自己的时钟走。消费者的速度受限于NPU算力处理不过来的时候帧就会在队列里堆积。如果队列不设上限内存会被慢慢吃光最后OOM被系统杀掉。如果队列设了上限满了之后生产者要么阻塞等待要么丢弃新帧。阻塞等待会导致摄像头驱动缓冲区溢出表现出来就是花屏、卡顿甚至掉流。丢弃新帧则意味着你永远在处理旧数据延迟越积越大画面和现实完全对不上。这就是双路视觉方案里最核心的矛盾采集速率与推理速率不匹配。解决这个矛盾的手段业内叫“背压”Backpressure说白了就是当消费者处理不过来的时候要有一个机制告诉生产者“你慢点”或者“我扔掉一些”。而“丢旧帧”是背压策略里最实用的一种——当队列满的时候不丢新来的帧而是把队列里最老的帧扔掉腾出位置给新帧。这样做的好处是你处理的永远是最新的画面延迟不会累积对于实时性要求高的场景比如机器人避障、无人机视觉来说这比“处理每一帧但延迟越来越大”要合理得多。这个方案我把它叫做“丢旧帧背压方案”是双路视觉方案二的阶段二核心内容。阶段一我们解决了双路摄像头的基本采集和推理流水线搭建阶段二要解决的就是这个速率匹配问题。适合谁看如果你已经在香橙派RK3588上跑通了单路yolov5s现在想上双路或者你正在做多路视频分析的项目被延迟和内存问题困扰那这篇内容就是为你准备的。下面我会从设计思路、核心细节、实操实现、问题排查几个方面把整个方案拆开讲清楚。2. 整体设计思路与方案选型拆解2.1 为什么不用阻塞队列而用丢旧帧最朴素的线程间通信就是阻塞队列。生产者往队列里放帧队列满了就阻塞等消费者取走一帧再继续放。这个模型在生产者速度可控的场景下没问题但摄像头采集是硬实时任务它的时钟由传感器决定你阻塞它它不会等你它只会继续往驱动缓冲区里写。当驱动缓冲区也满了就开始丢帧而且丢帧的行为你完全不可控可能丢的是关键帧导致解码器花屏。我实测过用阻塞队列跑双路刚开始几秒还正常十秒之后延迟就肉眼可见地增长半分钟之后画面延迟能到两三秒。用v4l2-ctl查看驱动缓冲区状态发现bytesused一直在高位说明驱动层已经在丢帧了。这种不可控的丢帧比应用层主动丢帧要糟糕得多因为你不知道丢了哪一帧也不知道丢了多少。丢旧帧背压的思路则完全不同。队列依然有上限但满了之后不是阻塞生产者而是由生产者主动把队列里最旧的那一帧挤出去然后把新帧放进来。这样队列长度始终恒定内存占用可控而且消费者拿到的永远是最新的帧。代价是你会丢掉一些中间帧但对于实时视觉任务来说丢掉旧帧比处理旧帧更有价值。你想想机器人避障的时候你基于三秒前的画面做决策那还不如不做。2.2 双路之间的资源竞争与隔离双路视觉还有一个容易被忽略的问题两路之间会互相抢资源。NPU是共享的内存带宽是共享的甚至CPU缓存也是共享的。如果两路共用一个推理队列那一路的突发流量会把另一路饿死。我试过共用一个队列结果就是两路交替卡顿A路跑几帧B路跑几帧体验极差。所以我的设计是每路独立队列、独立推理线程但NPU推理调用加一个全局互斥锁。这样两路在应用层是隔离的各自维护自己的帧队列和丢帧策略只有在真正调用NPU的时候才串行化。这样做的好处是一路的丢帧不会影响另一路的队列状态而且调试的时候可以单独看每一路的队列深度和丢帧计数定位问题方便很多。互斥锁的粒度也要注意。不要在采集的时候就加锁那样会把采集也串行化。锁只加在rknn_run这个调用前后保证NPU一次只处理一路的输入。实测下来这种粗粒度锁的开销可以忽略不计因为NPU推理本身就要几十毫秒锁的竞争开销在微秒级别。2.3 队列深度的选择逻辑队列深度设多少合适这个参数直接决定了你的延迟上限和丢帧率。设得太小比如深度为1那基本上每来一帧都要丢一帧因为消费者处理一帧要30毫秒生产者30毫秒来一帧刚好持平但只要有波动就会丢。设得太大比如深度为10那延迟上限就是10帧的时间按30毫秒一帧算就是300毫秒对于实时任务来说太长了。我的经验值是深度设为2到3。深度为2的时候延迟上限大概是60到90毫秒这个延迟对于大多数机器人视觉任务是可以接受的。深度为3的时候延迟上限到90到120毫秒稍微宽松一点丢帧率会低一些。具体设多少要看你的任务对延迟的容忍度。如果是避障建议深度2如果是视频分析可以放宽到3或4。这里有一个计算公式可以参考队列深度 可接受延迟 / 单帧推理耗时。比如你可接受的最大延迟是100毫秒单帧推理耗时是30毫秒那队列深度就是3。这个公式假设采集速率和推理速率大致相当如果采集速率远大于推理速率那队列深度对延迟的影响会更复杂因为队列始终是满的延迟就等于队列深度乘以推理耗时。3. 核心细节解析与实操要点3.1 帧队列的数据结构设计帧队列不能用简单的std::queue因为我们需要在队列满的时候从头部弹出最旧的帧。std::deque是更合适的选择它支持两端操作而且内存是分块分配的不会像std::vector那样在扩容时拷贝所有元素。我用的结构大概是这样struct Frame { cv::Mat image; int64_t timestamp; int frame_id; }; std::dequeFrame frame_queue; std::mutex queue_mutex; std::condition_variable queue_cv; const int MAX_QUEUE_SIZE 3;这里有几个细节要注意。第一cv::Mat的拷贝是浅拷贝引用计数加一所以往队列里放帧的开销很小不用担心拷贝图像数据。但要注意如果采集线程后续会修改这块内存那就必须深拷贝否则队列里的帧会被覆盖。我一般是在采集线程里做一次clone()确保队列里的帧是独立的。第二timestamp和frame_id是调试的关键。丢帧的时候你要知道丢了哪一帧延迟是多少全靠这两个字段。我习惯用std::chrono::steady_clock来打时间戳因为它不受系统时间调整的影响。第三条件变量queue_cv用来通知消费者有新帧到达。但注意丢旧帧的时候也要通知因为队列状态变了。不过如果队列一直是满的消费者其实一直在忙通知与否影响不大。我实测下来不加通知也能跑但加上更稳妥尤其是在队列从空变非空的时候。3.2 丢旧帧的具体实现逻辑丢旧帧的核心逻辑在生产者这边。采集线程拿到一帧之后先加锁然后检查队列是否已满。如果满了就从队头弹出一帧记录丢帧计数然后把新帧放到队尾。如果没满直接放队尾。最后通知条件变量解锁。void pushFrame(const Frame frame) { std::unique_lockstd::mutex lock(queue_mutex); if (frame_queue.size() MAX_QUEUE_SIZE) { frame_queue.pop_front(); dropped_frames; } frame_queue.push_back(frame); lock.unlock(); queue_cv.notify_one(); }这段代码看起来简单但有一个坑pop_front()的时候如果那一帧的cv::Mat还被消费者持有引用那内存不会立即释放要等消费者释放了才释放。这本身没问题但如果丢帧率很高内存分配和释放会很频繁可能导致内存碎片。我的做法是在丢帧的时候显式调用frame.image.release()加速引用计数归零。实测下来这个操作对降低内存碎片有帮助。还有一个细节丢帧计数要用原子变量或者加锁保护因为消费者线程可能也在读这个计数做统计。我用的是std::atomicint避免额外的锁开销。3.3 消费者线程的取帧策略消费者线程的逻辑是加锁检查队列是否为空空则等待条件变量非空则取队头帧解锁然后做推理。这里有一个关键点取帧之后要立即解锁不要持有锁做推理。否则生产者会被阻塞在pushFrame的锁上丢帧逻辑就失效了。bool popFrame(Frame frame) { std::unique_lockstd::mutex lock(queue_mutex); queue_cv.wait(lock, [this] { return !frame_queue.empty() || stop_flag; }); if (stop_flag frame_queue.empty()) return false; frame frame_queue.front(); frame_queue.pop_front(); return true; }注意wait的谓词里要检查stop_flag否则程序退出的时候消费者线程会永远卡在wait上。这是一个很常见的死锁场景我踩过好几次。另外取出来的帧如果是旧帧消费者其实没必要处理。但既然已经取出来了处理一下也无妨反正丢帧的逻辑在生产者那边已经做了。如果你想让消费者也参与丢帧决策可以在取帧之后检查时间戳如果太旧就直接丢弃不送NPU。这个策略我试过效果不明显因为生产者已经保证了队列里都是相对新的帧消费者再丢就有点多余了。3.4 双路互斥锁的粒度控制前面提到NPU推理要加全局互斥锁但锁的粒度要控制好。我见过有人在采集线程里就加锁一直持有到推理完成这样两路采集就完全串行化了采集帧率直接减半。正确的做法是只在rknn_run前后加锁{ std::lock_guardstd::mutex npu_lock(npu_mutex); rknn_inputs_set(ctx, 1, input); rknn_run(ctx, nullptr); rknn_outputs_get(ctx, 1, output, nullptr); }这样锁的持有时间就是NPU推理的时间大概30毫秒。两路交替执行每路的有效推理帧率大概是单路的一半但这是NPU算力决定的没办法绕过。如果你想让两路都跑满30帧那只能换更强的NPU或者用更轻量的模型。这里还有一个优化点rknn_inputs_set和rknn_outputs_get其实也可以放在锁外面因为它们操作的是各自的上下文不涉及NPU核心的竞争。只有rknn_run需要加锁。我实测过把inputs_set和outputs_get放到锁外面整体吞吐量能提升大概5%到8%因为减少了锁的持有时间。但要注意rknn_outputs_get之后要尽快把输出拷贝走否则下一帧的inputs_set可能会覆盖输出缓冲区。4. 实操过程与核心环节实现4.1 环境准备与依赖确认在开始改代码之前先确认你的环境是通的。香橙派RK3588上跑Ubuntu 20.04或者22.04都行我用的22.04。RKNN Toolkit2的版本建议用1.5.0以上因为早期版本对多线程推理的支持有bug会出现随机崩溃。检查命令cat /proc/version python3 -c from rknnlite.api import RKNNLite; print(ok)如果rknnlite导入失败说明RKNN Toolkit2没装好需要重新安装。另外确认NPU驱动版本cat /sys/kernel/debug/rknpu/version驱动版本和Toolkit版本要匹配不匹配的话推理结果可能不对或者直接报错。我遇到过驱动是0.9.2Toolkit是1.5.0结果推理输出全是乱码折腾了半天才发现是版本问题。4.2 双路摄像头采集线程的搭建双路MIPI摄像头在RK3588上一般接在/dev/video0和/dev/video1但具体是哪个口要看你的硬件设计。用v4l2-ctl --list-devices确认。采集线程用OpenCV的VideoCapture就行但要注意设置缓冲区大小cv::VideoCapture cap(0, 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, 1);CAP_PROP_BUFFERSIZE设为1很关键它告诉V4L2驱动只保留一帧缓冲区。这样当你取帧慢的时候驱动不会缓存旧帧而是直接丢。这和我们的丢旧帧策略是配套的应用层丢旧帧驱动层也丢旧帧双保险。采集线程的主循环while (!stop_flag) { cv::Mat frame; cap frame; if (frame.empty()) continue; Frame f; f.image frame.clone(); f.timestamp std::chrono::steady_clock::now().time_since_epoch().count(); f.frame_id frame_counter; pushFrame(f); }注意clone()不能省因为cap frame复用的是同一块内存不clone的话队列里的帧会被下一帧覆盖。4.3 推理线程与后处理推理线程从队列取帧做预处理resize到640×640归一化然后送NPU。预处理可以用RGA硬件加速但为了简单我直接用OpenCV的resize。实测下来1080p resize到640×640大概耗时5到8毫秒可以接受。while (popFrame(frame)) { cv::Mat resized; cv::resize(frame.image, resized, cv::Size(640, 640)); // 归一化、转RGB、转NCHW // ... { std::lock_guardstd::mutex lock(npu_mutex); rknn_inputs_set(ctx, 1, input); rknn_run(ctx, nullptr); rknn_outputs_get(ctx, 1, output, nullptr); } // 后处理解码yolov5输出NMS // ... }后处理是CPU密集型的yolov5s的输出是25200个候选框NMS耗时大概10到15毫秒。这部分不需要加锁两路可以并行做。但要注意后处理线程数不要超过CPU核心数RK3588有8个核两路各用一个核做后处理就够了剩下的核留给系统和其他任务。4.4 丢帧统计与性能监控跑起来之后你需要知道丢帧率是多少延迟是多少。我在每路线程里加了统计struct Stats { std::atomicint captured{0}; std::atomicint dropped{0}; std::atomicint inferred{0}; std::atomicint64_t total_latency{0}; };每隔5秒打印一次采集帧数、丢帧数、推理帧数、平均延迟。平均延迟的计算方式是推理完成时的时间戳减去帧的时间戳累加后除以推理帧数。我实测的数据双路1080p30fps队列深度2单帧推理耗时约30毫秒每路实际推理帧率约15fps丢帧率约50%。延迟稳定在60到80毫秒之间没有累积。这个数据说明丢旧帧策略是有效的延迟可控内存也稳定。如果你发现丢帧率太高比如超过70%那说明NPU实在吃不下需要考虑降低输入分辨率或者换更轻量的模型。yolov5s已经算轻量了再轻就得用yolov5n或者自己做剪枝。RK3588的NPU对yolov5n的支持也很好单帧推理能降到15毫秒左右双路就能跑到20fps以上。4.5 程序退出与资源清理程序退出的时候要先设置stop_flag然后通知所有条件变量等待所有线程join。顺序很重要先停采集线程再停推理线程。因为如果先停推理线程采集线程还在往队列里放帧队列会一直增长虽然不会OOM因为丢旧帧会限制长度但退出会变慢。stop_flag true; queue_cv.notify_all(); for (auto t : threads) { if (t.joinable()) t.join(); }另外RKNN的上下文要记得释放rknn_destroy(ctx);不释放的话下次运行可能会报“NPU busy”的错误。我遇到过好几次程序异常退出后NPU没释放再跑就起不来了只能重启。所以建议在代码里加信号处理收到SIGINT的时候走正常的清理流程。5. 常见问题与排查技巧实录5.1 丢帧率异常高的排查思路丢帧率高不一定是丢旧帧策略的问题可能是上游采集或者下游推理有瓶颈。排查顺序建议从下往上先看NPU推理耗时再看预处理耗时最后看采集帧率。用rknn_run前后的时间戳算推理耗时如果超过40毫秒说明NPU频率没跑满或者模型有问题。检查NPU频率cat /sys/class/devfreq/fdab0000.npu/cur_freq正常应该是10000000001GHz。如果是800000000或者更低说明NPU降频了可能是散热问题。RK3588的NPU满载功耗不低不加散热片的话很容易降频。我一开始没加风扇跑十分钟后NPU频率就降到800MHz推理耗时从30毫秒涨到45毫秒丢帧率直接翻倍。加了个小风扇之后频率稳定在1GHz问题解决。如果推理耗时正常那就看预处理。cv::resize在1080p到640p的时候如果用了默认的双线性插值耗时大概8毫秒。换成cv::INTER_NEAREST能降到3毫秒但精度会损失一点。对于yolov5s来说最近邻插值对精度的影响很小可以接受。5.2 画面延迟越来越大的原因如果延迟不是稳定的而是随时间增长那说明丢旧帧逻辑没生效。最常见的原因是队列没有设上限或者上限设得太大。检查MAX_QUEUE_SIZE是不是被改成了很大的值或者pushFrame里的判断条件写错了。另一个可能的原因是消费者取帧之后没有及时释放锁导致生产者阻塞在锁上队列实际上变成了阻塞队列。检查popFrame里是不是在锁内做了耗时操作。我见过有人在锁内做cv::resize结果生产者被阻塞丢帧逻辑完全失效。还有一种情况是条件变量的通知丢了。如果生产者丢帧之后没有notify_one消费者可能还在等队列里的新帧没人处理。虽然这种情况比较少见但一旦发生延迟会一直涨。建议在pushFrame的最后无条件notify_one不管有没有丢帧。5.3 NPU推理结果错乱的排查双路推理结果错乱最常见的原因是两路共用了同一个RKNN上下文。RKNN的上下文不是线程安全的两路同时调用rknn_run会导致输入输出缓冲区混乱。必须每路一个独立的上下文用rknn_init分别创建。如果确认是独立上下文那检查输入数据的格式。yolov5s的输入是NCHWRGB归一化到0到1。如果某一路的输入格式不对输出就会错乱。我遇到过一路用BGR一路用RGB结果两路的检测框位置都对不上。统一用RGB问题解决。还有一种情况是NPU内存不足。RK3588的NPU有独立的内存池如果两路同时申请大块内存可能会失败。检查dmesg里有没有rknpu: alloc memory failed之类的报错。如果有减小输入分辨率或者减少同时运行的模型数量。5.4 常见问题速查表问题现象可能原因排查方法解决方案丢帧率超过70%NPU降频或模型太重查看NPU当前频率加散热或换yolov5n延迟随时间增长队列无上限或锁粒度太大检查队列深度和锁范围设上限为2-3锁只包rknn_run画面花屏驱动缓冲区溢出v4l2-ctl查看缓冲区状态设CAP_PROP_BUFFERSIZE为1推理结果错乱共用上下文或输入格式不对检查上下文创建和输入格式每路独立上下文统一RGB程序退出卡死条件变量等待未唤醒检查stop_flag和notify退出时notify_allNPU报busy上次异常退出未释放dmesg查看NPU状态加信号处理正常释放上下文5.5 实操心得与避坑建议第一个心得不要迷信队列深度越大越好。我一开始设了10觉得这样丢帧少结果延迟到了300毫秒机器人避障直接撞墙。后来改成2丢帧率上去了但延迟稳定在60毫秒避障反应快了很多。对于实时任务延迟比丢帧率重要。第二个心得时间戳要用单调时钟。std::chrono::system_clock会被NTP调整导致时间戳回跳延迟计算出负数。用steady_clock就没这个问题。第三个心得丢帧计数要定期清零。如果程序跑几天dropped_frames会溢出int。用std::atomicuint64_t或者定期重置。我一般是每小时打印一次统计然后清零。第四个心得双路摄像头的曝光要同步。如果两路曝光时间差太多融合的时候会有问题。RK3588的MIPI接口支持硬件同步但需要摄像头模组支持。软件同步的话可以在采集线程里加一个同步点等两路都采集完再一起放队列。但这个会增加延迟看你的任务需求。第五个心得NPU频率要锁定。RK3588的NPU默认是动态调频的负载低的时候降频负载高的时候升频。但升频有延迟会导致推理耗时波动。可以用devfreq把频率锁定在1GHzecho 1000000000 /sys/class/devfreq/fdab0000.npu/userspace/set_freq锁定之后推理耗时稳定在30毫秒左右丢帧率也稳定了。代价是功耗高一点但为了稳定性值得。6. 方案扩展与后续优化方向这套丢旧帧背压方案目前跑双路1080p是稳的但如果你要上四路或者换成更高分辨率的摄像头就需要进一步优化。一个方向是用RGA做硬件预处理把resize和格式转换从CPU卸载到RGA能省出不少CPU时间。另一个方向是用RKNN的多核推理RK3588的NPU有三个核心可以配置成多核模式理论上能把推理吞吐量提升两倍。但多核模式的调度比较复杂需要RKNN Toolkit2的版本支持我还在测试中等跑通了再分享。还有一个方向是动态调整队列深度。当系统负载低的时候队列深度可以大一点减少丢帧负载高的时候队列深度自动缩小保证延迟。这个需要监控NPU利用率和队列深度做一个简单的反馈控制。思路不难但调参需要时间我目前还在实验阶段。最后再分享一个小技巧如果你发现丢帧率很高但延迟很低那说明你的系统其实是在健康运行的只是NPU算力不够。这时候不要急着优化代码先想想是不是可以降低输入分辨率或者把检测频率从每帧都做改成隔帧做。对于很多场景隔帧检测完全够用比如人员检测人不会在一帧之内消失。隔帧做的话NPU负载直接减半丢帧率也降下来了。这个取舍要根据你的具体任务来定没有标准答案。
返回列表