
在 AI Infra 的日常里DMADirect Memory Access是个被反复提起的概念CPU 把数据搬运交给专用硬件自己从 memcpy 的苦力活里解脱出来。可是每次聊到“DMA 做完了设备怎么告诉 CPU‘我干完了’”很多人都会卡一下。这一问在技术社群的热度不低说明它并不是一个只靠面试八股就能讲透的细节而是真实调试中绕不开的握手逻辑。今天这篇就围绕 DMA 完成后设备如何通知 CPU 这件事从原理、方案选型、内核链路到排查经验展开完整拆解。适合正在做网络、存储或者异构加速的工程师也适合被嵌入式 DMA 中断折腾得睡不着觉的同学参考。1. 先弄明白DMA 完成通知解决的是谁的问题1.1 为什么 CPU 不能一直等到数据搬完DMA 的价值是让 CPU 不被长距离数据搬运拖住。典型的搬运场景里CPU 只需要事先告诉设备“数据放在哪、搬多大”然后设备自己走总线把数据从源地址搬到目标地址。问题在于CPU 发完指令之后并不会知道设备具体哪一刻搬完。如果 CPU 傻等着那 DMA 的优势就没了——省下来的时间又浪费在等待上。因此必须有一个“完成通知”机制让设备在干完活之后主动喊一声。这个喊声如果做得好CPU 可以做别的事直到被喊醒如果做得不好可能出现 CPU 长时间空转、中断风暴或者设备干完了但 CPU 完全不知道导致后续流程永久卡死。在 AI Infra 场景里这个问题的分量更重。GPU 训练、分布式通信、NVMe 存储都依赖 DMA 搬运CPU 经常不是数据通路的主角而是协调者。协调者如果不知道“什么时候数据 ready”流水线就没法推进。所以 DMA 完成通知不是一个小功能而是整个异步体系的地基之一。1.2 完成通知的本质硬件向 CPU 提交“工作成果”把这个问题抽象一下DMA 完成通知本质上是一次“事件分发”。设备内部有一个状态机搬运完成后会进入 done 状态同时把描述符、状态字或者中断状态寄存器更新掉。CPU 侧要做的是感知这个状态变化。感知手段通常分成两类一是轮询CPU 反复读设备的寄存器或者内存中的描述符标志位二是中断设备通过中断控制器主动触发 CPU 去处理。两者没有绝对优劣只有场景适配。轮询简单、延迟稳定但烧 CPU中断节省 CPU但中断本身有开销频率高了反而会拖累吞吐。很多现代硬件会把两种手段组合起来比如中断合并攒一批完成事件再统一上报既能用中断的低延迟特点又不会让中断频率高到把 CPU 打崩溃。理解这个权衡很重要因为很多“为什么我的程序 DMA 很慢”的问题根源不在 DMA 本身而在完成通知的设计。1.3 AI Infra 里的完成通知为什么格外复杂AI Infra 的弹性场景里DMA 不只是在一颗 SoC 内部做搬运。GPU 显存、网卡 FIFO、NVMe 控制器、甚至 DPU 上都有独立的 DMA 引擎多个设备可能同时发起传输。CPU 面对的是一堆“可能会同时喊话”的人它得知道每一声是哪个设备、哪一笔传输、数据落到哪里。这就要求完成通知除了“喊一嗓子”还必须携带足够上下文。传统中断只有一根信号线CPU 要去读寄存器才知道是谁、为什么现代 PCIe 设备更多使用 MSI/MSI-X每个 DMA 完成队列可以绑定独立中断向量CPU 甚至不用读寄存器也能大概猜到是哪个队列的活干完了。这种细颗粒度的通知方式直接影响 AI 训练中通信和计算的重叠效率。2. 设备到 CPU 的信号通道从引脚中断到 MSI-X再到中断合并2.1 传统引脚中断简单但容易吵到 CPU最早 DMA 控制器使用的是专用中断引脚比如 X86 平台的 IRQ。设备完成 DMA 后拉高引脚中断控制器把请求送给 CPUCPU 保存现场后进入中断处理程序。这套机制的逻辑相当直白几乎不需要设备软件做额外解释。代价也很明显中断引脚数量有限多个设备可能要共享一条 IRQ 线。共享意味着设备需要在自己的 ISR 里判断中断是不是自己的不是的话还得放行。DMA 完成请求一多CPU 就会不断被打断上下文切换和频繁读状态寄存器的开销会明显抵消掉 DMA 省下的时间。我做嵌入式的时候遇到过类似问题SPI 用 DMA 接收每接收一包就触发一次中断CPU 占用率居高不下后来把中断合并加上才缓解。在 AI Infra 的服务器上传统引脚中断几乎已经不用于高性能数据通路。但嵌入式小系统里还在大量使用比如 STM32、GD32 的 DMA 中断这属于还没完全退役的方案做单片机或者边缘 AI 设备时仍然要面对。2.2 MSI/MSI-X把通知做成“一条带地址的消息”MSI 和 MSI-X 是 PCIe 时代更高效的通知方式。设备不再靠拉引脚而是往预先配置好的内存地址写一个特定的数据值CPU 会把这个写操作识别成一个“中断消息”。听起来绕实际效果是把中断变成了一条“带了收件人地址的消息”。MSI-X 相比 MSI 最大的进步是支持多个中断向量。一个设备可以把不同的 DMA 队列映射到不同的中断向量还能绑定到不同 CPU 核心。AI Infra 里常见做法是让 CPU0 专门处理控制面中断CPU2/CPU4 处理数据面完成中断这样多个队列之间不会互相挤占。这种设计让完成通知拥有很强的可扩展性。比如 NVMe SSD 有多个 IO 队列每个队列有自己的 Completion QueueMCU/主控完成 DMA 后往对应 MSI-X 向量写一个 Doorbell 消息。CPU 收到中断后可以根据向量号快速定位到对应的完成队列不再需要全设备范围内扫状态寄存器。2.3 中断合并妥协中的艺术中断合并或者说 Interrupt Coalescing是网卡和存储控制器里最常见的优化手段。它允许设备攒住多个 DMA 完成事件直到满足某个时间窗口或者事件数量阈值再统一发一次中断。这个机制对吞吐型场景帮助极大因为在跑大规模数据复制时如果每笔小传输都触发一次中断CPU 会被打断到怀疑人生。代价是延迟变高。DMA 已经完成了但中断被推迟数据在内存里躺着CPU 不知道。这很适合 AI 训练里“算一段、传一段”的模式通信的完成及时性可以稍微放宽换取 CPU 更多时间去执行算子。不过也有一些极端低延迟业务比如证券交易或者高速网络包处理会希望把合并关掉追求“DMA 一完成CPU 立刻被叫醒”的确定性。现在很多网卡支持自适应中断合并驱动可以根据当前负载动态调整合并参数。比如 DPDK 里就有收包速率变化时自动调节中断延时阈值的逻辑。这个思路同样适用于 DMA 完成事件不过在通用 DMA 引擎上不像网卡那么常见。3. 实操向一次 DMA 完成通知走过的完整链路3.1 发货前先写“快递单”描述符和 DoorbellDMA 传输开始前软件需要在内存里构造一个“描述符”里面写清楚源地址、目标地址、长度、传输方向还有一个状态字段。设备不会直接读软件的内存结构而是由软件通过写一个叫 Doorbell 的寄存器告诉设备“描述符已经备好你可以开始干了”。Doorbell 这个名字很形象就是软件在门口按铃设备听到铃声去门口拿快递单。按完铃之后软件就返回去干别的事了。此时设备开始自己跑总线逐字节搬运。这个阶段的 CPU 是完全不碰数据的这也是 DMA 的高效来源。3.2 设备干完活之后做了什么设备搬运完成后第一件事是更新描述符里的状态字段把它从“进行中”改成“已完成”同时可能还会填一些实际传输字节数、错误状态等。这个动作说到底是往内存里写几个字节对设备来说不是难事。问题是CPU 不会一直盯着这个字段所以设备还需要“额外动作”来提醒 CPU。这个额外动作就是前面说的中断上报。设备会先向中断控制器提交请求中断控制器处理优先级和路由最终把一个中断事件送到 CPU。CPU 响应后进入中断处理流程找到对应的描述符确认状态字段然后执行后续动作。整套流程就像一个国际快递软件下单写单子DMA 卡车送货送到后设备给 CPU 发“短信”CPU 收到短信后去站台取货并拆包验证。3.3 内核收到“短信”后怎么办ISR 与软中断的分工CPU 收到中断后会跳进一个中断处理函数这个函数有两段很关键的分工。上半部叫 hardirq要求清爽快速一般只做“认领”工作确认中断来自哪个设备把设备数据搬到内存后立即返回。长时间在 hardirq 里做复杂逻辑会让系统卡死也会阻塞更低优先级的中断请求。下半部有几种实现方式tasklet、softirq、workqueue还有线程化中断。DMA 完成事件的处理如果比较轻比如只是唤醒一个等待线程可以在 softirq 里做如果需要处理数据包、更新 iocb、调用用户态回调那最好放到 workqueue 或者内核线程里做避免长期占领软中断上下文。实际驱动开发时我习惯把“确认状态字段、收尾资源”放在 hardirq 或者立即调用的 softirq 里把“通知用户态、分派后续任务”放到线程化处理。这样的分工能最大程度压低中断关闭时间也方便代码维护。3.4 从内核到用户态eventfd、epoll 与 io_uring 的 CQAI Infra 的多数流水线最终要在用户态感知 DMA 完成。以 NVMe 驱动为例内核把命令放进 SQ设备完成后在 CQ 里产生一个完成队列项驱动通过中断得知 CQ 有新条目然后调用 blk-mq 的完成回调把请求对应的 bio 和 request 标记完成最终唤醒用户态进程。如果程序走的是异步 IO比如 io_uring完成回调会写 CQ ring然后通过 eventfd 触发用户态的事件循环。用户态不主动轮询设备而是被 epoll_wait 唤醒。这中间每一层都在传达同一句话“DMA 完了数据就绪了。”链路越长中间的延迟抖动越大因此高性能场景通常会尽量缩短链路让用户态直接读取设备 mmap 出来的完成队列。用一段接近真实但不依赖某个具体硬件的伪代码来演示驱动侧的处理逻辑能看得更清楚// DMA 完成中断处理简化版 irqreturn_t dma_done_irq(int irq, void *dev_id) { struct my_dev *dev dev_id; // 1. 快速判断这个中断是不是我们设备的 if (!readl(dev-regs INT_STATUS)) return IRQ_NONE; // 2. 把硬件产生的完成描述符搬进内存缓存 dma_sync_single_for_cpu(dev-dev, dev-desc_dma, sizeof(struct dma_desc), DMA_FROM_DEVICE); // 3. 遍历描述符找到状态已经置为 done 的项 while (desc-status DESC_DONE) { // 记录完成信息释放 DMA buffer complete_callback(desc); // 更新环形缓冲区尾指针 desc-status 0; desc; } // 4. 告诉硬件中断已处理可以继续 writel(INT_CLEAR, dev-regs INT_CLEAR); return IRQ_HANDLED; }这只是内核态的一半用户态还需要通过某种机制知道“内核已经处理完了”。常见做法是给等待队列或者 eventfd 置位代码里通常会写成wake_up_interruptible(dev-wait_queue)这样read()或者epoll_wait()等待的进程才会被唤醒。3.5 轮询是一条相反的路无中断的完成感知另一个极端是完全没有中断。CPU 主动去读描述符状态字段读到 done 就继续下一步。这种方式在 DPDK、SPDK 这类用户态轮询框架里非常常见因为网络包处理和 NVMe 访问追求极低延迟同时又愿意为了低延迟牺牲 CPU 核心来轮询。轮询对 DMA 完成通知的意义很明确消除了中断处理和上下文切换的抖动让完成时延趋于稳定。代价是 CPU 使用率 100% 跑着空循环。因此很多框架会做自适应比如一段时间没有完成事件就自动睡眠通过中断唤醒形成“轮询和中断交替”的模式。如果你在做 AI Infra 的训练通信优化应该能感受到这种模式的存在NCCL 里就包含内核态/用户态通信通道某些阶段会用轮询 busy-wait某些阶段会用 semaphore 等待中断或事件。DMA 完成通知选择哪条路本质是在延迟、CPU、功耗之间做平衡。4. AI Infra 场景下 DMA 完成通知的常见问题与排查心得4.1 中断风暴设备频繁喊话CPU 直接累瘫中断风暴是我调试中最常见的问题之一。设备每次完成一个极小的 DMA 传输就触发一次中断CPU 大部分时间都在响应中断而不是干活。这种问题在虚拟化场景尤其突出因为虚拟机的中断注入路径更长开销更高。排查思路可以从/proc/interrupts开始看。如果发现某个 IRQ 次数涨得异常快基本可以断定设备在疯狂上报完成事件。对策有三类加大 DMA 传输粒度让一次传输搬运更多数据开启中断合并设置合理的 time/rate 阈值或者使用专门借道轮询的框架让 CPU 在数据面直接刷完成队列而不是等中断。我曾经调过一款 FPGA 加速卡它每处理一个 64 字节请求就打一次中断导致 CPU 单核跑满吞吐只有预期三分之一。最后在驱动里加了一层“中断合并窗口”攒满 32 个完成事件或者超过 10 微秒再上报单核 CPU 占用立刻降下来吞吐也上去了。4.2 中断丢失或者“DMA failed to reset”这类怪异报错还有一类问题是设备把 DMA 做完了但中断没到或者 DMA 控制器复位失败比如网上经常看到的 RK3588 ethfailed to reset the DMA。这类报错往往不是完全无解的玄学而是 DMA 引擎的复位依赖时钟、电源域或者总线操作顺序。排查时建议一步步验证先看设备寄存器里复位状态位是不是被正确置起再看时钟有没有打开再看 PCIe 链路是否处于正常工作状态如果设备有独立的 debugfs 或者 ethtool 寄存器 dump优先利用。很多 DMA reset 失败是时钟没就绪导致的不是中断处理程序的 bug。中断丢失则要检查中断请求的“电平极性”和“触发方式”。传统中断多用边沿触发如果 CPU 在清中断之前设备又来了新的完成事件边沿可能被合并导致软件一直醒不来。解决办法是保证中断状态寄存器里所有 pending 位都处理完再写 clear不要用“只清自己处理的那一位”的方式否则容易漏掉并发完成事件。4.3 完成延迟不稳定中断合并、CPU 亲和性和其他坑DMA 完成通知的延迟是 AI Infra 里十分敏感的指标。如果时延抖动剧烈训练流水线的 bubble 就会变大同步开销分摊不均匀。最常见的影响因素有三个中断合并时间窗口、CPU 被其他进程抢占、IRQ 亲和性绑定不够合理。IRQ affinity 是很容易被忽略的参数。默认情况下内核可能把同一个设备的多个 MSI-X 中断分散到多个 CPU或者全部落在 CPU0。如果 CPU0 本身要跑控制面任务DMA 完成中断一多就会拖慢控制面。实践经验是手动把每个设备的完成队列中断绑定到确定的数据面 CPU并且用taskset同时把用户态线程绑到同一个 CPU形成“中断-处理线程-数据缓冲区”的局部性。我自己的调优节奏通常是先用mpstat观测哪个 CPU 的硬中断占用高再用grep /proc/interrupts确认中断分布最后通过/proc/irq/IRQ/smp_affinity设置亲和性同时配合 ethtool 或者 vendor 工具打开或关闭中断合并。整个过程并不复杂但能把尾延迟削掉不少属于性价比很高的一步。4.4 从 DMA 完成通知到分布式 DMADPU 与代理设备带来的新视角传统单机模型里DMA 完成通知只在一颗 CPU 和一个设备之间交互。到了 AI Infra 的规模化场景一概情感不限这一个点。比如 DPU 或者智能网卡上会跑一个“DMA 代理”宿主 CPU 把传输工作委托给网卡网卡完成后再通过门铃或者事件通知宿主。这个时候 DMA 完成的“设备”已经不是外设本身而是一个更靠近网络的处理器。这带来一个新问题DMA 完成通知的“终点”是远端 CPU而非本地中断控制器。有些实现会用网络报文承载完成事件类似 RDMA 的完成队列事件有些实现会用硬件 timestamp 和 doorbell 消息来同步多核完成状态。调试起来比传统 PCIe 中断更费劲因为链路里多了网络传输的延迟和乱序。如果遇到跨节点 DMA 完成通知延迟过高我通常会先拆两段来看本地 DMA 搬运时间、完成事件传输时间。不要一上来就调中断合并参数先确认完成事件走的是哪个队列、是否和流量共用同一个拥塞通道。只有在传输稳定的情况下调中断侧参数才有意义。4.5 嵌入式小系统也不该被忽略STM32/GD32 的 DMA 中断设计提到 DMA 完成通知很多人默认是服务器场景但大量热词搜索其实指向嵌入式开发比如 STM32 DMA、GD32E230 ADC DMA 数据紊乱、串口 DMA 加空闲中断。刚接触这些 MCU 的同学经常把 DMA 配置好却不打开中断结果数据在内存里“躺尸”主循环完全不知道。嵌入式里的经典组合是 DMA 加空闲中断。串口接收时DMA 一直往内存搬运数据但要判断一帧数据有没有结束不会每个字节都触发中断而是靠“总线空闲”触发一个空闲中断告诉 CPU“这帧数据应该结束了”。这个模式非常高效也是 DMA 完成通知在 MCU 侧的典型案例。实操中要注意 DMA 通道的中断优先级要设置合理尤其是 ADC 多通道采集时如果 DMA 中断优先级太低可能被其他中断延迟处理导致数据槽位都被覆盖最后读出来的数据是乱的。ISR 里也要小心不要在中断里做太多处理先迅速把 DMA 目标缓冲区里的数据收走再慢慢解析。很多 GD32E230 的数据紊乱问题根源不是硬件而是中断处理不及时导致 DMA 又搬运了一次新数据把上一份覆盖了。5. 收尾前想再补的一句体会DMA 完成通知这个机制衡量标准其实只有一条CPU 能否及时、无负担地知道“数据已经到位”。效率高不高要看整条链路里每一环是否都对得上——描述符、Doorbell、中断向量、ISR、下半部、用户态唤醒。任何一个地方松懈都会造成队友空等或者 CPU 白忙。个人经验是调试这类问题时别只盯着中断号或者寄存器先把“事件流”画出来哪个设备、哪个队列、哪个描述符、哪条唤醒路径一步一步追踪往往比瞎试参数高效得多。希望这篇能把 DMA 完成通知这件事讲明白也让你下次遇到设备“不说话”的时候心里有个清晰的排查顺序。