ARTICLE DETAIL

资讯详情

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

Ping-Pong缓冲优化:让DMA与计算并行,彻底释放芯片性能

Ping-Pong缓冲优化:让DMA与计算并行,彻底释放芯片性能 1. 性能卡的根源为什么搬运和计算必须错峰做芯片架构优化的朋友应该都有过这种体验拿到一块新板子跑起一个简单的图像处理 pipeline结果一看 profilerCPU 使用率只有三成DMA 带宽也没跑满整个系统就在那儿干等。你翻来覆去看代码发现逻辑一点没错——先搬数据再算数据搬完再算下一块。这种写法放在教科书里完全正确但在真实芯片上跑就是在浪费每一毫秒。问题出在搬和算天然就是两种不同的资源。麒麟芯片这种移动 SoC 里负责搬运数据的 DMA 控制器和负责计算的 CPU/GPU/NPU 是相互独立的硬件单元。独立意味着它们理论上可以同时干活但串行写法把它们硬生生排成了先后顺序。打个比方你在厨房做饭一个人既当洗菜工又当炒菜师傅洗完一棵菜才点火炒一棵洗菜的时候锅空着炒菜的时候水槽空着人和设备全在轮流休息。真正的后厨不会这么干一定是两个人配合一个在水槽那边洗一个在灶台这边炒流动起来。单缓冲模式下就是这么浪费的。DMA 把数据从 DDR 搬到 SRAM这个过程 CPU 帮不上忙只能等搬运完成后 CPU 开始算这时候 DMA 通道闲着算完了 CPU 把它写回 DDR又是一次空等。整个时间轴拉出来就是搬→算→写→搬→算→写串行链路上每一环都有对应硬件在空转。芯片设计者把 DMA 独立出来就是为了并行你却在软件层面把并行能力废掉了。Ping-Pong 优化要解决的正是这个搬运和计算之间的时间交错问题。核心思路简单粗暴准备两块缓冲区DMA 往 A 里搬数据的同时CPU 去算 B 里已经搬好的数据等 DMA 把 A 填满、CPU 把 B 算完两者交换角色。数据搬运和计算在这个机制下像两条流水线一样保持各自满负荷理论上的耗时从搬运时间加计算时间缩短为两者中的较大值而不是两者之和。这个标题中的让搬运与计算并行起来本质就是消除硬件资源之间的相互等待而不是去优化某一行具体代码的快慢。搞清楚这个目标之后再看 Ping-Pong 具体怎么运作就会顺理成章得多。2. 乒乓操作的完整工作周期从等数据到换手2.1 双缓冲的基本状态机我在调试麒麟平台上的视频前处理模块时最开始用一张状态表把乒乓逻辑讲清楚对团队沟通帮助很大。两块缓冲区假设命名 Buffer A 和 Buffer B各自只有两个状态DMA 正在填充写计算单元正在处理读。整个系统在运行中会在这几个状态之间反复跳转初始阶段DMA 开始向 Buffer A 填充数据计算单元空闲等待Buffer B 空置。首次换手DMA 填满 Buffer A发出完成中断计算单元开始读 Buffer A同时 DMA 立即转向 Buffer B 开始填充。稳态运行DMA 在 A 和 B 之间来回填充计算单元在 B 和 A 之间来回读取永远不碰同一块正在被对方占用的缓冲区。所谓乒乓就是这两块缓冲区像乒乓球一样在 DMA 和计算单元之间来回弹跳。名字听起来有点游戏感但机制本身非常严谨——它保证了任何时刻DMA 写入的缓冲区和计算单元读取的缓冲区不可能是同一块因此不会出现一边写一边读的数据竞争问题。2.2 单缓冲与双缓冲的时间对比用一组简化数据算一下你就能直观看到差距。设搬运一块数据耗时 100 个时钟周期计算一块数据耗时 150 个时钟周期总共要处理 10 块数据。单缓冲模式是串行执行每一块都是搬 100 周期 算 150 周期总耗时就是 10 乘以 250等于 2500 周期。这里面 DMA 干活的时间只有 1000 周期其余 1500 周期 DMA 在等计算完成反过来计算单元只有 1500 周期在算剩下 1000 周期在等搬运。两块独立硬件都只发挥了六成左右的效能。Ping-Pong 双缓冲模式下呢第一块数据仍然要先搬完才能开始算这段启动时间逃不掉100 周期。但从第二块开始因为 DMA 在第一块数据被计算的同时就已经在填充第二块缓冲区了所以整个链条变成搬完第一块100周期之后计算单元连续处理 10 块数据每块 150 周期DMA 在计算单元忙于前一块的时候已经把下一块搬好了。总耗时就是 100 10 乘以 150等于 1600 周期。相比 2500 周期性能提升了约 36%而且还没有算上写回结果的时间如果算上写回差距会更明显。如果搬运时间大于计算时间比如搬运 200 周期、计算 100 周期那么总耗时就是首块搬运的 200 周期加上后续每块搬运的 200 周期10 块一共 2000 周期而串行模式要 3000 周期。注意这时候决定总耗时的变成了搬运时间计算单元反而成了跟随者。Ping-Pong 不会消除两者中较大的那项但能把较小那项完全隐藏掉。2.3 为什么换手这段路最容易被忽略很多第一次在麒麟芯片上实现乒乓的人画原理图时都觉得简单至极——两块 buffer 换来换去而已。但实际写代码就发现问题了DMA 填完 A 之后怎么让计算单元准确地知道A 已经可以被读了计算单元算完 B 之后又怎么告诉 DMAB 这块已经被读完了你可以往里面写新数据了这里有两个常见的同步手段。最简单的是 DMA 完成中断每填满一块就触发一次中断中断服务程序里把缓冲区指针切到另一块同时唤醒等待计算的任务。这种做法的缺点也很明显——高频中断对系统有压力如果一块数据只有几十微秒的处理时间中断就会像暴雨一样砸过来。另一种更高效的方式是轮询硬件状态寄存器。很多 DMA 控制器支持描述符完成位软件忙等某个地址上的标志位置位置位表示该缓冲区已经搬完。轮询可以避免中断开销但会占用 CPU 周期。实战中的选择标准是数据块大、切换不频繁时用中断数据块小、切换极频繁时宁可让 CPU 空转等待标志位也比中断上下文切换的代价低。我在麒麟平台上还试过第三种方式让计算单元完成任务后直接写一个硬件清标志寄存器这一步其实是告诉 DMA这块可以覆写了。这等于是把同步从软件中断层面下沉到硬件层面双缓冲切换的开销降到了十几个周期。具体实现细节在不同芯片上差异较大但思路是共通的——同步机制越靠近硬件乒乓切换的代价就越低。3. 麒麟芯片上落地 Ping-Pong 必须较真的几个工程细节原理图看着简单落地时有一堆细节会扎手。麒麟芯片作为移动端 SoC它的内存架构、缓存策略、DMA 通道配置都有自己的脾气。我在实际项目里踩过的坑基本集中在下面五件事上。3.1 Cache 一致性最容易翻车的地方计算单元读数据的时候数据可能还在 Cache 里没有落回 DDRDMA 往 DDR 里写数据的时候Cache 里可能还留着旧版本的数据。一旦两边失去同步计算单元读到的就是Yesterday once more——过期数据而且这种 bug 神出鬼没极其难查。在麒麟平台上做 DMA 搬运标准的流程是用 DMA 映射接口如dma_map_single/dma_unmap_single来管理缓冲区。映射的时候驱动可以指定方向DMA_FROM_DEVICE表示设备往内存写数据映射动作会帮你做 cache invalidate使缓存失效确保后续 CPU 读取时直接从 DDR 拿新数据DMA_TO_DEVICE表示设备从内存读数据映射时做 cache clean把脏数据写回 DDR确保 DMA 看到的不是 Cache 里的残影。我见过不少刚从裸机开发转过来的人习惯了自己手动操作 cache 维护函数在麒麟 Linux 环境下依然想手动 clean/invalidate。这种做法的风险在于内核的 DMA 框架在特定场景下会做双层维护你自己再插一手就可能导致 cache line 里残留错误。更稳妥的做法是缓冲区用dma_alloc_coherent分配它直接给你一块非缓存的物理内存CPU 和 DMA 访问天然一致不需要任何手动维护——代价是 CPU 读写这块内存会慢一些因为绕过了 Cache。对吞吐要求高、命中率又低的流水数据来说这点代价通常可接受。3.2 缓冲区对齐64 字节是底线128 字节更安心麒麟芯片的 Cache line 大小通常从架构上可以查到ARMv8 体系下一般是 64 字节。DMA 描述符和缓冲区如果不对齐到这个粒度后果不只是慢一点点——它可能导致一个 Cache line 被 DMA 和 CPU 反复互相拖拽硬件为了维持一致性来回做总线同步延迟翻倍都是轻的。我处理视频帧数据时习惯把每块 Ping-Pong 缓冲区按 128 字节对齐。这么做有两个原因一是麒麟平台的某些 DMA 引擎在突发传输模式下传输粒度正好是 128 字节对齐之后可以完整利用突发长度避免首尾两小段被拆成分散事务二是 SIMD比如 NEON做数据读写时128 位宽的加载指令本身就偏好对齐地址顺手也能提升计算端的效率。分配内存的代码很简单核心是确认分配的起始地址和长度都满足对齐要求。用kmalloc的话要显式传__GFP_DMA之类的标志或者直接用dma_alloc_coherent一次性解决对齐和一致性两件事。3.3 内存屏障标志位的可见性问题在双缓冲切换的代码里你通常会维护一个软件标志比如buffer_ready[A] true用来表示A 已经被 DMA 填完。但 CPU 和编译器可能会对这个标志的读写做重排——编译器觉得先写 buffer 内容再置标志位是理所当然的但到汇编层面顺序未必有保证CPU 的乱序执行和 store buffer 也可能让标志位先于数据被其他核心看到。我早年调试一个多核协作的乒乓系统就遇到过核 0 处理完数据置位标志然后继续干活核 1 看到标志位变化立刻去读数据结果读到的是半新不旧的内容。原因就是核 0 的写数据操作还在 store buffer 里排队标志位却已经冲出重围被核 1 看到了。解决方法是成对使用内存屏障。置标志位之前先执行一次写屏障dsb st之类确保前面的数据写入已经落定读取标志位的时候用带读屏障的方式加载确保标志位一旦为真后续的数据读取不会被重排到标志判断之前。如果用的是 Linux 内核开发smp_store_release和smp_load_acquire这两组接口就能同时完成屏障和数据交换比我手动插屏障干净得多。3.4 DMA 描述符链把搬运变成一条流水线单次 DMA 搬运的启动开销并不低——要配置源地址、目的地址、传输长度、突发大小还要等 DMA 引擎把描述符装载完毕。如果你每一块数据都走一次完整的配置→启动→中断→重新配置流程Ping-Pong 节省下来的计算等待时间又会从配置开销里漏掉。更聪明的做法是初始化时就把两个描述符链接成一条链DMA 引擎按顺序自动处理先搬 A搬完发中断并自动装载 B 的描述符搬完 B 再自动回到 A。描述符之间的跳转完全由硬件完成软件只需要在中断里检查现在停在哪一块。这就像洗衣机支持连续投放衣物而不是每洗一桶都要重新按一遍启动键。在麒麟芯片的 DMA 驱动里链式描述符通常是一组预分配的结构体每个结构体里填写好下一跳的指针。初始化的时候把 A 描述符的 next 指到 B再把 B 的 next 指回 A一个环形描述符链就成型了。实际运行时DMA 引擎始终在这条环上循环奔跑软件的角色从发指令的人变为在换衣间偷偷换衣服的人——趁 DMA 在搬一块数据的时候把上一块已经被算完的缓冲区重新挂回空闲列表。3.5 计算单元访问乒乓缓冲区的习惯首选 ping-pong 专用内存设置 Ping-Pong 缓冲区时除了对齐和一致性还要选对内存属性。麒麟芯片的 DDR 带宽是共享资源NPU、GPU、ISP、CPU 同时跑的时候争抢很激烈。如果你把乒乓缓冲区放在普通缓存内存里每次 DMA 搬运都要做一次 invalidate如果该地址恰好被 CPU 预取进了 Cacheinvalidate 的开销会被放大好几倍。我的处理习惯是单独预留一块内存池专门用来做 Ping-Pong 数据交换。这块区域用 UNMAP 属性配置成外设写、CPU 读的流动缓冲区CPU 正常用 Cache 读没问题但不要在这里做频繁的随机写。数据进来之后CPU 做的是只读处理处理结果写到另一块输出缓冲区里。这样可以把 Cache 维护的频率降到最低限度DMA 往这块区域写数据的带宽也有了确定性保障。麒麟芯片的 DMA 引擎一般来说读 DDR 的带宽表现不错但外设访问走哪个路径、会不会跟 GPU 抢带宽不同平台差异很大建议拿到板子后先跑一轮带宽基准测试再定缓冲区位置。4. 参数调优与实测缓冲区多大、切换频率多快才合适4.1 先从带宽延迟乘积估算起步缓冲区大小的选择理论上有一个非常经典的估算公式——你希望 DMA 在计算单元忙完上一块之前把下一块搬完。假设计算单元处理一块数据需要的时间是 (T_c)DMA 搬运一块数据需要的时间是 (T_d)那么只要 (T_d \le T_c)Ping-Pong 就能完美隐藏搬运开销。反过来如果 (T_d T_c)说明搬运成了瓶颈你需要考虑把数据块拆小——虽然拆小后切换会更频繁但至少计算单元能在每块搬运间隙里插空算完一部分不要让某个硬件完全闲下来。实际工程里我更倾向于做一次线性的时间预算。先确定目标帧率或数据吞吐量算出每个处理周期可分配的周期数然后倒推单块数据允许的最大搬运体积。举个例子麒麟平台上一路 1080p30 的视频前处理每帧时间预算约 33 毫秒假如计算占比 60%剩下 40% 也就是约 13 毫秒要给搬运让路。用平台实测的 DDR 带宽比如 10GB/s算13 毫秒内可以搬 130MB 数据而一帧 1080p YUV420 数据量不到 3MB理论上绰绰有余。这种场景下缓冲区大小不必设太大反而要小心缓冲区过大导致 Cache 命中率下降CPU 读数据时频繁 miss。4.2 切换频率和中断开销的平衡缓冲区大小直接决定了切换频率。缓冲区设得小切换次数多DMA 完成中断的频率就高。每次中断哪怕只花 5 微秒做上下文切换和标志更新一秒内几千次中断也会侵蚀掉大量 CPU 预算。我在一个图像缩放模块上做过统计缓冲区设为 32KB 时每秒中断次数约 9000 次中断处理总耗时占 CPU 约 4.5%缓冲区增大到 128KB 后中断次数降到 2300 次左右中断开销占比降到 1.1%但计算端开始偶尔出现等数据的情况因为单块数据变大后计算耗时超过了搬运隐藏区间。最后折中取了 64KB中断开销约 2%又没明显拖慢计算。这里的调参过程没有银弹关键是先把搬运时间曲线和中断开销曲线分别测出来。测搬运时间很简单DMA 从 DDR 搬一块数据到 SRAM打点记录起始和结束的 cycle 计数。测中断开销可以临时挂一个空的 handler统计同一时间段内的调用次数再乘以单次空处理时间。两张曲线图叠加起来最优区间就会一目了然选一个点让搬运时间小于计算时间同时中断总开销占比控制在几个百分点以内。4.3 多级流水两块缓冲区不够怎么办双缓冲解决的是搬运和计算两个阶段的并行。如果整个链路拉长到三个环节——DMA 搬运、预处理、核心计算——双缓冲就不够用了DMA 填完 A 后预处理单元开始处理 A核心计算单元却空着要等预处理做完核心计算才有数据可算。这就是为什么有些高性能场景会采用三缓冲甚至四缓冲。我实现过一种折叠方式把 Ping-Pong 扩展成两对缓冲区一对给 DMA 搬运用一对给预处理单元和核心计算之间交换用。DMA 搬运进来的原始数据放在入口跨接区预处理单元从跨接区读取并在预处理缓冲上做变换核心计算单元从预处理缓冲上读结果。链路加深后调度复杂度成倍上升但吞吐也明显提升。如果你刚接触 Ping-Pong我建议先把双缓冲调通再考虑多级流水——双缓冲是基本功多级流水是进阶功跳级容易写成 bug 盛宴。4.4 实测数据怎么打点才可靠性能打点这件事我见过太多人测出漂亮但不真实的数据。问题多半出在打点方式上。常见的错误是直接用高精度计数器读出 cycle 数值但编译器优化把打点代码挪了位置或者 CPU 动态调频导致 cycle 数和真实时间不成比例。更可靠的做法是关掉动态调频调到 performance governor用硬件性能计数器如 ARM PMU 的CPU_CYCLES和STALL_BACKEND来测。关调频是为了让 cycle 计数和时间有确定对应关系读 PMU 寄存器要用isb同步避免读到旧值。另外测 Ping-Pong 效果时千万不要只测整个 pipeline 跑完的总时间那样你分不清节省的时间到底来自计算还是来自搬运。要为每个阶段单独打点DMA 搬运起始/结束、计算起始/结束、换手完成时刻。把时间轴拉出来之后如果发现某个阶段总时长已经低于理论最小值多半是打点本身插入了额外指令影响了流水线需要换一种更轻量的打点方式。5. 踩坑实录从理论到稳定运行三个真实教训5.1 脏数据陷阱Cache 里的陈年旧账第一次在麒麟平台上跑通 Ping-Pong 时我信心满满地投了一组测试帧进去结果输出图像出现了诡异的条纹——每隔一段就有一行旧图像残留。查了两天最终定位到 Cache 一致性问题。当时的缓冲区用的是普通kmalloc内存DMA 搬运完之后直接发中断告诉 CPU 可以读了。但从 CPU 的视角来看这块缓冲区的内存地址早就在 Cache 里缓存过了——上一次计算循环结束后Cache 里残留的是旧数据。DMA 往 DDR 里写了新数据Cache 却不知道这件事CPU 读数据时直接从 Cache 命中拿到的是上一次的残留于是每块数据里都夹杂着上一帧的影子。后来我把缓冲区分配改成了dma_alloc_coherent一次性绕开 Cache这种影子问题就再没出现过。这个教训告诉我凡是 CPU 和 DMA 会共享访问的缓冲区分配策略必须从一开始就算清楚不能等到出问题了再补 cache 维护函数。5.2 中断风暴100% CPU 的假象还有一次Ping-Pong 逻辑跑起来之后整个系统的 CPU 占用飙到了 100%我一度以为是计算量太大。但仔细一看 CPU 时间都耗费在中断上下文里——DMA 完成中断像机关枪一样连续触发每个中断服务程序里又做了缓冲区指针切换和唤醒操作唤醒一个高优先级的处理线程处理线程跑几步又去睡睡下又被下一个中断唤醒颠来倒去全是开销。解决方法有两个方向一是数据块改大降低中断频率二是在中断服务程序里只做必要的状态翻转比如写一个标志寄存器实际的数据处理全部推迟到内核线程或者软中断里。第二个方向尤其重要——中断服务程序越短越好这是嵌入式开发的基本素养但在乒乓切换中很容易被忽略因为你觉得多写两行汇编没啥。把中断服务程序精简到 10 行以内后CPU 占用从 100% 降到了 35%这时候才真正看出来 Ping-Pong 带来的计算能力余量。5.3 边界块处理最后一帧不能丢乒乓循环跑得正顺时别忘了头尾两块的边界问题。启动阶段DMA 往 A 里搬完第一块数据之前计算单元没有任何可算的东西——这段时间既不能空等也不能乱算。我通常的做法是启动后计算单元先进入阻塞等待状态等第一个完成中断唤醒它。这会导致启动阶段有大约一块数据的搬运延迟无法避免。收尾阶段的问题是DMA 可能已经把最后一块数据搬到了 A但计算单元还在算 B 里的倒数第二块等 DMA 发现没有新数据可搬它停在那里而计算单元算完 B 之后去看标志发现 A 虽然填好了但 DMA 已经关停了没人为 A 的完成再发一次中断。处理办法是处理完最后一块数据后在计算单元的任务里手动置一次最后一个缓冲区已就绪标志等于补一次软件触发的虚拟中断。我把最后一块的处理逻辑单独抽成一个函数不跟主循环混在一起这样双缓冲循环可以保持纯粹的对称性边界情况只在一个地方处理出 bug 的概率大大降低。6. 更进一步的优化思路从双缓冲到多级流水如果你把双缓冲吃透了接着可以往更宽的方向走。Ping-Pong 本质上是对生产者-消费者模型的一种硬件感知优化它把生产者DMA和消费者计算单元之间的重叠做到极致。但现实中链路往往不只是两段可能是 ISP 出帧、DMA 搬到内存、CPU 做预处理、NPU 做推理、DMA 搬回、CPU 做后处理——每一段交接点都可以部署一对乒乓缓冲区。我见过团队在麒麟平台上跑端侧 AI 推理时把预处理、模型推理、后处理三个环节逐一乒乓化整体延迟降了将近一半。核心经验是等下一对缓冲区的状态必须先于上一级任务结束被准备好否则上级任务结束时要等待下一级腾地方流水线就会断流。这和 CPU 流水线中的前递forwarding很像——数据从上一级刚产生最好下一级立即可用但允许指令之间差一拍。更高级的玩法是让 DMA 描述符链和环形队列结合做一个多级柔性缓冲池。Ping-Pong 是两块缓冲的固定交替环形队列是 N 块缓冲的循环复用灵活性更高但调度复杂度也更大。起步阶段先按两块缓冲把机制跑通再去扩成环形队列控制风险会好很多。另外如果你的计算单元本身就是多核的比如麒麟的 CPU 集群有多个大核那可以考虑把输入数据按块分散到多个核上各自跑一段独立的 Ping-Pong 流程核与核之间无共享缓冲省掉大量同步开销。这种方式在数据可分片的场景下图像分块、音频分段收益特别明显但如果数据块之间有依赖就老老实实按顺序来不要强行并行。我个人在实际操作中的体会是Ping-Pong 优化的边界其实很清晰——它只解决搬运与计算的重叠问题不负责解决计算算法本身的效率。先把搬运链条理顺再回头看计算热点两者互相配合才能真正榨干麒麟芯片这种异构 SoC 的性能。最后再分享一个小技巧调优过程中把每个版本的改动单独存档记录下缓冲区大小、中断策略、描述符配置这三组关键参数出问题时方便快速回退对比。芯片性能调优是个迭代过程有好的基线版本在手心里才不慌。
返回列表