Ping-Pong双缓冲:NPU算子优化中让数据搬运与计算重叠的硬核技巧

发布时间:2026/9/5 19:35:59
Ping-Pong双缓冲:NPU算子优化中让数据搬运与计算重叠的硬核技巧 做麒麟芯片架构上的算子优化绕不开一个叫 Ping-Pong 的性能技巧。我第一次认真研究它是因为一个矩阵类算子实测跑得很不理想AI Core 的利用率不到三成可每一条指令看起来都正常。后来把数据搬移的时间拉出来看才发现问题根本不在计算而在搬运。数据没有及时送到计算单元手里计算单元只能等着。Ping-Pong 优化就是专门解决这种“搬运慢、计算闲”局面的思路它可以让你在同一个时间段内让数据搬运和计算真正重叠起来而不是傻等一个完成再开始另一个。这篇文章我尽量用讲人话的方式把这个技巧从原理讲到落地包括为什么要用、怎么切 buffer、同步怎么做、有哪些坑。适合做算子开发、嵌入式异构计算、驱动或者底层性能优化的人看也适合刚接触 NPU 架构但想搞懂数据流的人。1. 先从搬运说起麒麟芯片架构里的时间到底耗在哪1.1 数据搬运单元和计算单元的关系在麒麟这种移动 SoC 里NPU 并不是一个孤立的“大黑盒”。它的内部通常有专门的 AI 计算核比如负责矩阵运算的 Cube 单元、负责向量运算的 Vector 单元也有负责算地址、做控制流的标量单元。可是计算单元不能直接从主存 DDR 里随机取数它们的数据必须先进到片上的一小块高速缓存区也就是芯片手册里常说的 Local Buffer 或者 L0 buffer计算单元只从这片区域里取操作数。这就带来一个很现实的问题数据从 DDR 到片上 buffer 这一段路必须有人搬。谁搬DMA 控制器。你写算子的时候如果直接在主存上做计算那是做不到的至少主流架构不会让你这么干。计算单元视野范围内的数据只有片上那一小块 buffer。于是整个算子的执行过程基本是DMA 把输入数据搬到片上 buffer计算单元启动起来做运算算完的结果再被 DMA 搬回 DDR。这种组织方式并不是麒麟芯片独有的。很多 GPU、DSP、NPU 都是类似的思路。因为“算”和“搬”本来就是两种不同性质的活计算单元擅长做乘加DMA 擅长做连续内存拷贝。看懂这个关系后你才会意识到搬运开销在整个算子里可能占掉很大一部分时间尤其是当单块数据比较大、或者算法对数据做多次遍历时搬运时间甚至会超过计算时间。我接触过不少刚上手 NPU 算子开发的人他们最习惯看的就是“计算指令执行了多久”却忽略了数据在进 buffer 之前其实已经等了不少时间。真正要优化的对象往往不是计算单元而是数据流。1.2 不算不知道DMA 串行执行时的时间账我们用一个场景感受一下。假设你要处理的数据可以切成 M 块比如一个大张量的多个分片。按最朴素、没有任何优化的写法每一块的处理流程如下启动 DMA把第 k 块数据从 DDR 搬进 buffer。等 DMA 完成。启动计算单元处理这块数据。等计算完成。启动 DMA把结果从 buffer 搬回 DDR。继续下一块。如果一次搬运耗时为 Td一次计算耗时为 Tc那么处理一块需要的时间至少是 Td Tc两块就是 2 × (Td Tc)。这里最关键的是DMA 在搬运第 k 块时计算单元完全是空闲的计算单元在处理第 k 块时DMA 也完全是空闲的。两个昂贵的硬件资源没有重叠使用这是巨大的浪费。举个具体数字帮助理解。假设实测一块数据的搬运时间约 12 微秒计算时间约 8 微秒。串行情况下处理一块要 20 微秒20 块就要 400 微秒。如果让 DMA 和计算并行起来那么每一块的平均耗时就能压缩到大约 Td 和 Tc 中的较大值附近也就是约 12 微秒。20 块就只要 240 微秒左右。省下来的这 160 微秒就是你优化空间。当然现实中不可能做到每一块都完美重叠因为流水线的首块和尾块总有一些无法掩盖的时间。但当块数足够多时这种首尾开销会被摊薄。Ping-Pong 优化的目标就是把中间的每一块都尽量叠满。2. Ping-Pong 优化的运作方式双缓冲如何让 DMA 和计算重叠2.1 从单缓冲到乒乓缓冲的演进Ping-Pong 优化的核心其实不是多么高深的数学而是一句话准备两个 buffer而不是一个。两个 buffer 轮流使用当一个 buffer 里的数据正在被计算时另一个 buffer 正在被 DMA 填充新的数据。等计算完成当前 buffer 可以用来接收下一批数据等 DMA 完成另一个 buffer 里已有新鲜数据可以开算。两个 buffer 像乒乓球一样来回切换这就是“Ping-Pong”名字的由来。听起来很直白但实际落地时容易出各种问题因为你要面对的不只是“两个 buffer”而是“两个异步硬件引擎之间的同步”。DMA 完成的时间你没法预测计算完成的时间你也没法预测两个引擎各自忙各自的必须用事件或者标志位来做握手。如果只用一个 buffer流程一定会退化成串行。为什么因为下一块数据如果要开始搬运就必须先确保当前 buffer 里的旧数据已经被计算单元取走而下一块数据没搬进来之前计算单元又只能空等“新数据”。归根到底是资源不够导致的等待。所以单缓冲结构再怎么调优也无法从根上让搬运与计算并行。这一点我希望你牢牢记在心里。2.2 一个完整轮次的乒乓时间线为了让文字描述足够清楚我们假设有两个 buffer叫 buf0 和 buf1数据块编号从 0 开始。下面是理想乒乓流程阶段一DMA 把第 0 块数据从 DDR 搬入 buf0。这个阶段计算单元闲着因为还没有任何数据可算。 阶段二DMA 完成第 0 块搬运开始把第 1 块数据搬入 buf1。与此同时计算单元开始处理 buf0 里的第 0 块。 阶段三DMA 完成第 1 块搬运开始把第 2 块数据搬入 buf0。注意此时 buf0 中的第 0 块必须已经被计算完了DMA 才能安全覆盖它。同时计算单元开始处理 buf1 里的第 1 块。 阶段四DMA 完成第 2 块搬运开始搬第 3 块到 buf1计算单元处理 buf0 里的第 2 块。这个模式持续下去你会发现一个规律DMA 和计算单元确实同时在干活但 DMA 永远比计算快一步。DMA 搬的是第 k1 块计算算的是第 k 块。这一步之差就是并行度所在。最后收尾时也要注意当所有数据都搬完时DMA 已经没有下一块可搬了但计算单元还在处理最后一块。这段尾块时间没有办法被隐藏。反过来如果某一次计算比 DMA 快得多那么计算单元会在每个阶段等待下一块数据搬运完成此时优化的主要方向又变成了减少搬运时间比如让数据更紧凑、或者用更大的块减少启动次数。2.3 Buffer 划分与同步触发条件正常设计中两个 buffer 的大小一般相同这样才能保证来回切换时不会出现某个 buffer 装不下一块数据的情况。如果两个 buffer 大小不一致那么较窄的那个 buffer 会成为瓶颈整体的并行结构会被破坏。同步触发条件需要分别考虑 DMA 侧和计算侧DMA 侧在写入某个 buffer 之前必须确认这个 buffer 里上一次的数据已经被计算完了。否则你把新数据写进去旧数据还没被读走那就出大问题。这个条件叫“buffer 空闲”。计算侧在启动计算之前必须确认当前要用的那个 buffer 里的数据已经被 DMA 完整搬入。不能 DMA 才搬了一半计算单元就开始取数。这个条件叫“数据就绪”。你可能会想只要两个条件满足就可以自由调度。但工程实现里经常出现一种“竞态”DMA 写 buffer 的同时计算单元读同一个 buffer两边都认为自己拿到了正确的信号。怎么避免核心在于每个 buffer 的“空闲”和“就绪”状态位必须严格更新而更新动作要放在对应引擎的完成事件里不是在主循环里随便置位。我的经验是把 DMA 完成中断里做的事、计算完成中断里做的事分开写清楚。DMA 完成中断只负责将对应 buffer 的“就绪”位置 1计算完成中断只负责将对应 buffer 的“空闲”位置 1。主调度循环只检查这些状态位不直接和硬件寄存器交互。这样即使逻辑复杂思路也会比较清晰。3. 自己动手实现 Ping-Pong 调度状态机与实操步骤3.1 让每个 Buffer 带状态而不是靠运气同步真正写代码的时候我不建议你在主循环里用“先等 DMA 完成再等计算完成”这种方式因为它很难覆盖所有边界情况。更稳妥的做法是把每个 buffer 抽象成带状态的对象。每个 buffer 有下面几种状态IDLEbuffer 空闲DMA 可以写入。LOADINGDMA 正在往这个 buffer 里写数据。READY数据已经完整搬入等待计算。RUNNING计算单元正在处理这个 buffer 中的数据。当 DMA 开始搬一块新数据时buffer 从 IDLE 变成 LOADING。DMA 完成中断触发后LOADING 变成 READY。计算单元启动时READY 变成 RUNNING。计算完成中断触发后RUNNING 变成 IDLE。整个系统永远在这几个状态之间轮转。这个状态机的好处是它把复杂的时间重叠变成了简单的状态判断。比如你在调度循环里想做一次新的 DMA只需要找到一个处于 IDLE 状态的 buffer而不需要去回忆它上一次是被谁用过的、用完了没有。做一次新的计算只需要找一个 READY 的 buffer。3.2 用伪代码描述双 Buffer 调度主循环下面我给一份示意级别的伪代码帮助你理解调度结构。实际项目里需要根据麒麟芯片的 SDK 接口替换启动函数和中断函数但整体骨架是通用的。#define BUFFER_NUM 2 struct buffer_ctrl { void *dma_addr; // DMA 使用的地址 volatile bool is_idle; // true: DMA 可以写入 volatile bool is_ready; // true: 数据已完整搬入可以计算 }; struct buffer_ctrl buf[BUFFER_NUM]; void dma_done_isr(int buf_id) { // DMA 搬运完成这个 buffer 的数据可以开始计算了 buf[buf_id].is_idle false; buf[buf_id].is_ready true; } void calc_done_isr(int buf_id) { // 计算完成这个 buffer 可以重新被 DMA 覆盖了 buf[buf_id].is_ready false; buf[buf_id].is_idle true; } void pingpong_run(chunk_t *chunks, int total_chunks) { int dma_seq 0; // 下一个要启动 DMA 的块号 int calc_seq 0; // 下一个要启动计算的块号 bool calc_started false; // 初始化让两个 buffer 都处于空闲 for (int i 0; i BUFFER_NUM; i) { buf[i].is_idle true; buf[i].is_ready false; } // 第一步先把第 0 块数据搬进 buf[0] dma_start(buf[0].dma_addr, chunks[0], 0); dma_seq 1; while (calc_seq total_chunks || dma_seq total_chunks) { int b_id; // 尝试启动计算找一个 ready 的 buffer if (!calc_started) { b_id calc_seq % BUFFER_NUM; if (buf[b_id].is_ready) { compute_start(buf[b_id].dma_addr, b_id); buf[b_id].is_ready false; calc_started true; } } // 尝试启动下一段 DMA找一个 idle 的 buffer if (dma_seq total_chunks) { b_id dma_seq % BUFFER_NUM; if (buf[b_id].is_idle) { dma_start(buf[b_id].dma_addr, chunks[dma_seq], b_id); buf[b_id].is_idle false; dma_seq; } } // 等待中断或事件避免忙轮询 wait_for_event(); // 如果计算完成推进 calc_seq 并允许调度下一块 if (calc_done_flag) { calc_done_flag false; calc_seq; calc_started false; } } }这份伪代码里我把两个引擎的推进拆成了独立逻辑这样你可以明显看到 DMA 和计算各自在找自己的 buffer互不阻塞。实际使用时dma_start和compute_start通常是异步接口调用后立即返回。中断回调里除了更新状态位还要清理对应的“完成标志”。不同 SDK 风格差异很大但状态机的含义是通用的。注意几个细节volatile只能保证编译器不优化访问真正的外设和 CPU 之间的同步需要芯片提供的内存屏障指令。如果 SDK 提供了dma_wait或synchronize接口优先用接口不要自己用普通变量。缓冲区状态位最好定义成 unsigned int 或者 bool 数组不要和功能代码混在一起方便调试。中断里不要做复杂逻辑置位后马上返回。所有调度判断都放到主循环里。3.3 分块大小怎么选先让搬运时间和计算时间尽量接近Buffer 大小和分块大小是直接相关的。你既然要用两个 buffer那么片上缓冲区总大小通常要分成两部分。除非你有特殊设计否则两个 buffer 各占一半。分块大小的选择有个朴素目标让 DMA 搬运一块的时间和计算单元计算一块的时间尽量接近。如果 DMA 时间远大于计算时间那计算单元会有大段空闲如果计算时间远大于 DMA 时间那 DMA 的空闲也会很多。只有两者接近乒乓重叠的效率才最高。你可以通过测试来确定合适的值。做法是固定数据集把一块的大小从 64KB、128KB、256KB、512KB 逐渐往上调分别记录每块的平均 DMA 时间和计算时间。选一个两者比较接近、且单块启动开销占比不大的尺寸。通常块太小会让 DMA 的启动和中断固定开销占比变高块太大又会逼近片上 buffer 上限甚至无法把两块同时放进去。经验上块大小落在“DMA 线性搬运区的边缘”通常比较理想。也就是数据量继续增大DMA 效率不会明显提升的那个拐点附近。如果一块数据连续存放地址对齐到芯片 AXI 总线突发长度DMA 通常跑得很满。如果不连续例如做了大张量切块后的非连续行DMA 可能每次只搬一小段效率会大减这时尽量把数据重排成连续内存再搬运。3.4 别忘了预热和尾块时间乒乓调度和 CPU 流水线一样存在“填充”和“排空”阶段。第一块数据搬运时计算单元一定在等待这段时间是流水线填充最后一块计算完成时DMA 已经在等着做输出写回这段时间是流水线排空。这两个阶段的耗时无法被优化掉所以不要期望整个算子耗时变成严格的“总搬运时间”或“总计算时间”。实际算下来整段算子的理想耗时大约是第一块搬运时间 所有中间块的处理重叠时间 最后一块计算时间如果分块数量 N 足够大那么首尾两个单块时间会被摊得很薄。如果总共只有两三块Ping-Pong 的收益就会非常有限。我看到有的同学在一块大数据上硬切很小的分片结果同步开销和中断开销比节省的搬运时间还多得不偿失。所以做 Ping-Pong 前先估算一下你的数据能不能切成至少 4 块以上如果不能优化效果大概率不明显。如果数据集本身很小直接一次性全部搬进 buffer 再计算反而比乒乓更简单高效。4. 常见问题与排查技巧实录4.1 故障现象速查表我在实际项目里遇到的问题汇总成一张速查表遇到类似情况可以直接对照。现象可能原因处理方向性能完全没提升同步事件里做了忙等待两个引擎并没有真正并行检查代码里是不是在 DMA 启动后立刻阻塞等完成计算结果偶尔错误buffer 覆盖发生在计算完成之前检查 DMA 写 buffer 前是否确认了“空闲”状态计算结果完全不对DMA 只搬了一半就开始计算检查启动计算前是否等待了 DMA 完成事件中断频繁CPU 占用高分块太小每次搬运都产生一次中断增大分块或者使用 DMA 描述符链减少中断数据看起来是旧的没有做 cache 一致性维护DMA 写 buffer 前需要 invalidate 对应区域程序卡死或死锁状态位被错误顺序置位两个引擎互相等待把事件日志打出来看卡在哪个 buffer 上使用 DDR 直读性能反而更高数据量太小乒乓搬运开销大于计算本身重新评估是否需要乒乓或换单次全量加载4.2 我踩过最深的几个坑第一个坑是地址对齐。DMA 不是随便给个地址就能高效搬运。它通常要求 buffer 地址按照 AXI 总线突发长度对齐可能是 32 字节、64 字节甚至 128 字节。如果 malloc 出来的地址不对齐DMA 轻则性能暴跌重则直接报错。分配片上 buffer 时必须使用专门的 aligned allocator不能随手拿一块普通内存就开 DMA。第二个坑是 cache 一致性问题。DMA 是外设它写入的内存不会自动更新 CPU 的 cache。如果 DMA 搬完数据后计算单元或 CPU 去访问这片 buffer很可能读到 cache 里残留的旧数据。解决办法有两种一种是 DMA 缓冲区使用 non-cacheable 或 write-through 属性但性能会有损失另一种是在每次 DMA 操作前手动 invalidate 相关 cache 行在计算完成后 clean 相关 cache 行。具体选择取决于芯片手册和 SDK 实现。第三个坑是中断和主循环抢状态。如果你把一个 buffer 的状态位既在主循环里写又在中断回调里写又没有做原子保护就会出现状态错乱。最典型的情况是DMA 完成中断把is_ready置 1主循环还没来得及读取另一个事件进来又把状态改掉。我的处理方式是让主循环只做决策中断只做状态切换所有的状态切换都放在同一临界区内保护。第四个坑是最后一轮的遗漏。很多手工实现会在主循环里正确调度了中间块却忘记补最后一块计算导致结果缺失。用状态机实现时尤其要注意calc_seq是否推进到了total_chunks。我建议在代码里增加一个断言循环结束时所有 buffer 都应该重新回到 IDLE 状态否则说明有问题。4.3 排查思路把并发问题拉平到时间轴上排查这类并发问题最难的是“看不到先后顺序”。两个异步引擎谁先谁后单靠脑补很容易出错。我自己最常用的办法是做一个小的软件日志系统把每一次 DMA 启动、DMA 完成、计算启动、计算完成这些事件按顺序记到一块环形的内存日志里每条日志带时间戳、buffer 编号和块号。当结果不对时把日志 dump 出来在时间轴上还原整个过程。你会很直观地看到某一次 DMA 是不是在旧计算完成前就启动了或者某一次计算是不是在 DMA 完成前就开始了。大部分并发错误只要这么拉一次时间轴原因就浮出水面了。排查过程中还有个小技巧先跑只有两块数据的测试用例。因为乒乓结构的最小完整循环就是两块如果两块数据都处理不对那再多块也没用。两步能验证初始流程和切换逻辑。然后再跑三块验证 ping 到 pong 的第二轮切换。逐级增加块数能快速定位问题藏在哪一步。5. Ping-Pong 不是终点什么场景不适合以及进阶方向5.1 不该硬上 Ping-Pong 的几类场景不是所有计算都适合 Ping-Pong。如果算法本身是“小数据量 高计算密度”比如一个小矩阵相乘数据可能还不到 buffer 的一半一把全搬进去算完就行用乒乓反而增加同步和切换开销。再比如算子存在严格数据依赖下一块数据必须等上一块算完才能得到那你就没法提前把下一块搬进来这时原生的双缓冲帮不上忙。还有一种情况是 DMA 带宽已经饱和。当搬运本身已经把内存总线占满了就算你再怎么用乒乓切换整体时间也不会缩短。此时真正的瓶颈在系统带宽不在流水线结构。你可以通过查看内存控制器的占用率来确认这一点。此外如果你的硬件只有一个 DMA 通道并且不能和计算单元并行工作Ping-Pong 也只是空中楼阁。所以在动手做优化之前先确认硬件是否真的支持独立的搬运引擎和计算引擎。5.2 从双缓冲到三缓冲和多级流水既然双缓冲能让 DMA 和计算并行那三缓冲能不能带来更多收益答案是在一些场景下可以。三缓冲本质上是在搬运速度波动较大、DMA 启动时间不稳定的情况下增加一个“蓄水池”来吸收抖动。如果 DMA 偶尔因为总线仲裁变慢而这时第三个 buffer 里已经备好了数据计算单元就不会被迫空等。代价是占用更多片上内存且调度逻辑会复杂一些。更进一步的做法是把流水线拆成更多阶段。比如有的算子流程是DMA 从 DDR 搬输入、计算单元做第一阶段、中间结果做一次转置或量化、再进入第二个计算阶段、最后写回。这种流程可以考虑多级乒乓每个阶段都有自己的双缓冲形成一种软件流水。这样做起来复杂度高但收益也大。如果你用的是厂家提供的张量编译器或图优化工具那么部分 Ping-Pong 转换可能已经被工具在底层自动完成。但即使工具帮了你理解原理也很有价值。因为当性能报告显示“流水线有 bubble”时你需要能看懂是哪一级没对齐是搬运慢还是计算慢是 buffer 不够还是同步事件开销太大。这些判断离不开对 Ping-Pong 机制本身的理解。最后再分享一个我对 Ping-Pong 优化的体会把双缓冲做对靠的不是记住某个固定写法而是把两个异步引擎当成生产者和消费者来看待。DMA 是生产者计算单元是消费者。消费者吃得太快生产者就要抓紧补货生产者供得太快消费者就要跟上。任何一端掉链子另一端的等待是藏不住的。学会从数据流的角度审视整个算子你自然就知道瓶颈在哪里也自然能判断要不要用 Ping-Pong。