Ping-Pong双缓冲:让移动端图像处理的数据搬运与计算并行起来
2026/9/7 3:00:52 网站建设 项目流程

做移动端图像优化的工程师,大概都见过这种尴尬:麒麟芯片架构标称的算力一个比一个高,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的实现思路其实非常朴素:准备两块buffer,A和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写入的buffer,CPU如果不主动失效对应的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就是最稳定、最可控的并行加速手段之一。如果你手头也有“资源占用率高但吞吐量上不去”的模块,不妨先画一张数据流图,然后从一块双缓冲开始试起。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询