ARTICLE DETAIL

资讯详情

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

Ping-Pong双缓冲:让移动端图像处理的数据搬运与计算并行起来

Ping-Pong双缓冲:让移动端图像处理的数据搬运与计算并行起来 做移动端图像优化的工程师大概都见过这种尴尬麒麟芯片架构标称的算力一个比一个高NPU的TOPS数字越来越吓人可真把一条ISP链路或者推理流水线跑起来吞吐量就是上不去。最气人的是任务管理器里DMA引擎的利用率几乎拉满计算单元却有大把时间在空转等计算单元忙起来了DMA又在旁边等着。我在麒麟平台上调端侧图像管线那阵子天天被这种“一快一慢、互相等待”的节奏折磨后来才想明白问题的关键不在算力而在数据搬运和计算之间那种严格的串行关系。要解决它就用Ping-Pong双缓冲让搬运和计算并行起来。1. 串行数据流让芯片资源利用率直接打对折1.1 搬运耗掉的周期远比想象中多以拍照为例。传感器输出的RAW数据一帧主摄RAW在五千万像素级别下动辄就是30MB甚至更高的数据量。这块数据从传感器搬到内存再从内存搬到ISP、NPU中间每一步都是一次真正的传输带宽和时间都花出去了。传统处理流程是严格的“搬运→计算→搬运→计算”DMA先把RAW从传感器搬到系统内存算力单元读完、算完再把结果写回然后才开始处理下一帧。这个流程里任何时刻都只有一个单元在工作。DMA搬运期间CPU和NPU是闲的计算期间DMA是闲的。两头都在干活但两头永远没有同时干活。单从资源利用率来讲这种实现的理论性价比就已经打了对折工程实现中的损耗只会更多。而且这种浪费不是靠提高单个模块性能就能补回来的你得先让两个模块同时跑起来后面的一切优化才有意义。用具体数据说话假设一段数据搬运要5ms计算要4ms串行模式下总耗时就是9ms。如果搬运和计算能完全重叠总耗时只要max(5,4)也就是5ms。光这一步吞吐量就提升了接近一倍。移动端图像与AI场景里搬运和计算经常是同一个数量级所以这个提升不是纸面上的而是真实可感的。1.2 双重等待不仅慢还产生无谓调度开销串行流除了等待本身还有一个很少有人注意的隐藏成本每一步交接都要“停下来等对方”而这个等待必然伴随中断、唤醒、状态同步。在异构平台上这些操作往往要跨越不同硬件的总线通信链路开销比同核内部同步高一个量级。如果你做过系统集成会发现端侧任务里同步开销占任务总耗时的10%甚至更多一点都不稀奇。流水线阶段每增加两级等待累计就多两级。我经常用一个对比来解释这个现象假设只有一个流水线工人他既要搬运零件又要组装成品。组装的时候搬运停下来搬运的时候组装置下来哪怕他手脚再快总产量也只有两个动作真正并行的系统的一半。这个比喻不严谨但很直观——串行根本不是“慢一点”而是把整个系统锁死在“同一时间只做一件事”的囚笼里。更麻烦的是这种等待还会放大系统抖动。某个时刻DMA突发被其他高优先级任务抢占搬运时间拉长计算单元跟着干等反过来计算单元被调度打断DMA也只能停着。同步点越多抖动越容易被放大帧率曲线就会变得很不稳定。1.3 计算与搬运的时间比值决定了优化的天花板如果计算耗时远大于搬运耗时串行造成的浪费相对小优化空间有限如果搬运耗时和计算耗时差不多甚至搬运更慢那并行带来的收益就非常可观。移动端图像和AI场景恰恰属于第二种数据量大搬运时间长而计算在硬件加速器上又很快两者常常是同一数量级。实操时不要一上来就动手改代码先在目标设备上分别打点测一下搬运耗时和计算耗时各是多少把时间比值算出来。有些模块串行跑已经优化得不错硬改成Ping-Pong反而增加内存开销性价比不高。先量化再动手这是做优化最重要的习惯。就像治病要先诊断一样连“病根”在哪都不清楚就动刀大概率会越改越乱。2. Ping-Pong双缓冲从“轮流干活”到“同时开工”2.1 双缓冲的经典模型Ping-Pong的实现思路其实非常朴素准备两块bufferA和B让它们交替“上岗”。第一轮DMA往A里搬数据搬完计算单元开始处理A与此同时DMA开始往B里搬第二份数据。第二轮B搬完计算单元转去处理B同时DMA往A里搬第三份数据。如此循环往复A和B像乒乓球一样在搬运和计算之间来回切换这就是Ping-Pong名字的由来。关键在于任何时刻计算单元在处理上一份已就绪的数据搬运单元在搬运下一份未知的数据两个动作完全重叠。理论上只要一份数据从搬运完成到计算完成的总时间少于“一个搬运周期加一个计算周期”系统就能一直保持流水不空转。用生产线类比更清楚两个工位之间放两张台子。搬运工把零件放到台子A后装配工取走台子A的零件组装同时搬运工往台子B放新零件。两工位各有活干不用互相干等。台子A和台子B就是那两块buffer搬运工就是DMA引擎装配工就是CPU或NPU。这个类比简单但能解释Ping-Pong九成以上的设计逻辑。2.2 为什么必须是“两份buffer”而不是“把一份buffer加大一倍”这个我被人问过很多次一份大buffer难道不能通过偏移量实现类似效果吗不能。问题的根源在于内存访问的竞争同一块内存如果搬运单元正在写前面部分计算单元同时在读后面部分在两个单元之间必然有总线仲裁和缓存一致性的冲突。轻则读到半新半旧的数据重则总线争抢导致整体吞吐量不升反降。双缓冲的本质是通过物理上独立的两块内存把“对同一资源的写”和“对同一资源的读”在时间上彻底错开。这是一种“以空间换时间、以隔离换并行”的做法思路简单但可靠性和工程可维护性都远高于在单buffer上做精细的读写控制。尤其是异构平台上DMA引擎和CPU/GPU/NPU各自都有缓存和存储管理单元强行共享一块内存跨单元的一致性维护成本会把你拖垮。2.3 谁来负责切换状态机与帧号在硬件设计里Ping-Pong切换通常由帧计数器完成。每完成一轮搬运/计算计数器加一用奇偶判断当前该用哪块buffer奇数帧用A偶数帧用B。搬运算完成信号到达时只在中断处理里改一下状态指针不做重活。软件侧实现同样简单示例如下/* 简化的Ping-Pong状态切换 */ struct pingpong *pp; unsigned int idx pp-frame_id 1; pp-ready[idx] 1; /* 标记本buffer数据已就绪 */ pp-frame_id; /* 切换到下一块 */这个代码看起来简单但要特别注意ready标记必须发生在数据真正写完之后顺序错了竞争就会来敲门。代码里真正的业务逻辑不在这个切换函数里而在消费者拿到ready标志之后对数据的消费流程。真实工程里切换逻辑往往会封装成一个状态机包含IDLE、PING搬运中、PONG搬运中、PING计算中、PONG计算中这几个状态。状态之间的迁移条件就是搬运和计算的完成事件。把这个状态机和frame_id配合起来用既容易调试也方便加诊断日志。千万别把状态写散在多个回调函数里否则查问题的时候会非常痛苦。3. 麒麟架构上Ping-Pong最容易见到收益的几个位置3.1 ISP图像处理链路让RAW帧不再断流在移动SoC里ISP负责对传感器传回的RAW数据做一系列处理去噪、黑电平校正、去马赛克、白平衡、色调映射等。数据量本来就大处理步骤又多如果等上一帧ISP全部处理完传感器才输出下一帧帧率必然被拖垮。我在写这条链路时把Sensor DMA输出直接接到双缓冲上第一帧RAW数据到达后ISP开始处理第一帧同时Sensor DMA往第二块缓冲写第二帧。这样连拍、录像模式下RAW数据能以固定帧率持续流入不会因为ISP计算慢而丢帧。如果再把“搬运、ISP处理一、ISP处理二”做成三级流水让三个阶段分别错开效果还能更好不过实现复杂度也会同步上升一般先从双缓冲起步比较稳。3.2 NPU推理引擎喂数据和算数据解耦NPU推理是Ping-Pong最典型的用武之地。模型执行一次前向需要输入数据而输入数据往往要先做归一化、量化、通道重排等预处理输出侧还有后处理。如果CPU先完成全部预处理再启动NPU计算计算期间CPU就又闲了。标准的做法是CPU在NPU算第一个batch时同时准备第二个batch的输入数据并且写到另一个输入buffer。输出侧也用双缓冲NPU写完第一个输出结果后CPU往出读NPU继续计算第二个输出。这个模式在端侧推理引擎里几乎是标配。调优时要注意batch大小和buffer数量的匹配别让预处理线程变成新的瓶颈。有个实测场景让我印象很深同一个轻量检测模型串行模式下CPU预处理和NPU推理加起来需要8ms一帧改成双缓冲后CPU预处理和NPU推理重叠单帧延迟没有变但吞吐从每秒120帧涨到接近190帧。这就是“延迟不降、吞吐翻倍”的典型效果对离线批量任务尤其友好。3.3 大小核、DMA与片内数据通路的接力麒麟平台是典型的大小核架构也涉及大量CPU与NPU、GPU之间的数据交换。当任务在不同计算单元之间迁移时模型权重、中间特征、控制信息都需要搬运。这类搬运本质上也适合用双缓冲来平滑。片内数据通路的存储转发单元、DMA控制器基本都是基于类似Ping-Pong的思路设计的只是硬件已经固化了留给软件更多是配置参数和中断处理。软件层面同样可以借鉴多线程里生产者线程往环形缓冲写消费者线程从环形缓冲读这就是Ping-Pong思想在通用计算里的延伸。端侧很多耗时任务真正值得优化的不是算子本身而是算子之间的数据传输等待。花大力气把一个算子的计算从2ms优化到1.5ms如果数据还在等搬运整体吞吐根本看不到变化。先把数据流动起来再回头抠算子见效快得多。4. 决定Ping-Pong收益的细节同步、Cache 与对齐4.1 同步是双缓冲的灵魂两个单元同时干各自的活怎么知道对方干完了软件里可以用信号量、完成量、条件变量硬件里靠中断或事件。但无论哪种机制有一条铁律不变共享状态比如ready标志的更新必须是原子的并且必须保证在数据完全写完之后再更新状态。顺序倒过来计算单元就会拿到半帧数据轻则画面撕裂重则返回完全错误的推理结果。我在代码里尽量用ARM平台的原子操作和内存屏障来保证顺序比如在准备数据完成之后、置位ready之前插入一条屏障指令__asm__ volatile(dmb ish); pp-ready[idx] 1;dmb ish的意思是确保这条指令之前的访存操作在内部共享域里已经对其他执行单元可见。对双缓冲切换来说这句话就是在告诉硬件数据都写完了你现在可以把标志位亮出来了别人看到标志为1时数据一定已经就位。缺少这个屏障某些乱序执行的硬件可能先把标志改掉然后数据还在路上消费者的噩梦就开始了。同步设计的另一个原则是“尽量少同步”。每多一个同步点系统就多一个抖动的入口。如果能在驱动层用一个waitqueue搞定就不要在业务层再套一层锁。能用几个原子位域表达的状态就不要搞一个完整的互斥锁。双缓冲的切换本质上只关心“数据ready了没有”这个信息用一两个bit表达就够了。4.2 Cache一致性问题最容易翻车的角落CPU和DMA之间最大的暗坑是Cache一致性。CPU计算时会把数据暂存在L1/L2 Cache里而DMA直接访问物理内存两边看到的数据可能不一样。如果CPU写完buffer后没有把Cache里的脏数据刷到内存DMA搬走的就是旧数据反过来DMA写入的bufferCPU如果不主动失效对应的Cache行读到的也是旧数据。两种常见做法一是把buffer映射为write-back在搬运或计算前显式flush Cache line二是把buffer映射为uncached或write-through让数据直通内存彻底绕开Cache一致性问题。前者性能好、需要手工刷新后者干净可靠、但每次访问都打到内存高频小数据量场景尤其亏。我的建议是大块数据主体用write-back加手工flush小块控制头用uncached具体用哪种必须实测对比不要凭感觉。这里分享一个排查经验如果你发现双缓冲的结果时对时错错误没有固定规律优先怀疑Cache一致性问题。可以在每次搬运和计算之间加一次显式的flush/invalidate看问题是否消失。如果在加与不加之间切换问题跟着出现和消失那基本就能锁定是缓存同步的锅。4.3 对齐、有效长度与DMA边界DMA控制器通常要求内存地址按32/64/128字节对齐长度也要符合对齐要求。buffer分配时如果不对齐轻则DMA传输退化为逐字节慢速模式重则直接报错。调Ping-Pong时分配buffer要预留对齐余量记录有效长度不要把“buffer容量”当成“本次有效数据长度”。这块有个真实的坑某次压测时双缓冲的数据量在最后一帧不足一个对齐单位处理代码直接按buffer容量把空洞也算进去了结果输出图像出现黑边。查了很久才定位到是对齐和有效长度没分开维护。后来所有buffer都增加了一个valid_len字段处理逻辑只认这个字段容量仅供参考问题再没复现过。问题域后果推荐策略同步顺序错读到半成品数据先写数据再置标志配合原子操作和内存屏障Cache不一致数据旧、结果错大块数据write-back加flush小块控制头走uncached地址不对齐DMA降速或报错分配时对齐到128B单独记录有效长度5. 我调Ping-Pong踩过的坑5.1 坑一把cache刷新塞进中断反而丢了性能第一次做双缓冲优化时为了保险我在DMA完成中断里直接把整个buffer flush了一遍。结果中断处理时间涨到原来的几倍并行省下来的时间又被同步开销吃回去了。后来把flush移到计算开始前由消费方在读数据前执行失效/刷新操作情况才好转。中断里只做最轻量的标志更新重活一律拿到线程里做。这个教训让我养成了一个习惯每项操作都要算“时间账”。中断里做一次flush看起来只是多几行代码但每次DMA完成都要执行累积起来就是一笔大开销。并行优化的核心是让两个单元同时干活而不是在其中一个单元上拼命加戏。5.2 坑二buffer数量不是越多越好双缓冲不是万灵药三缓冲、四缓冲也不是越多越好。我在某个视频前处理模块试过四缓冲内存占用涨了快一倍帧率几乎没动。原因很简单这个场景的搬运耗时与计算耗时接近1:1双缓冲已经把重叠做到了理论上限再加buffer只是多占内存。反而是计算耗时显著大于搬运耗时的模块三缓冲能明显平滑抖动。做不做、做几级先量化场景再决定。大概可以记一个这样的判断思路如果搬运时间和计算时间的比值接近1双缓冲就够如果计算时间明显大于搬运时间比如1:3甚至更大三缓冲才能把计算阶段的空闲时间充分填满如果搬运远远大于计算问题根本不在于流水线级数而是数据压缩、搬运方式甚至总线频率出了问题加buffer解决不了本质。5.3 坑三只测稳态压测场景全露馅稳定帧率下Ping-Pong跑得漂亮不代表它在突发负载下也能扛住。系统同时跑多个任务时内存带宽被抢、处理器被占满、NPU可能因为热管理降频这时候计算耗时漂移非常大双缓冲切换逻辑很容易在边界出错。后来我在代码里加入统计打点持续跟踪每一帧的搬运耗时和计算耗时用量化指标去找异常才把边界问题一个个抓出来。常用的一个指标叫overlap率就是搬运与计算实际重叠的时间占总执行时间的比例。你可以用trace工具或者硬件性能计数器来采集。 提示overlap率重叠率 搬运与计算实际重叠的时间 / 总执行时间。这个指标比单纯看帧率更能说明双缓冲是否调对。overlap率高于0.7基本说明双缓冲调得不错低于0.5回去查同步和调度。我在多个模块里用过这个指标非常直观建议你也试试。5.4 一个亲测好用的调优流程先串行跑通再改双缓冲不要一上来就并行。第一步把单buffer模式跑正确得到基线数据。第二步加双缓冲并保证逻辑正确。第三步用trace工具同时抓DMA事件和计算单元事件把时序图拉直肉眼确认搬运和计算是不是真的重叠。第四步算overlap率高于0.7基本说明调好了低于0.5回去查同步和调度。这个流程不复杂但每一步都做扎实能省下大量排错时间。尤其是第一步很多人贪快直接写双缓冲出了问题都分不清是原有逻辑的bug还是并行引入的bug。先保证单buffer完全正确再动并行排查范围就能缩小一大半。6. 从Ping-Pong延伸到更大的并行视野6.1 三缓冲在波动负载下多一道缓冲双缓冲适合负载平稳的场景。负载波动大时计算单元偶尔变慢搬运单元只能等着双缓冲的第二个buffer一旦用尽流水线还是会断。三缓冲等于多加一个槽位让波动有时间被吸收掉。视频渲染里三缓冲很常见就是为了同时避免画面撕裂和帧率下降。代价是内存占用更大移动端要掂量着用。选二缓冲还是三缓冲没有绝对标准。我个人的判断逻辑是先看数据源是不是匀速。传感器输出通常比较匀速双缓冲就够网络数据、用户交互触发的那类任务突发性强三缓冲会更稳。任何缓冲都只是把抖动延迟化不能消除抖动如果系统总体容量已经撑不住加再多buffer也白搭。6.2 Ring Buffer把两个槽位扩展成多个Ping-Pong本质是槽位数为2的环形缓冲。如果发现两个槽不够用或者槽位数目需要动态调整可以直接用Ring Buffer取代。生产者和消费者各自维护独立的读写指针空满判断放软件侧做硬件DMA只跟对应槽位地址交互。这里容易踩的坑是槽位复用消费者还在读某个槽位时生产者不能覆盖它。实现时要在消费者完成之后才把该槽位放回空闲队列。Ring Buffer在驱动层非常常见很多网卡驱动、视频采集驱动都用它来平滑不同速率的数据流。相比Ping-Pong它的优势是槽位可扩展、动态性强劣势是空满判断和指针管理更复杂边界条件更多。如果你对Ping-Pong的切换逻辑还在摸索阶段建议先把双缓冲跑熟再考虑Ring Buffer。6.3 并行思想不只在芯片内部“让搬运和计算并行”这种思想放到整个系统里一样有效。大规模集群的分布式训练数据预取、梯度传输与反向计算重叠本质是同一件事。并行SQL优化、边缘推理平台的任务流调度底层也都是把“数据准备”和“数据消费”解耦。并行执行多个独立命令和把一个任务拆成多个阶段交错执行思想源头是相通的都是要打破“串行等待”的默认假设。我在端侧做并行优化时形成的一个习惯是先画出完整的数据流图标出所有搬运与计算的依赖关系再决定在哪一级加缓冲、加几级。每次跳过大方向直接堆线程和buffer最后都要回来返工。数据流图不用画得多精美A4纸上几条箭头标清楚就行但它能帮你一眼看出哪些环节其实没有依赖、可以并行哪些环节有硬依赖、加缓冲也没用。6.4 最后分享一个小技巧也是我个人的体感做Ping-Pong优化最重要的是“先画图、再打点、后动手”这三步。画图标清楚依赖打点量化搬运耗时和计算耗时动手时尽量保持切换逻辑简单——只动状态指针不做重活。切到第二轮、第三轮时永远把“数据就绪”这个标志放在数据实际写入之后用原子操作和内存屏障保证顺序。把这些基本功做扎实Ping-Pong就是最稳定、最可控的并行加速手段之一。如果你手头也有“资源占用率高但吞吐量上不去”的模块不妨先画一张数据流图然后从一块双缓冲开始试起。
返回列表