
先说我上周遇到的一个真实场景。有个团队做AI推理服务优化网卡是双万兆RTT和带宽看着都正常但整体吞吐就是上不去CPU占用还飙到接近打满。查了一圈中断次数暴高每个数据包到达都会触发一次中断CPU把大量时间花在中断处理上而不是跑推理。更麻烦的是另一台机器驱动改了轮询模式中断没了但某个核被空转轮询占死业务线程被挤到别的核延迟反而更差。这两个案例本质上是同一个问题DMA把数据从网卡搬到内存后设备怎么通知CPU“我干完了”。传输本身很快真正决定系统吞吐和延迟的往往是这个“通知”环节怎么设计。这期每日一问就把这件事彻底拆开讲清楚。从DMA和CPU的协作关系讲起把轮询、中断、MSI-X这些主流通知姿势的原理说透再往下拆到真实驱动路径——网卡收包、NVMe读盘最后聊到高性能场景下的调优策略以及最让人头疼的DMA排查问题。适合做AI Infra底层建设的朋友、写驱动和BSP的工程师、以及被嵌入式DMA问题折磨的同学参考MCU上玩串口DMA的也能在最后几节找到对应的思路。1. DMA跑完活之后为什么还得专门通知CPU很多人觉得DMA就是把数据从设备搬到内存搬完就算完事。这个理解只说对了一半。DMA的职责确实是搬运但搬运完必须有一个“交付确认”动作否则数据搬了等于没搬CPU不知道新数据已经到位自然也不会去消费它。1.1 一句话解释DMA在等什么DMA做的事情是代替CPU把一个数据块从一个地址搬到另一个地址。这个过程不需要CPU逐字节参与但DMA控制器本身并不是一个“有智能”的处理器它只会按预定配置执行传输传输结束后产生一个结果状态。这个结果状态就是关键。CPU需要知道两件事第一这次传输到底成没成功第二传输的数据在内存里的哪个位置、长度是多少。如果是读设备数据CPU要等数据完全落内存了才能安全访问如果是写设备数据CPU要等DMA确认写完才能释放这块内存缓冲否则可能造成数据还没写出去、缓冲就被复用的问题。我见过不少新手写驱动DMA配置全部正确传输也确实发生了但读取数据时总是读到旧值或缺字节。最后定位下来就是没有正确等待“完成事件”直接去读内存读的时候数据才传输到一半。在AI Infra环境下这个逻辑更明确。拿大模型训练举例检查点保存时数据从GPU显存通过PCIe DMA写进NVMe SSD如果控制器没有正确告诉CPU“写入完成”训练框架就不敢继续往下走只能干等。再比如分布式训练的参数同步数据从网卡DMA收进内存CPU得知道包完整到达了才能做聚合运算这个“到达”信号靠的就是DMA完成通知。1.2 通知的本质从“轮询”到“事件驱动”把DMA完成通知这件事抽象一下本质上是一个“事件通知”问题。设备完成传输后CPU怎么知道这件事有两条路。第一条叫轮询。CPU不依赖任何硬件通知自己去翻状态寄存器或者查看DMA描述符里的完成标志位。这台设备搬完了吗看一眼没搬完过一会儿再看一眼。它的好处是逻辑简单、延迟确定但代价是CPU要持续占用——哪怕设备根本没活干CPU也得不停转圈查状态。第二条叫中断。设备在完成DMA传输后主动给CPU发一个信号CPU被打断后去执行对应的处理函数。这就像你请跑腿小哥送外卖不用站在门口干等小哥到了自然会按门铃。门铃响了你才放下手头的事去开门。中断这个机制的聪明之处在于把“查询”变成了“通知”把“主动等待”变成了“被动触发”。在传输不频繁的场景下CPU可以在等待期间忙别的利用率大大提升在传输非常频繁的场景下中断本身的开销又会成为新瓶颈于是又衍生出一堆优化措施后面会细说。选择哪种方案取决于事件频率、延迟要求、CPU负载三者的权衡。没有绝对的对错只有适合不适合。这也是为什么现实的驱动里两种方案同时存在甚至像网卡NAPI那样混着用。2. 设备通知CPU的几种主流姿势把通知机制拆开看实际硬件方案其实就几类纯轮询、传统引脚中断、MSI/MSI-X消息中断。每一类的适用场景和性能表现都不太一样搞清楚它们的特点才能理解为什么驱动代码里会有那么多“分支”。2.1 轮询最朴实的做法轮询是通知机制里最古老、最简单也最容易理解的一种。设备不主动说话CPU自己去看。具体到DMA场景常见的做法有两种。一种是CPU直接读设备的状态寄存器看传输完成位有没有置位另一种是使用DMA描述符环描述符里有一个软件可以查询的完成标志DMA引擎写完数据后在描述符里打上“done”标记CPU循环扫描这个标记。轮询最大的优点是延迟低且确定。一次轮询的延迟就是一次寄存器读取或内存读取的时间没有中断响应、没有上下文切换延迟数字非常稳定。所以在传输非常频繁、CPU反正也闲不下来的场景轮询反而是最优解。比如说DPDK用户态驱动处理高速网络流量时默认就禁用中断、全程轮询NVMe和RDMA在高IOPS场景下也可以配置成轮询模式避免中断风暴。轮询最大的缺点是浪费。假设一块网卡每秒钟只收到10个包轮询方式下CPU必须持续占着一个核心去查状态哪怕大部分时间没有任何新包到达这个核心也干不了别的。在多核服务器上这意味着一个宝贵的物理核被白白绑死。另外轮询模式下CPU功耗也会比中断方式高不少数据中心场景这个成本不能忽略。所以我通常建议这样取舍事件频率很高、延迟要求很苛刻的场景优先考虑轮询事件频率忽高忽低、CPU资源宝贵的场景优先考虑中断或者中断配合NAPI那种混合模式。2.2 中断让设备自己喊一嗓子中断机制和轮询正好相反由设备在DMA传输完成后主动向CPU发出信号打断CPU当前工作强制CPU跳转到对应的中断处理函数。传统的中断信号走的是物理引脚。在PCIe之前的时代设备通过中断请求引脚把信号发给中断控制器中断控制器再转给CPU。到了PCIe时代经典的做法仍然保留称为INTx这是一条共享的边带信号线多个设备可以复用同一条中断线。但INTx有个很恼人的问题共享。多个设备共用一条中断线时CPU收到中断信号后无法直接判断到底是哪个设备触发的必须依次调用这条中断线上所有设备的中断处理函数逐个检查“是不是你干的”。设备多时效率极低而且还会出现一个设备频繁中断、拖累同线其他设备的情况。中断相比轮询的优势非常明显CPU不用一直盯着设备状态可以安心做自己的事情只有设备真正完成传输时才被打断处理。这个特性让中断成为绝大多数设备驱动默认的通知方式尤其是在事件频率不高的场景CPU利用率可以做到极高。但中断也有代价。每次中断都要打断CPU流水线、保存现场、执行中断处理函数、恢复现场这中间还有缓存污染和分支预测失效的成本。如果DMA完成事件非常频繁比如万兆网卡每秒几十万甚至上百万个包每个包一个中断CPU光是处理中断就会消耗大量时间系统吞吐直接崩塌这种现象就叫中断风暴。2.3 PCIe时代的新玩法MSI和MSI-X为了规避INTx共享中断的缺陷PCIe时代引入了一种新的消息中断机制。设备不再通过引脚发信号而是直接发起一个特殊的内存写事务往CPU中断控制器的一个特定地址写入数据。这个内存写操作就像按了一下门铃CPU侧收到信号后根据“门铃地址”识别出是哪个设备、哪个队列触发了中断。MSI的好处是每个设备、每个功能可以有自己的中断向量不再共享驱动程序收到中断后不需要“猜”是谁干的。MSI-X则更进一步允许一个设备拥有多个中断向量每个硬件队列都能分配独立的中断号。这个特性太重要了。现代网卡普遍支持多队列每个队列都可以绑定一个独立的MSI-X中断向量再通过设置中断亲和性把不同队列的中断分发到不同CPU核心上处理完美实现多核并行收包。NVMe SSD也一样每个I/O队列都有独立的完成队列和MSI-X向量不同队列的中断可以分散到不同核心。在AI Infra场景里MSI-X几乎是标配。一个GPU服务器里网卡多个队列、NVMe多个队列、GPU自身的DMA引擎每个都在占用MSI-X向量。排查中断问题时打开/proc/interrupts一看几十上百个向量分布在各个设备上一目了然。我自己的习惯是拿到一台高规格服务器第一件事就是看/proc/interrupts确认关键设备网卡、NVMe的中断向量是否分散到了多个CPU上。如果全部落在CPU0上性能一定有问题得赶紧调整中断亲和性。3. CPU收到通知之后处理链路是怎么走的设备发出了完成通知对CPU来说这只是开始。从硬件信号到软件逻辑真正处理完DMA完成事件中间要经过好几层。这块很多人理解得比较模糊总以为中断了就是执行一个函数那么简单其实里面的细节直接关系到最后性能。3.1 中断控制器消息怎么路由到CPU核心先说硬件层面。中断信号从设备发出后首先到达的是中断控制器而不是CPU核心本身。x86平台上传统INTx信号先到I/O APICI/O APIC统一管理后通过总线发送给某个CPU核心的Local APICMSI/MSI-X则是设备直接写一个特定地址这个地址映射到CPU的Local APIC窗口Local APIC识别后触发CPU中断。ARM平台的逻辑类似硬件中断统一由GIC管理GIC的Distributor接收各设备中断再通过CPU Interface转发给具体核心。中断控制器的一个关键功能是分发。多核系统里中断不一定非要发给CPU0可以按照预设的策略发给任何一个核心。这就有两个问题值得注意。第一个是中断负载不均。如果所有设备的中断都默认落在同一个核那个核会被打满其他核闲着整体性能大打折扣。所以Linux提供了/proc/irq/N/smp_affinity文件可以手动设置每个中断允许落在哪些核心上。第二个是NUMA亲和性。服务器普遍有多路CPU、多组内存设备DMA写入的内存如果和中断处理的CPU不在同一个NUMA节点跨节点访问内存会有额外延迟。理想情况是设备所在的PCIe槽位接在哪个NUMA节点它的中断就分配到那个节点的CPU上DMA内存也在那个节点分配这样路径最短、延迟最低。3.2 上半部和下半部为什么中断处理要拆两段中断处理函数有个铁律必须尽可能快。因为在中断上下文里系统处于一种“特殊状态”内核代码无法睡眠也不适合执行长时间操作。如果中断处理函数执行时间过长其他中断会被阻塞甚至触发soft lockup直接把系统搞崩。但DMA完成后的处理往往并不轻松。网卡一次DMA可能收到好几千个包如果中断处理函数里把这些包全部解析、送上协议栈中间涉及大量内存操作和锁竞争耗时可能达到毫秒级别这在中断上下文里完全不可接受。为了解决这个矛盾Linux把中断处理拆成了两半。上半部也就是硬中断处理函数只做最紧急、最少量的事比如清除中断状态、停止当前中断源继续发中断、把后续处理挂到“下半部”的队列里。下半部通过软中断、tasklet或工作队列机制执行在这里才做真正耗时的工作比如处理网络数据包、唤醒等待IO的进程。以网卡驱动为例大多数人熟悉的NAPI机制就是这种思想的典型代表。硬中断里做的只是登记一个poll回调然后退出真正的收包逻辑在稍后由软中断执行而且是以轮询方式批量处理多个包而不是一个包一个包地中断。3.3 实战拆解网卡DMA收包完成后的完整路径把前面这些串起来看一个真实的网卡收包流程就好懂多了。以支持MSI-X的多队列网卡为例整个过程是这样的。数据包到达网卡物理端口网卡硬件解析后通过DMA直接把包写到主机内存中某个环形缓冲区对应的位置这个缓冲区由驱动预先分配并注册到网卡。DMA写完一个包或多个包后网卡更新描述符环中描述符的完成标记然后触发自己队列对应的MSI-X中断。CPU收到中断后进入硬中断处理函数。这个函数非常短通常就是读取一下是哪个队列触发的然后调用napi_schedule把自己的NAPI实例挂到当前CPU的软中断队列上紧接着返回。这个过程中硬中断处理函数会屏蔽该队列的后续中断避免CPU被同一队列的中断持续轰炸。随后内核触发软中断执行网卡驱动的poll回调。poll回调进入循环从DMA描述符环里取出已完成的包逐个送进协议栈处理。处理完一批包之后poll回调会检查预算和剩余包数量如果还有大量包没处理完继续轮询本队列如果这批包都处理得差不多了就退出poll重新开启该队列的中断等待下一轮数据到达。这个流程最关键的点在于硬件层面用中断“按门铃”软件层面用轮询“批量消化”二者结合既避免了中断风暴又保证了高吞吐时的效率。3.4 实战拆解NVMe SSD的完成通知路径NVMe SSD的DMA完成通知路径做得比网卡还要讲究因为NVMe协议从设计之初就是为高并行、低延迟I/O而生的。先说写入场景。应用程序发起写请求后驱动在内存中构建一条命令放进名为Submission Queue的环形队列然后往SSD控制器写一个门铃寄存器告诉控制器“有新命令赶紧来取”。控制器收到门铃后通过DMA把这条命令从内存读走理解命令内容然后执行真正的数据写入期间SSD内部会自己处理FTL映射、垃圾回收这些脏活累活。数据写完后控制器会构造一条Completion Queue条目通过DMA写回主机内存表示“命令完成了”。这个CQ条目写回主机内存后控制器再触发MSI-X中断通知CPU来查看完成状态。CPU收到中断后驱动在中断处理函数里找到对应的完成队列读取CQ条目确认命令成功或失败释放相关的内存缓冲唤醒正在等待这次I/O的进程。然后驱动还要做一件很关键的事更新CQ的队头指针并写回门铃寄存器。这一步是告诉控制器“你写的完成条目我已经读走了这个槽位可以复用了”。NVMe这套机制里门铃和完成条目的配合特别重要。如果驱动忘记更新完成队列队头门铃控制器会认为CQ槽位全部被占用无法再写入新的完成条目整个队列就卡死了。我在实际排查中遇到过这种问题现象就是I/O请求发出去后石沉大海中断偶尔触发一次但驱动读不到有效的完成条目最后定位下来就是CQ队头门铃更新逻辑在某个极端情况下被跳过了。4. 高性能场景下通知机制怎么调才不吃亏了解了通知机制的原理接下来要解决一个实际问题在AI Infra这种高吞吐、低延迟场景下怎么让DMA完成通知不拖后腿。这部分的调优空间其实很大而且效果立竿见影。4.1 中断聚合控制中断频率的艺术中断虽然省CPU但频率太高就会变成负担。每个DMA完成都触发一次中断在高IOPS场景下CPU可能把大量时间花在处理中断本身上而不是真正消费数据。中断聚合Interrupt Coalescing就是为了解决这个问题。基本思路很简单设备不要每个事件都立刻发中断而是攒一批事件或者等一小段时间再统一发一次中断。这样中断次数大大减少CPU可以一次处理更多数据吞吐自然提升代价是单个事件的延迟会被拉长一点点。网卡上这个功能通常叫Interrupt Coalescing或Interrupt Moderation常见配置参数有两个一个控制等待时间比如最多等100微秒再发中断一个控制触发数量比如攒满32个包再发中断。具体到Linux上可以用ethtool命令查看和配置。我调试网络性能时经常在这两个参数之间找平衡延迟敏感的小包服务就调小等待时间纯吞吐场景就调大攒包数量效果非常明显。NVMe也有类似机制叫做Interrupt Coalescing原理差不多也是让控制器攒几个完成条目再发一次中断适合IOPS高但能接受毫秒级延迟的批量场景。低延迟单次IO场景则建议关闭让每个完成事件都立刻触发中断延迟最小。4.2 忙轮询低延迟场景的另一步棋中断聚合降低了中断开销但增加了延迟。有些场景对延迟极度敏感宁可让CPU一直盯着DMA完成状态也不愿意等那几十微秒的中断等待时间这时候就可以考虑忙轮询。Linux网络协议栈里有一个选项叫busy poll通过给socket设置SO_BUSY_POLL参数让应用程序在等待数据时不是进入睡眠等中断而是主动去轮询网卡的接收队列。这种模式下数据到达后不需要等中断调度直接就被应用程序取走UDP等小包场景的延迟能降不少。代价是这个CPU核心会被完全占用不再响应其他任务所以适合流量相对集中的场景。NVMe驱动也支持类似的轮询模式通过io_uring的IORING_SETUP_IOPOLL标志开启。开启后提交和完成I/O都通过主动轮询完成队列来检查结果不走中断路径延迟更低、IOPS更高代价同样是CPU占用率上升。我在AI Infra这边的经验是控制面流量用中断数据面高频流量用轮询两者结合能在CPU利用率和延迟之间取得不错的平衡。不过轮询模式配置不当很容易把CPU烧满必须评估业务能不能接受这种资源占用最好先用压测工具确认预期收益再上生产。4.3 多队列和中断亲和性把通知分散到多核单核处理中断的瓶颈最直接的解法就是让多个核一起来处理。这需要设备支持多队列并且中断能够分散到不同CPU核心。现代网卡普遍支持RSS即接收侧扩展。网卡硬件会根据数据包的五元组信息做哈希把同一个流的包固定分发到同一个硬件队列不同流可以分配到不同队列。每个队列配一个MSI-X中断向量通过设置中断亲和性就可以让不同队列的中断落在不同CPU核心上实现多核并行收包。NVMe SSD也一样通常每个CPU核心都可以配置一个独立的I/O队列各队列之间互不干扰。训练框架发起的每个读I/O都绑定到某个核心的队列上完成中断也只会打到那个核心处理完成条目的CPU和数据消费的CPU是同一个缓存命中率更高。实际操作中我一般会先看/proc/interrupts的分布情况如果发现大量中断集中在少数几个核上就手动修改对应中断号的smp_affinity把它们分散开来。注意要结合NUMA拓扑让中断落在设备所在NUMA节点的核心上避免跨节点访问。另外还要留意irqbalance服务它在某些情况下会自动调整中断分布但它的策略不一定适合AI服务器的性能需求必要时建议关闭改成手动控制。5. 实战排查DMA完成通知链路出问题怎么办理论说了这么多真正在做项目时大家遇到更多的是DMA完成通知链路出了问题数据卡住不动、中断狂丢、数据错乱。这一节我把自己踩过的坑和排查经验整理出来都是实打实的操作。5.1 rk3588 eth failed to reset the dma 类报错排查很多做嵌入式或者ARM SoC开发的朋友应该对这类报错不陌生网卡或其他DMA外设初始化时报“failed to reset the dma”直接导致驱动加载失败。这个报错虽然发生在SoC平台上但排查思路对所有DMA初始化失败都有参考价值。常见原因有几个。第一个是时钟或电源域没使能。很多SoC的DMA控制器和以太网MAC挂在同一个电源域或时钟树下设备树里如果漏配了相关时钟或电源节点DMA控制器根本无法运行复位也就无从谈起。第二个是设备树里的复位配置错误。DMA控制器的复位可能不止一路有的需要先复位DMA本身有的需要先复位外设MAC再复位DMA顺序不对就会导致失败。设备树里的reset-names、resets属性必须和驱动里的调用顺序保持一致。第三个是硬件时序问题。有些DMA控制器对复位信号有严格要求比如复位信号要保持一定宽度的低电平或者复位完成后需要等待固定时间才能访问寄存器。驱动如果复位完立刻读状态寄存器可能读到的是复位还没完成的中间状态误判为失败。排查这类问题的步骤我一般是这样先看dmesg里报错前后的完整日志确认是probe阶段还是open阶段再检查设备树里DMA节点、时钟、电源、复位相关配置与SDK参考设计是否一致然后加打印或者用JTAG读DMA控制器的复位状态寄存器看硬件是否真的完成了复位最后比对SDK里对应的参考驱动确认初始化序列没有漏步骤。绝大多数情况下这个问题都不是DMA硬件坏了而是配置时序或者设备树配置不对。5.2 DMA中断丢失、数据错乱排查思路DMA数据传输本身的排查如果只挑最值得说的那一定是中断丢失和数据错乱这两大类。先看现象再逆推原因效率会高很多。对照表格梳理会更清楚。先说中断丢失。典型现象是DMA传输明明完成了CPU却一直没收到通知业务等待超时。排查优先看三个地方第一中断在硬中断处理函数返回前有没有被重新屏蔽很多驱动在处理完中断后需要重新使能该中断如果漏了就会导致后面所有中断都不触发第二共享中断线的ISR里对设备ID的判断是否准确如果判断错误直接返回IRQ_NONE内核会认为这个中断不属于该设备后续处理被跳过第三中断亲和性是否被设置到了错误的CPU核心上如果中断被分发到了隔离或者offline的核业务进程根本感知不到。再说数据错乱。现象是DMA确实搬了数但读到的内容不对、位置偏移或者字节错乱。第一个要排查缓存一致性问题CPU在访问DMA写入的内存前必须确保缓存和内存一致使用dma_alloc_coherent这种一致性映射或者dma_cache_sync主动刷新缓存都可以解决漏掉这一步DMA写入的内容很可能被CPU读到旧缓存覆盖。第二个要确认DMA传输宽度和源/目的寄存器宽度是否匹配比如12位的ADC数据、16位的DMA宽度、8位的存储三不对齐就容易错位常见于gd32这类MCU的ADC多通道DMA采集场景。第三个是描述符环的溢出问题驱动消费描述符的速度跟不上DMA填充速度新数据覆盖旧数据导致错乱需要检查buffer大小和消费节奏。我在MCU和服务器驱动调试中都遇到过类似问题缓存一致性那个坑尤其隐蔽因为它在x86和ARM上表现还不太一样x86有较强的缓存一致性协议问题不容易暴露ARM上则非常明显。所以写驱动时一定要主动处理DMA内存的映射和缓存同步不能指望硬件“应该会处理好”。5.3 从MCU到服务器DMA加空闲中断的经典玩法最后聊一个很有意思的交叉验证MCU上经典的“DMA加空闲中断”方案和服务器上的中断聚合、NAPI思路本质上是同一个道理。MCU串口接收场景里最常见的问题是每个字节都进一次中断CPU负载高、长时间阻塞在高优先级中断里导致其他任务抖动。DMA加空闲中断的思路是配置串口的DMA通道自动把接收到的数据持续搬到内存缓冲区然后开启串口的空闲中断。当一帧数据发送结束后接收线上出现空闲状态硬件触发空闲中断软件在中断里根据DMA搬运到哪个位置了判断这一帧数据有多长、是否完整。这个方案里DMA负责“搬运”空闲中断负责“通知”搬运和通知解耦CPU只在数据帧到达完毕时才被打断效率比逐字节中断高得多。说起来这个“攒一批再通知”的思想跟网卡的中断聚合是一样的传输密集时保证CPU不被频繁打断传输完成时又能及时得到通知。我在很多AI Infra工程里也见到过同样思路的移植比如模型推理服务的数据预取模块用类似“批量收集DMA完成事件再统一处理”的方式减少CPU上下文切换提升吞吐。从MCU到服务器底层原理其实是相通的理解了一个场景另一个场景也就触类旁通了。关于DMA完成通知的调优我个人实际操作中的体会是不要太迷信哪种方案绝对好。中断省CPU但可能带来延迟抖动轮询延迟低但吃核心聚合省中断但牺牲时限。最好的做法是先把业务的数据特征摸清楚是高频率小消息还是低频率大块数据再针对性地调整中断参数或轮询策略。还有一个小技巧配置完中断亲和性或聚合参数后一定要用压测工具跑一遍全链路同时看延迟、吞吐和CPU占用三个指标只盯着一个容易把自己带偏。DMA通知机制看似是一个很底层的细节但它在AI Infra里决定的是整个系统的数据面水位值得多花时间打磨。