拓冰建站拓冰建站
首页 / 资讯中心 / 正文

麒麟芯片Ping-Pong双缓冲:让数据搬运与计算并行

手机SoC做性能优化做久了你会发现一个特别反直觉的事实很多时候算力再强也顶不上数据搬运拖的后腿。麒麟芯片这种集成度极高的移动平台更是这样CPU、GPU、NPU、ISP、DSP全塞在一块芯片里数据要在内存和各个加速单元之间来回倒腾“搬数据”的时间往往比“算数据”还长。所以“让搬运与计算并行起来”就成了绕不开的核心问题而Ping-Pong双缓冲就是解决这个问题最经典、也最实用的一招。这篇文章从原理讲到落地再附上我实际调试时踩过的坑希望对做嵌入式开发、驱动编写和芯片验证的朋友有点参考价值。1. 搬运与计算的冲突为什么芯片会“等数据”1.1 冯诺依曼瓶颈计算单元在等“菜”先把问题还原清楚。任何一次数据处理本质上都由“取数→计算→写回”三个动作组成。在冯诺依曼架构下指令和数据共用一条通路计算单元要执行任务第一步必须去内存里把指令和数据取回来这个“访存”动作从计算机诞生那天起就和计算速度之间存在巨大落差。学术界管这个叫“内存墙”意思是计算单元再快也会被内存访问的速度挡在墙里面。放到麒麟这种SoC里内存墙的感受会特别明显。比如NPU做一次图像推理需要把一张图片从DDR内存搬到NPU的片上SRAM算完之后再把结果搬回DDR。DDR的带宽数字看起来很吓人但实际访问延迟高达几百纳秒而NPU内部的计算时钟可能已经跑到1GHz以上。这意味着NPU每等一次数据中间就白白浪费掉几十甚至上百个时钟周期。数据量一大浪费就成规模了。我见过不少刚入行的朋友拿到一个算法加速任务第一反应就是死磕计算路径循环展开、指令重排、查表优化全都试一遍结果性能提升还不到10%。后来用性能分析工具一测发现一半以上的时间全耗在数据搬运上。这就是典型的“算得快不如搬得快”场景。Ping-Pong优化的核心就是专门解决这个“搬”和“算”互相等待的问题它用两个缓冲区交替工作让数据搬运和计算在时间上重叠起来不让任何一个环节空转。1.2 搬运数据的两条路DMA与CPU直接搬在深入Ping-Pong之前还得把“搬运”这件事的两种实现方式说清楚否则后面很多细节都会对不上号。第一种是CPU直接搬运。也就是CPU通过LOAD/STORE指令先把数据从源地址读到寄存器再写到目的地址。这种方式简单直观但代价极大搬运期间CPU被完全占住什么计算都干不了而且每次搬多少取决于寄存器宽度吞吐量上不去。打个比方你让一个资深工程师去复印文件他确实能复印但整个团队的技术问题就没人处理了这是极大的资源浪费。第二种是DMADirect Memory Access直接内存访问搬运。DMA是芯片里专门负责搬运的硬件模块CPU只需要告诉它三件事源地址、目的地址、搬运长度然后触发一次搬运DMA就会自动把一大块数据从内存搬到外设或者从外设搬到内存。搬运期间CPU完全解放出来可以继续算自己的东西。搬运完成后DMA会发一个中断通知CPU“我干完了”。在麒麟这种复杂SoC里ISP、NPU、视频编解码器基本都配了自己的DMA引擎或者挂在系统级DMA控制器下面。Ping-Pong优化的前提就是这种“能独立搬数据”的硬件必须存在。没有DMA双缓冲只能靠软件轮询模拟CPU一样被拖死效果大打折扣。2. Ping-Pong优化核心思路双缓冲如何让搬运和计算并行2.1 单缓冲的痛算一会儿、等一会儿要理解双缓冲的价值先看单缓冲是怎么拖后腿的。假设一个视频采集场景摄像头每帧数据到达后DMA把数据搬到内存里的Buffer0。搬完产生中断处理核心开始对这一帧做处理。处理完摄像头下一帧数据又来了DMA再把它搬到Buffer0。这里有个致命问题如果下一帧数据到达时处理核心还在搞上一帧两件事就撞车了。DMA不能往Buffer0里写新数据因为那会覆盖掉还没处理完的旧数据。于是DMA只能原地等待摄像头采集到的数据没地方放要么丢弃要么在外面憋着。把时间线画出来就是搬运100ms→计算100ms→搬运100ms→计算100ms纯串行。整个系统的处理周期等于搬运时间加计算时间吞吐量直接被砍掉一半。而且这只是理想情况一旦计算偶尔慢了丢数据的风险立刻出现。很多刚开始做驱动开发的朋友遇到这种问题第一反应是“那我把缓冲加大一点”但加大的只是等待的蓄水池并没有改变“搬和算不能同时进行”这个根本矛盾。2.2 双缓冲的精髓两个桶倒着接水Ping-Pong的思路特别朴素既然一个缓冲会在“旧的没算完、新的又要来”时冲突那就准备两个缓冲。用接水的例子来看。Buffer A是第一个桶Buffer B是第二个桶。DMA先把数据接到A桶里接满后通知计算核心“A桶满了你开始处理A”。与此同时DMA立刻转头往B桶里接下一批数据。计算核心在处理A的时候B桶正在被DMA灌水。等B桶灌满、A也处理完了两者交换角色DMA往A里灌下一批计算核心处理B。时间线就变成了搬运A100ms的同时B闲置搬运B100ms的同时计算A100ms同步进行然后搬运A100ms的同时计算B100ms也在跑。从第二个周期开始每个周期的有效时间只需要一个搬运时间计算被完整地藏在搬运背后。理想情况下吞吐量直接翻倍而且计算核心不再空等数据。“Ping”和“Pong”这俩名字来自乒乓球球在A和B两个缓冲之间来回打你接完我接节奏感极强。理解了这两个桶的交替关系后面看硬件寄存器配置和软件状态机切换都会轻松很多。2.3 为什么是Ping-Pong除了它还有谁你可能会问要实现搬运和计算的并行除了双缓冲还有别的招吗我每次做方案选型时一般会在三样东西里挑Ping-Pong双缓冲、环形缓冲Ring Buffer、多级缓冲池。环形缓冲是把多个缓冲排成一个环生产者DMA和消费者计算核心各自维护读写指针只要读指针不追上写指针就能持续工作。它的优势是适合数据量波动大、持续流式的场景槽位之间还能复用。但痛点也很明显多个缓冲带来的内存开销更大指针管理复杂一旦消费者处理速度跟不上读写指针的竞态问题就会让人头疼。多缓冲池是更重的方案一般用3个甚至4个缓冲来进一步吸收处理时间的抖动适合处理耗时特别不稳定的场景。而Ping-Pong双缓冲的定位是“最小配置的双工并行”两个缓冲一个填一个算交替使用逻辑最简单同步开销最小非常适合数据到达节奏稳定、计算时间基本固定的场景。摄像头帧率固定、音频采样率固定、NPU输入帧尺寸固定这些全是Ping-Pong的舒适区。麒麟芯片上的图像信号处理器ISP数据流、视频硬编解码、NPU的输入输出搬运大多采用Ping-Pong或者以Ping-Pong为基础的扩展机制。原因不复杂这些场景数据速率稳定双缓冲简单可靠硬件实现成本低驱动代码好写出了故障也好查——你只需要盯住两个缓冲、两条控制路径就够了。工程上越简单的方案越可靠Ping-Pong在芯片这种对稳定性要求极高的环境里能一直占主导地位不是没有道理的。3. 麒麟芯片架构中的Ping-Pong落地从ISP到NPU3.1 典型场景一ISP图像数据流麒麟芯片里的ISP是Ping-Pong用得最典型的地方。摄像头传感器输出的RAW数据是持续不断的流不管上一帧算没算完下一个像素、下一条扫描线的数据都会按照固定的时钟周期准时到达。如果ISP内部的处理单元因为拿不到数据而停下来整条图像流水线就会产生反压严重的时候直接丢帧、花屏。实际SoC里通常这样设计摄像头接口CSI收到数据后DMA按照预先配置好的地址把数据交替写入Buffer A和Buffer B两个内存区域。当一帧或一条扫描线写完后硬件会发出一个“帧完成”中断同时自动把DMA的目标地址切到另一个缓冲区。ISP核心收到“Buffer A ready”信号后开始从A地址读数据处理处理的同时DMA已经在往B写下一帧了。一轮处理完角色互换如此往复。这里有个关键的硬件设计点DMA地址切换到底是硬件自动完成还是需要软件介入早期一些芯片方案驱动需要在中断服务程序ISR里手动改写DMA的目的地址寄存器。这不仅占用CPU时间还容易因为中断响应不及时而漏掉切换导致数据错乱。后来主流做法是用“描述符链表”预先配好两条DMA描述符硬件处理完一条后自动跳到下一条对应的描述符中间完全不需要CPU干预。麒麟这类成熟SoC的DMA控制器基本都支持描述符链表的自动跳转Ping-Pong在硬件侧往往就体现为一组互相咬合的链表节点软件只负责在初始阶段把链表建好剩下的全交给硬件。3.2 典型场景二NPU推理中的数据搬运NPU跑深度学习模型前处理要缩放图片、做数据归一化后处理要解码输出中间的矩阵运算在NPU内部完成。输入数据和输出结果都得经过DDR与片上SRAM之间的搬运。如果搬运和计算没有重叠NPU会频繁停下来等数据算力利用率可能跌到50%以下。花大价钱堆出来的TOPS算力最后实际用上的不到一半你说亏不亏。用Ping-Pong的思路通常可以做两级双缓冲。第一级在输入侧DMA把下一张图片往Buffer A搬的同时NPU正在用Buffer B里的数据做推理。第二级在输出侧NPU把上一轮的推理结果写到Out ADMA同时把Out B里的结果搬回DDR。两级乒乓嵌套起来整个流水线在稳态下每个周期都在同时做“搬输入、算模型、搬结果”这三件事中的至少两件NPU利用率能从串行模式的50%左右提升到接近90%。这块我实际调过最大的感受是不少NPU的SDK已经封装好了Ping-Pong逻辑但封装好的东西有个问题——你不清楚它内部的缓冲切换策略。遇到算子吞吐上不去还是得回到驱动层去看中断是怎么触发的、缓冲描述符有没有正确配对、Cache有没有及时维护。后面排查部分我会把这些细节展开讲。3.3 在SoC总线视图里看Ping-Pong再往底层走一点。麒麟芯片的各个模块挂在AMBA总线上数据通路是典型的“主-从”结构。DMA和外设是总线主设备MasterDDR控制器是从设备Slave。Ping-Pong缓冲的两个内存区域通常分配在DDR里。为了减少对总线的占用有些场景还会把其中一个缓冲区放到片上SRAM另一个放DDR形成“片上-片外”混布的双缓冲。这种混布方案的好处是计算单元读片上SRAM的速度远比读DDR快适合放实时性要求高的中间结果。坏处是片上SRAM容量有限只适合放紧凑的数据块。正式产品里到底怎么部署需要在性能、面积、功耗之间反复权衡。我参与过的项目里给视频编解码器做中间帧缓冲时就试过“全放DDR”“全放SRAM”“乒乓混布”三种方案最后是乒乓混布胜出——性能最高但代价是驱动代码里Cache操作的复杂度上了一个台阶出问题也更难定位。工程上的收益和代价永远是并存的这一点值得反复提醒自己。4. 实操过程一个简化版Ping-Pong搬运驱动到库里来点实际的。下面用一个简化但完整的嵌入式驱动示例演示Ping-Pong的核心流程。场景设为DMA从外设FIFO里收数据交替写入两个缓冲CPU侧每收到“缓冲就绪”中断就对缓冲里的数据做一次简单处理这里用累加和代替真实算法。4.1 数据结构与初始化先定义两个缓冲和一个控制结构。为了贴近嵌入式真实情况用C语言地址用物理地址概念。实际使用中可能要做虚拟地址映射这里先不展开。#define BUFFER_SIZE 4096 static uint32_t ping_buf[BUFFER_SIZE] __attribute__((aligned(32))); static uint32_t pong_buf[BUFFER_SIZE] __attribute__((aligned(32))); typedef enum { BUF_PING 0, BUF_PONG 1 } buf_id_t; typedef struct { uint32_t *addr[2]; volatile buf_id_t dma_target; // DMA当前正在写的缓冲 volatile uint32_t ready_flag[2]; // 每个缓冲的就绪标志 } pingpong_ctrl_t; static pingpong_ctrl_t pp_ctrl;初始化时把两个缓冲的地址和DMA的初始目标都设好。初始状态让DMA先写Ping此时还没有任何数据可处理两个缓冲的就绪标志都清成0。void pingpong_init(pingpong_ctrl_t *pp) { pp-addr[0] ping_buf; pp-addr[1] pong_buf; pp-dma_target BUF_PING; pp-ready_flag[BUF_PING] 0; pp-ready_flag[BUF_PONG] 0; // 配置DMA目的地址 pp-addr[pp-dma_target]长度 BUFFER_SIZE * 4 dma_config(pp-addr[pp-dma_target], BUFFER_SIZE * sizeof(uint32_t)); dma_start(); }这段代码有两个容易踩坑的细节。第一缓冲要按32字节对齐。许多DMA控制器要求源和目的地址对齐到总线宽度AXI总线一般按16字节边界做突发传输用32字节对齐更保险能避免总线把一次突发拆成两半。第二ready_flag一定要用volatile修饰。这个标志会在中断服务程序里被改写主流程里要被读取如果编译器把它优化到寄存器里就会导致两侧看到不一致的值这种bug极难排查。4.2 中断处理Ping-Pong切换的核心DMA完成一次搬运后会产生中断。中断服务程序里要做三件事把刚写完的缓冲标记为“就绪”切换DMA目标到另一个缓冲然后是覆盖保护判断——检查下一个缓冲的数据是否已经被处理完。void dma_isr(void) { buf_id_t done pp_ctrl.dma_target; // 刚刚被写满的缓冲 buf_id_t next (done BUF_PING) ? BUF_PONG : BUF_PING; // 关键检查下一个缓冲如果还没处理完说明计算侧落后了 if (pp_ctrl.ready_flag[next] ! 0) { // 处理不过来按系统策略丢弃或报错 err_handler(ERR_PROCESS_OVERRUN); } // 标记本次完成的缓冲可被处理 pp_ctrl.ready_flag[done] 1; // 切换到另一个缓冲作为DMA目标 pp_ctrl.dma_target next; dma_config(pp_ctrl.addr[next], BUFFER_SIZE * sizeof(uint32_t)); dma_start(); // 通知处理任务软件中断/信号量唤起处理线程 compute_notify(done); }这里最容易被忽略的就是那个overrun检查。如果计算核心的处理速度跟不上DMA的搬运速度两个缓冲都会被占满且都没有被处理完。这时候如果还硬性切换DMA就会把还没算过的数据覆盖掉数据处理结果直接错乱。实际产品里这个分支通常对应一个丢帧策略摄像头场景里直接丢弃最旧的那一帧数据优先保证实时性音频场景里可能要做插值或静音补齐。总之不能什么都不做让数据在无感知中被写坏。4.3 处理端消费数据处理端的代码相对简单收到通知后读取done对应的缓冲处理完后清掉就绪标志。void process_thread(void) { buf_id_t id; uint32_t sum 0; wait_notify(id); // 阻塞等待中断通知 if (pp_ctrl.ready_flag[id] 0) { return; // 异常情况防御性处理 } // 处理缓冲区数据实际场景替换为真实算法 for (int i 0; i BUFFER_SIZE; i) { sum pp_ctrl.addr[id][i]; } // 处理完成清标志允许DMA下次写入 pp_ctrl.ready_flag[id] 0; process_result(sum); }到这里一套完整的Ping-Pong流程就跑通了。从时间线上看第二个DMA周期开始时DMA写Pong的同时处理器在算Ping的数据第三个周期DMA写Ping的同时处理器在算Pong的数据。搬运和计算的重叠就此形成。注意处理线程里清ready_flag的时机必须准确早清了DMA可能覆盖未处理的数据晚清了DMA就会误判overrun实际测试中要反复验证这个时序窗口。4.4 为什么要加内存屏障与Cache维护如果你把这段代码放到真实的麒麟SoC上跑大概率会遇到一个诡异问题DMA搬运完成后CPU读到的数据有时是旧的。这不是代码逻辑错了而是Cache一致性在作祟。CPU访问内存时会先把数据缓存到L1/L2 Cache里。DMA是独立的总线主设备它写内存时不会主动通知CPU“你Cache里的数据过期了”。如果CPU的Cache里还留着之前的数据副本读出来的就是旧值。解决办法是在DMA写之前对目的缓冲做Cache Clean把Cache里的脏数据刷回DDR在DMA写完、CPU要读之前做Cache Invalidate让Cache行失效强制下次从DDR重新加载。// DMA搬运前把缓冲对应的脏Cache行刷回DDR cache_clean(ping_buf, BUFFER_SIZE * sizeof(uint32_t)); cache_clean(pong_buf, BUFFER_SIZE * sizeof(uint32_t)); // DMA完成中断里 cache_invalidate(pp_ctrl.addr[done], BUFFER_SIZE * sizeof(uint32_t));这块是驱动开发里最容易踩的坑也是麒麟这类带MMU和Cache的高性能SoC上跑Ping-Pong必做的功课。很多从裸机MCU转过来的朋友没有这个意识一到Linux或RTOS环境就翻车。而且Cache操作本身也有时间开销操作粒度越小越频繁性能损失越大。所以实际工程里要算清一笔账Cache维护的代价和等待数据的代价哪个更高再决定用不用、多久用一次。5. 常见问题与排查技巧实录5.1 处理速度跟不上overrun丢数据症状中断频率稳定但系统统计里丢包、丢帧率持续上升。排查逻辑先确认计算侧单次处理耗时是不是稳定小于DMA搬运周期。如果单次处理时间接近甚至超过搬运周期双缓冲迟早被填满触发上一节说的overrun。解决办法有几个方向优化处理算法缩短单次处理时间。增大缓冲区长度但这不是根治只是拉长缓冲的时间窗口。升级到三级甚至四级缓冲把处理耗时的瞬时抖动吸收掉。在系统层面主动降载比如摄像头降帧率、音频降采样率。我实测中遇过一个有意思的案例某团队用Ping-Pong跑AI前处理处理线程偶尔被RTOS里的高优先级任务抢占导致偶发overrun。表面看是缓冲个数不够实际是调度抖动。后来把处理线程优先级提到DMA中断之下、其他系统任务之上问题就消失了。Ping-Pong虽然是硬件友好方案但在软件侧任务优先级和中断响应时间同样决定成败。5.2 Cache一致性问题数据“明明搬完了却读不到”症状DMA中断已经触发但CPU读到的缓冲内容还是上一次的旧数据。重启后第一次大概率正常跑一段时间才异常。这是典型的Cache一致性问题。我踩过最狠的一次是给视频通路做DMA采集编码器从同一段地址读数据结果偶发花屏。光看代码没有任何问题最后用总线跟踪工具抓波形才确认DMA确实把数据写到了DDR但编码器侧的高速缓存没有失效读的是陈旧Cache行。解决思路是搞清楚数据通路里谁写的、谁读的在合适的位置加Cache Clean或Invalidate。务必区分两者Clean是把Cache里的脏数据写回DDR解决“CPU写了但DMA读不到”的问题Invalidate是把Cache行标记为失效解决“DMA写了但CPU读到旧数据”的问题。下Cache操作指令时还要注意操作的是虚拟地址还是物理地址以及操作范围是否已经对齐到Cache Line。5.3 DMA描述符链断链切换失灵症状系统跑一阵后DMA突然停止工作中断不再触发数据完全静止。通过调试寄存器查看DMA停在某个描述符上不再前进。这种问题多数出在描述符链表的配置上。Ping-Pong用两个描述符互链时如果软件在处理完某次中断后重置描述符回写状态的时机不对或者把“当前描述符”指针改错链路就会断。建议做法是描述符链表配置好之后驱动层只通过“使能/禁用”来控制不要在中断处理函数里频繁改写描述符的关键字段。如果确实需要修改某个描述符务必先停DMA、改链路、再重新启动避免硬件在读取描述符的过程中出现半更新状态。另外检查DMA中断标志是否被正确清除。有些DMA控制器的中断是电平敏感的不清标志就一直触发有些是边沿敏感的漏一次服务波形就乱了。写驱动第一步就是翻寄存器手册确认中断类型和清除时序这个功课省不了。5.4 缓冲对齐与总线效率症状Ping-Pong逻辑完全正确但DDR带宽利用率不高比理论峰值低一大截。原因常常是缓冲地址没有对齐到Cache Line通常是64字节或者总线突发长度对应的字节数AXI突发常见16/32/64字节。地址不对齐时总线会把一次突发拆成好几次传输效率自然降低。建议分配缓冲时用aligned_alloc(64, size)之类的接口并且在描述符配置里确认地址位宽和掩码没有截断对齐位。这个检查很简单但能省下大量排查性能的时间。6. 从Ping-Pong延伸并发与并行的通用思维最后聊点超出芯片层面的思考。Ping-Pong解决的是“搬运”和“计算”这对生产者和消费者之间的并行问题而这个思路在软件世界里的应用远比想象中广。比如Linux下用双线程模拟双缓冲一个线程负责读IO数据另一个线程负责处理中间用两个槽位交替交换数据。再比如数据库并行SQL优化表的扫描和Join计算如果能分阶段重叠本质上也是Ping-Pong时间重叠思想的变体。甚至流程引擎里那些“并行网关”“包含网关”的配置也是把没有依赖关系的分支并行执行让整个流程的吞吐量提上去——背后的思维模型和乒乓交替是共通的。我观察过不少团队写并行程序一上来就上线程池、Actor模型、消息队列框架很重效果却很一般。原因往往是把“并发”和“并行”搞混了。并发是逻辑上同时处理多个任务并行是物理上同时执行多个任务。Ping-Pong双缓冲之所以简单高效就是因为它把生产者和消费者的依赖关系拆开了让两者在时间上重叠同时又不需要引入复杂的锁和无锁队列。这种“用最简单的结构解决最核心的冲突”的思路放到哪个领域都适用。回到麒麟芯片的语境里Ping-Pong只是众多优化技巧中的一块但它串起来的却是整个SoC数据流的骨架DMA、中断、Cache、总线、任务调度。把这些基础功夫打扎实了再去碰AI推理加速、视频编解码优化这类高端话题你才会知道瓶颈到底卡在哪一个环节。就我个人的实战体会调Ping-Pong最忌讳“凭感觉加缓冲”。正确的做法是先量化搬运耗时多少、计算耗时多少、中断延迟多少、Cache操作耗时多少列一张表用数据说话。数据不会骗人Ping-Pong自然也不会。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门