
1. 先说结论DMA干完活靠“敲门”通知CPUDMADirect Memory Access直接内存访问这个技术在 AI Infra 里几乎是躲不开的。GPU要从主机内存拉权重、NVMe SSD要把模型数据读进内存、网卡要把远端数据搬进本地缓冲区——这些高速数据搬运如果每一步都让CPU亲自来做CPU早就被IO操作拖垮了。所以DMA控制器的角色就是替CPU打工的“搬运工”你告诉它数据从哪里来、到哪里去、搬多少字节它就自己吭哧吭哧搬完搬的时候CPU该干嘛干嘛。但这里有个特别实际的问题搬运工干完了活怎么告诉老板“我搞定了”如果靠CPU用轮询的方式不停地去问“搬完了没”那DMA解放CPU的意义就大打折扣——CPU还是被绑在等待上了。真正的答案是DMA控制器在传输完成后会通过中断Interrupt通知CPU。这就像你点了个外卖外卖员到了不会站在楼下干等你主动探头去问而是直接按门铃门铃响了你就知道“饭到了”。中断就是硬件给CPU按的那个门铃。这篇东西我打算从硬件链路一路讲到驱动代码把“DMA完成后如何通知CPU”这个机制的里里外外掰开揉碎再结合AI Infra场景说说为什么这个机制选择直接影响系统吞吐。适合正在搞AI基础设施、做驱动开发、或者对高性能计算IO路径感兴趣的同学即使你暂时不写驱动理解了这套机制对排查“CPU占用高但性能上不去”这类问题也特别有帮助。提示轮询不是完全被淘汰了。在极低延迟、单设备、数据量极小的场景下轮询甚至比中断更高效。但绝大多数AI Infra的批量数据搬运场景中断机制是绝对的王者。下面我细讲。2. 从DMA控制器到CPU的完整通知链路2.1 DMA控制器的三个关键角色搬运工、状态旗、门铃按钮DMA控制器本身就是一个小的状态机。我们以最常见的存储器到存储器搬运为例看看一次完整的过程CPU配置DMA控制器的源地址寄存器、目的地址寄存器、传输长度寄存器然后往控制寄存器里写一个“启动”位。DMA控制器开始逐字节或者逐块地从源地址读数据、写到目的地址。这个过程里CPU完全不用参与。搬运完成后DMA控制器内部的状态寄存器中会有一个“传输完成Transfer Complete”标志位被硬件自动置1。同时DMA控制器的中断状态寄存器里对应的中断挂起位也会被置位。如果之前配置了“中断使能”那么DMA控制器就会在它的中断请求引脚上拉出一个有效的电平或者边沿信号——这就是触发中断的“电信号敲门”。你可以把DMA控制器的中断请求引脚理解成门铃按钮。DMA搬完了按钮被按下然后门铃线路上就会有一个电信号往CPU方向传。关键点是这个电信号不是直接进CPU的它要先经过一个中断控制器。2.2 中断控制器扮演“总机接线员”的GIC/APIC在x86平台这个总机叫做APICAdvanced Programmable Interrupt Controller在ARM平台它叫GICGeneric Interrupt Controller。名称不同职责高度一致收集各个外设发来的中断请求。根据优先级仲裁决定先给CPU报哪个。记录中断号方便CPU在响应时快速识别是哪个设备在敲门。在ARM GIC的语境下还要把中断分发给具体哪个CPU核。这里有一个容易出现理解偏差的地方CPU并不是“中断一来就放下手里所有事直接跑过去”。实际情况是中断控制器把信号送到CPU的某个核上CPU执行完当前正在执行的指令之后先去保存现场把当前任务的寄存器状态压栈然后根据中断向量表跳转到对应的中断处理程序ISR去执行。ISR读取DMA控制器的中断状态寄存器发现是“传输完成”就做相应的善后工作——标记传输完成、唤醒等待的进程、清中断标志位。这个“总机接线员”角色的存在解决了一个很实际的问题当一个系统里有几十个设备都可能产生中断时CPU不可能给每个设备都留一个直接的引脚那样CPU的引脚根本不够用。中断控制器帮CPU做了一个“先统一收线、再转接”的抽象让CPU只需要面对一个中断入口。2.3 中断亲和性多核CPU时代谁去开门在AI Infra的服务器里一颗CPU有几十个核GIC/APIC把中断分发给哪个核直接影响性能走向。默认情况下中断可能会被分发给任意一个核但这样会导致缓存局部性变差——上次处理这个设备中断的核是CPU0这次变成了CPU3那CPU0缓存里关于这个设备的状态全部失效了。所以实际工程里我们通常会给特定的DMA相关中断绑定固定的CPU核也就是“中断亲和性IRQ Affinity”。比如在Linux下可以通过修改/proc/irq/irq_number/smp_affinity文件用十六进制掩码指定这个中断只发给CPU2和CPU3。对AI训练服务器来说如果GPU数据搬运的中断始终落在同一个核上那么该核在处理完中断后相关的页表、描述符缓存都会保持在热状态下一次中断处理就非常快。3. AI Infra场景下这个“通知”为什么格外关键3.1 GPU训练场景的DMA中断一次训练迭代搬多少数据先说个数据量级的概念。以训练一个大型语言模型为例假设模型权重有70GB每次迭代都要从主机内存搬运一批数据到GPU显存哪怕一次只搬几个GB也是在百万毫秒级别内必须完成的。这个搬运过程的终点是DMA完成中断告诉CPU“数据已经进显存了”CPU才会去通知GPU“你可以开始算了”。如果这个中断通知链路慢了GPU就会在那里空转等数据——GPU的计算单元时间非常宝贵空转一毫秒都是浪费。另外还有一个常被忽视的点训练过程中的梯度同步。多卡训练时每张卡算完梯度后要通过NVLink或者PCIe做梯度聚合这个过程中DMA完成的确认通知同样起着“发令枪”的作用。后续算子在等待梯度就绪时靠的就是DMA完成中断去唤醒。可以说DMA中断延迟直接叠加到每次迭代的耗时上积累下来就是几个百分点的训练吞吐差距。3.2 NVMe SSD与网络卡高IOPS下的中断风暴风险AI Infra不只有GPU还有存储和网络。NVMe SSD的IOPS动辄百万级别网卡在分布式训练里也是每秒钟几十万个小包进进出出。每个IO完成都要来一次DMA中断如果处理不当CPU会被“门铃”按到崩溃——这就是工程师们常说的“中断风暴interrupt storm”。为了解决这个问题硬件和软件都在做优化这里简单列几个关键思路中断聚合Interrupt CoalescingDMA控制器或者设备允许攒一批传输完成后再统一报一次中断。延迟稍微增加一点但CPU中断处理次数大幅下降。多队列Multi-Queue网卡和NVMe控制器支持多个DMA队列每个队列可以路由到不同的CPU核上处理把“按门铃”的压力分散到多颗核头上。MSI/MSI-X中断相比传统的中断线MSI-X允许设备直接往特定CPU核写一个内存消息来触发中断定向能力更强避免了传统共享中断线的排队问题。这些都建立在“DMA完成会通知CPU”这个基本机制之上。理解了这个根本机制再去看厂商的各种调优参数就不会觉得玄学了。3.3 异构计算下的等待机制事件同步与信号量在CUDA编程里cudaMemcpyAsync是异步的——它发起DMA传输后立即返回CPU可以继续做别的事。但当你调用cudaStreamSynchronize时CPU就会进入等待状态直到DMA传输完成事件到来。这个事件的背后本质上就是DMA完成中断触发了驱动里的回调逻辑进而唤醒等待队列里的CPU线程。这里我想强调一个经验之谈对AI框架做性能分析时如果发现CPU的等待占比很高不要只盯着算子本身用perf或者py-spy看看线程是不是阻塞在wait_event这类调用上。如果阻塞时间很长大概率是DMA完成中断没有及时送达而不是计算太慢。4. 落到代码驱动里怎么实现“等待干完”的逻辑4.1 从轮询到中断写代码的视角转换我们先从代码的视角感受一下两种方式的区别。早期或者极简场景下的DMA轮询写法大概是这样// 设置DMA寄存器... dma_start(); // 轮询状态寄存器直到完成位被置位 while (!(readl(dma_base DMA_STATUS) DMA_STATUS_COMPLETE)) { cpu_relax(); } // 搬运完成开始处理数据 process_data();这种方法简单粗暴但CPU在等待期间什么事都干不了。虽然cpu_relax()会让出流水线但线程还是占着这个核整体的CPU利用率看起来不高但系统吞吐也被拖住了。换成中断驱动模式之后逻辑被拆成了两半发起者不傻等而是“我先睡你干完了叫我”中断处理函数干“叫醒”的活// 发起DMA传输 static irqreturn_t dma_done_handler(int irq, void *dev_id) { struct my_dma_device *dev dev_id; // 读状态寄存器确认是这个传输完成的中断 u32 status readl(dev-base DMA_STATUS); if (status DMA_STATUS_COMPLETE) { // 清中断标志位避免中断反复触发 writel(DMA_STATUS_COMPLETE, dev-base DMA_STATUS_CLEAR); // 唤醒等待队列中的进程 dev-done 1; wake_up_interruptible(dev-wait_queue); } return IRQ_HANDLED; }发起者那边的逻辑就解脱了struct my_dma_device *dev ...; // 配置源、目的、长度 writel(src_phys_addr, dev-base DMA_SRC); writel(dst_phys_addr, dev-base DMA_DST); writel(len, dev-base DMA_LEN); // 使能完成中断启动传输 writel(DMA_CTRL_START, dev-base DMA_CTRL); // 睡眠等待中断来了wake_up就继续往下走 wait_event_interruptible(dev-wait_queue, dev-done); dev-done 0;这样CPU在线程里等待时可以被调度器切走去运行别的任务等DMA干完了中断一来这个线程再被唤醒。对整个系统来说CPU资源的利用率高了一个级别。注意wait_event_interruptible 返回后最好重新检查一下条件是否真的成立了。因为可能被信号打断返回时dev-done还是0。实际驱动里通常用循环包一层或者用wait_event_interruptible_timeout带一个超时保护防止硬件异常时进程永远睡死。4.2 中断处理函数为什么不能做“重活”这里有个很多新手容易踩的坑在中断处理函数里做了太多事情。比如读出来的数据很大直接在ISR里做内存拷贝或者在ISR里调用了一个可能睡眠的函数。这都是大忌。中断处理函数运行在原子上下文中期间当前CPU核上高优先级的中断被屏蔽任何调度、睡眠、动态内存分配GFP_KERNEL都是不被允许的。正确做法是ISR里只做最必要的事确认中断来源、清中断标志、唤醒等待者。如果确实需要做复杂善后就用Linux的tasklet或者workqueue把重活推到下半部去执行。我们这个“DMA完成通知CPU”的场景因为通知完的主要动作就是唤醒一个进程所以在上半部直接做掉没有任何问题。但如果你的驱动在DMA完成后还需要对数据做校验和、再做一次较小的搬运务必考虑下半部机制免得让中断路径过长影响其他时间敏感的设备。4.3 request_irq 的正确姿势注册中断处理函数是初始化阶段必须做的。这里有几个我实践下来觉得特别值得注意的点int ret; // 拿到DMA设备对应的IRQ号可能是设备树里指定的也可能是PCIe 的MSI-X申请来的 int irq platform_get_irq(pdev, 0); ret request_irq(irq, dma_done_handler, IRQF_TRIGGER_HIGH, // 由硬件触发方式决定 my_dma_device, dev); if (ret) { dev_err(pdev-dev, failed to request irq %d, ret%d\n, irq, ret); return ret; }第一个坑是handler和dev_id的匹配。如果你的驱动注册了多个设备或者同一个中断号上挂多个设备dev_id是用来区分的所以通常传递设备结构体指针。中断处理函数的第一个参数irq只是告诉你哪个中断号发生了具体是哪个设备、哪个DMA通道都要靠dev_id指向的结构体里的寄存器去判。第二个坑是共享中断。有些设备不支持独立的MSI-X中断只能和别的设备共用一个中断号。这种情况下request_irq必须带IRQF_SHARED标志且中断处理函数里如果发现不是自己的设备产生的中断必须返回IRQ_NONE。如果忘了带IRQF_SHARED申请就会失败。第三个坑是中断的触发方式。边沿触发和电平触发对中断处理的要求完全不一样。边沿触发有锁存机制如果一个边沿没被及时响应可能会丢。电平触发只要不解除电平中断就会反复进入处理函数容易导致风暴。用DMA设备时通常芯片手册会给明确的推荐配置照着配就好如果是FPGA自己做的DMA设备和硬件工程师对齐触发方式格外重要软件上配置错了表现出来的症状可能非常诡异。4.4 DMA描述符与完成回调硬件抽象的进阶视角现代高性能DMA设备比如NVMe控制器、GPU、智能网卡早已不是CPU直接配置一堆寄存器这么简单了。它们普遍采用描述符环Descriptor Ring的方式驱动在内存里维护一个环形队列每个描述符里写清楚源地址、目的地址、长度、标志位硬件自动从队列里取任务一个个执行完成后写回描述符里的状态字段然后产生一个完成中断。在这个模型下“DMA干完了”的通知就更加高级了。驱动不再为每个传输逐一注册回调而是在中断处理函数里遍历完成队列批量处理所有已经完成的请求。这大大提高了高IOPS场景的效率。以Linux的io_uring或者NVMe驱动为例一次中断过来可能同时有几百个请求完成了驱动轮一遍完成队列挨个唤醒对应的等待者或者回调函数。这个思路在AI Infra里也用得淋漓尽致。GPU驱动、RDMA网卡驱动、DPU数据处理单元驱动几乎都是这种描述符加完成队列的架构。理解了这个模型你就能明白为什么中断处理函数里不建议做重活——因为一次中断可能要处理海量的完成事件再把耗时的数据不做规划地丢进去整个系统的IO路径就会变得又长又慢。5. 实际调优经验与排查锦囊5.1 中断风暴DMA完成中断频率过高怎么办一个典型的症状是系统负载不高单看CPU占用率也不满但你用top看一眼会发现某个进程CPU时间明明不多系统整体却卡卡的用cat /proc/interrupts看一下某个中断号的计数在疯狂上涨。这就是典型的DMA完成中断频率过高。我遇到过一个真实案例某次在调一块FPGA加速卡的驱动时因为配置错了DMA控制器的中断合并阈值原本应该攒够64个描述符才报一次中断结果一个描述符完成就报一次导致中断频率直接高了几十倍。处理方法是修改阈值寄存器把中断聚合打开让中断频率降到合理的范围。**实战中判断“合理范围”的一个简单标准中断带来的CPU开销不要超过设备吞吐带来收益的1%左右。**如果网卡跑50Gbps流量时单核的软中断处理已经吃满了一整颗核那一定是要做中断聚合或者多队列分发的。5.2 中断迟迟不来问题排查链路顺序假设DMA搬运没有完成中断也一直没来代码卡在wait_event里不动了。我建议按这个顺序查先看硬件寄存器DMA控制器的状态寄存器里搬运是否已经完成传输完成位是不是已经置1了如果硬件层面已经完成了那大概率是“中断没传出来”检查中断使能位和中断状态寄存器。确认中断支配在中断处理函数入口加一行printk或者trace_printk看看到底有没有进中断。如果没进检查中断控制器层面的使能和屏蔽配置GIC/APIC的相应寄存器以及设备树或者ACPI表里中断号的映射对不对。确认中断号和设备匹配用cat /proc/interrupts看各个中断号的计数对比设备到底是哪个IRQ。如果设备申请的IRQ号和实际硬件连接的中断线不一致也会出现“谁来敲门都敲不到我家”的窘境。确认等待条件没被错误消费wait_event条件变量是共享的可能在中断来之前就被别的地方改成了1导致进程提前醒来却以为传输完成然后继续等待或者反过来。查代码里有没有别的地方写这个标志位。另外irq和dev_id那块的坑也要复盘。共享中断下如果自己的ISR返回了IRQ_HANDLED导致别的设备中断没识别也可能让某个设备的完成信号被吞掉。这时把ISR里返回IRQ_NONE的态度要严谨。5.3 多队列设备与中断亲和性配置实战对AI服务器来说多队列设备的配置是绕不开的一环。以主流网卡为例每个队列都会被分配一个MSI-X中断每个中断可以绑定到不同的CPU核上。手动配置时需要注意几个点用lspci -vvv查看设备支持的MSI-X中断向量数量一般会对应队列数。找到每个队列对应的IRQ号之后通过写smp_affinity掩码来绑定CPU。比如掩码是0x30代表IRQ只发给CPU4和CPU5。配置完记得用cat /proc/interrupts验证一下确认中断处理计数是否持续增长而且增长的那一栏确实是绑定的那个核。这里有个很容易被忽略的细节NUMA架构下中断处理核最好和DMA设备所在的PCIe控制器在同一个NUMA节点。跨NUMA访问内存会有额外的延迟和带宽损失对AI训练这种对延迟敏感的场景影响尤其明显。可以通过lstopo命令看看设备挂在哪颗CPU的PCIe root上然后把该中断绑到这颗CPU的本地核上。5.4 中断处理函数的“慢性子”排查法当某次DMA完成中断处理时间很长时系统的其他中断响应也会被拖慢。Linux里排查中断处理耗时常用的手段是ftrace里的irqsoff和preemptirqsoff追踪器# 开启中断关闭追踪 echo 0 /sys/kernel/debug/tracing/tracing_on echo irqsoff /sys/kernel/debug/tracing/current_tracer echo 1 /sys/kernel/debug/tracing/tracing_on # 等一段时间或者复现问题后 cat /sys/kernel/debug/tracing/trace看到最大中断关闭时间的调用栈后基本就能锁定位高耗时的ISR。老实说这种问题一般是驱动写得糙导致的比如在ISR里加锁、做耗时循环、或者调用了会睡眠的接口。把重活挪到下半部之后中断响应时间会立刻好看很多。6. 几个我踩过的坑写成给你们避雷关于“DMA完成后怎么通知CPU”这个话题我最后聊聊自己实际调试过程中的几个体会。第一个坑是清中断标志的时机。有些DMA控制器要求在中断处理函数里先读取数据、再清标志有些则要求先清标志、再读数据。顺序搞反了轻则多触发几次误中断重则直接丢中断。我建议拿到芯片手册后先把这个时序弄清楚最好在写ISR之前用硬件的loopback模式单独测一下中断路径不要等到系统联调时再排查那样变量太多了。第二个坑是共享中断和自定义的MSI中断不要混在一起处理。共享中断时无法直接从IRQ号反推设备必须读自己的设备寄存器来判定MSI中断则通常和设备一一对应可以放心用IRQ号做查表。这两种处理逻辑写在一起代码容易绕死而且最后出bug时特别难查。第三个坑是wait_event和超时的配合。AI加速卡在异常时有时会出现DMA控制器挂死的情况如果驱动的等待没有超时保护系统会一直卡在等待队列里有些看门狗机制根本救不回来。我的习惯是任何DMA等待都加上超时超时后至少打印一条寄存器快照日志方便事后分析是在哪个环节卡的。最后还有个心得排查DMA中断问题最有效的工具不是示波器而是/proc/interrupts加上trace-cmd。先看中断计数有没有涨再看源头的驱动有没有进ISR最后再怀疑硬件。**这个从统计到代码、再到电气信号的排查顺序能帮你在毫无头绪时稳住阵脚。**调试过程中记得多留打印哪怕临时加的printk显得代码很丑也比两眼一抹黑强得多。等问题定位清楚了再把调试代码删除也来得及。