
在现代计算机中,中央处理器(CPU)是计算的大师级架构师,但它常常被移动数据的琐碎任务所拖累——这个过程被称为程序化 I/O(PIO)。这种低效率造成了严重的性能瓶颈。针对此问题的优雅解决方案是直接内存访问(DMA),这是一种将数据传输的“搬砖”工作委托给专门控制器的机制,从而使 CPU 能专注于更复杂的任务。这种委托引入了并行性,并极大地提高了系统吞吐量。
本文将全面探讨直接内存访问。第一章“原理与机制”将剖析 DMA 的核心工作方式,从其基本的性能权衡到它在现代架构中带来的复杂挑战,如总线争用、虚拟内存交互,以及缓存一致性这个微妙但至关重要的问题。随后,“应用与跨学科联系”一章将揭示这些原理在现实世界中的应用,展示 DMA 作为从磁盘 I/O 和网络通信到支撑我们数字世界的安全框架和高性能计算集群等一切背后默默无闻的功臣。
想象你是一位大师级建筑师,一个能够设计出最复杂思想殿堂的卓越头脑。你的时间是无价的。现在,想象一下,你被要求日复一日地将砖块从采石场搬到建筑工地。这是必要的工作,但却是对你独特才能的巨大浪费。这正是现代中央处理器(CPU)所处的困境。CPU 是计算能力的奇迹,但其大部分工作都涉及将大块数据从一个地方移动到另一个地方——从网卡到内存,或者从硬盘到内存。当 CPU 亲自处理这种“搬砖”工作,一个字节一个字节地枯燥操作时,我们称之为程序化 I/O(PIO)。它能完成任务,但此时的建筑师不是在设计,而只是在搬运。
一定有更好的方法。而且确实有。
优雅的解决方案是雇佣一位专家:一个专门、高效且能独立工作的搬运工。在计算机中,这个专家就是直接内存访问(DMA)控制器。CPU 扮演项目经理的角色,只需给 DMA 控制器下达一份工作指令:“请将这么多数据从这个源头移动到那个目的地。”然后,CPU 就可以自由地返回去处理自己复杂的任务了。一旦 DMA 控制器完成了它的工作,它会向 CPU 发送一个简短的通知——一个中断——说:“货物已送达。”
这种委托原则是 DMA 的核心。它在系统中引入了并行性:CPU 可以在思考的同时,让 DMA 控制器进行数据移动。当然,委托并非没有成本。CPU 必须花费一些时间准备工作指令(DMA 设置开销),并花少量时间处理完成通知(中断处理开销)。另一方面,PIO 没有设置开销;CPU 直接开始移动数据。
这就产生了一个典型的经济学权衡。对于非常小的任务,建筑师自己搬几块砖通常比为搬运工写一份工作指令要快。但对于搬运成千上万块砖来说,雇佣搬运工的初始管理开销会得到千百倍的回报。在计算术语中,存在一个盈亏平衡点。如果 CPU 驱动传输的每字节成本是 个周期,而 DMA 的一次性设置成本是 个周期,那么对于任何大于约 字的数据块,DMA 都更高效。对于任何可观的数据量,DMA 的优势都是压倒性的。
优势有多大?让我们考虑一个涉及 512 KiB 数据块的现实场景。使用 PIO,CPU 会完全被占用,先移动数据,然后处理数据。使用 DMA,CPU 启动传输后立即开始处理工作,而 DMA 控制器则在后台处理传输。即使算上 CPU 设置 DMA 传输和处理完成中断的时间,完成整个任务的总时间也大大减少了。在典型情况下,整体数据处理吞吐量可以提升 1.58 倍或更多。通过委托这些粗活,我们解放了 CPU,让它去做最擅长的事情,从而使系统效率大大提高。
我们关于 CPU 和 DMA 控制器在完美、并行的和谐中工作的故事,其实有点过于简单了。它们可能在处理不同的任务,但必须共享相同的基础设施。无论是 CPU 需要获取指令或数据时,还是 DMA 控制器在传输期间,它们都需要使用系统的主要数据高速公路:内存总线。
当 DMA 控制器正在积极传输数据时,它就是总线的主宰。如果 CPU 恰好在那个时刻需要总线来获取下一条指令,它就必须等待。从某种意义上说,DMA 控制器正在“窃取”CPU 本可以使用的内存周期。这种现象被称为周期窃取或总线争用。
这不是恶意行为;这是两个工人共享单一路径的自然结果。我们可以很简单地量化这个效应。如果在很长一段时间内,DMA 控制器占用了总线时间的比例为 ,那么 CPU 可用的带宽必然减少到峰值总线带宽 的 。CPU 对内存的访问实际上受到了限制。
从另一个角度看,如果 DMA 控制器以周期性突发的方式工作,在每个时间周期 内独占总线持续时间为 ,那么 CPU 会发现自己在 的时间比例内被阻塞,无法访问内存。这就是为什么在我们详细的性能分析 中,CPU 的有效每指令周期数(CPI)在 DMA 传输期间实际上会增加。CPU 被迫在某些周期内空闲,等待总线空闲,这使得它自己的工作耗时更长。DMA 带来了巨大的净收益,但其性能优势并非“免费”——它们是以争夺共享资源为代价的。
到目前多,我们一直将内存想象成一个简单、单一、连续的地址空间。但现代系统要复杂得多。现代操作系统给每个程序一种它独占整个内存空间的错觉。这就是虚拟内存。CPU 在*逻辑地址中思考和工作,这些地址就像程序自身世界内的私人邮寄地址。操作系统在硬件内存管理单元(MMU)的帮助下,将这些逻辑地址转换为计算机 DRAM 芯片中的实际物理地址*。
这就给我们的 DMA 控制器带来了一个棘手的问题。一个进程告诉操作系统:“我在我的逻辑地址 1000 处有一个缓冲区,请让网卡向其中进行 DMA 数据传输。”但 DMA 控制器不理解逻辑地址;它只知道物理地址。这种转换是如何发生的呢?
一个简单的方法是让操作系统为缓冲区找到一个大的、物理上连续的内存块。然后它可以给 DMA 控制器单个物理起始地址和总长度。问题在于,物理内存很快会碎片化成由已用和空闲块组成的拼凑图。找到一个大的连续块可能变得像在拥挤的城市里为一辆豪华轿车找停车位一样困难。
解决方案非常巧妙:分散-聚集 DMA(Scatter-Gather DMA)。操作系统不再给 DMA 控制器单个地址,而是提供一个物理地址和长度的列表。这个列表就像一套行车路线,告诉 DMA 控制器:“从物理地址 A 开始写入 100 字节,然后跳转到物理地址 B 写入 500 字节,再跳转到物理地址 C……” DMA 控制器遵循这个列表,将传入的数据“分散”到正确的物理片段中,或从这些片段中“聚集”数据。
这个功能非常强大,因为它允许 DMA 与非连续内存无缝协作,但它也引入了微小的开销。与单个连续传输相比,对 个段进行分散-聚集操作需要 CPU 构建,并让设备获取 个额外的描述符。每个段之间的转换也可能产生微小的同步成本。总开销可以表示为 ,其中 是每个描述符的成本, 是段之间的隔离成本。这种开销通常很小,但它凸显了系统设计的一个基本原则:灵活性往往伴随着微小的性能税。
与虚拟内存的交互还带来了另一个更严峻的挑战。操作系统作为资源管理大师,喜欢保持灵活性。为了充分利用有限的物理 RAM,它可能会暂时将一个不活跃的数据块(一个“页面”)移到磁盘上,或者仅仅为了减少碎片而将其移动到 RAM 中的另一个物理位置。
现在,想象一下,在 DMA 传输过程中,操作系统决定对我们 DMA 缓冲区中的一个页面执行此操作。DMA 控制器对操作系统的重新整理毫不知情,继续向原始物理地址写入数据。最好的情况是数据丢失。最坏的情况是,它会破坏操作系统现在放在那个旧位置上的任何东西。结果是一片混乱。
为了防止这种情况,必须强制执行一条严格的规则:在 DMA 操作的整个持续时间内,构成缓冲区的物理内存页面必须被锁定(pinned)。锁定是驱动程序向操作系统发出的一个命令:“在我说可以之前,不要移动或回收这些页面。”它们被锁定在物理 RAM 中的位置,为 DMA 设备创建了一个稳定的目标 [@problem_sps_id:3656302]。在拥有I/O 内存管理单元(IOMMU)——一个用于外围设备的 MMU——的现代系统中,物理页面和 IOMMU 为这些页面所做的地址转换都必须被锁定,以确保稳定性。
锁定解决了数据损坏问题,但它具有全系统范围的影响。操作系统的页面置换算法(决定在内存压力下换出哪些页面)依赖于有一个大的“牺牲”页面池可供选择。当我们为 DMA 锁定了 个页面时,我们将可替换帧的池从 缩小到 。如果所有运行中程序的总内存需求(它们的总工作集 )在此之前刚刚得到满足(),那么这种减少可能成为压垮骆驼的最后一根稻草。如果现在需求超过了可用的未锁定内存(),系统可能会开始颠簸(thrash)——这是一种灾难性的状态,系统花费更多时间在换入换出页面上,而不是做实际工作。我们再次看到,DMA 的好处并非完全免费;它们对系统的其他部分施加了实实在在的约束。
我们来到了 DMA 世界中最微妙、最引人入胜的挑战:一致性问题。CPU 并不总是直接与主内存打交道。为了达到极快的速度,它们依赖于称为缓存的小型、极快的本地内存库。当 CPU 读取数据时,一份副本被放入缓存中。在随后的读取中,它可以访问快速的缓存副本,而不是一直去访问慢得多的主内存。
陷阱就在这里。考虑以下事件序列:
会发生什么?CPU 首先检查其缓存。它在缓存中找到了缓冲区的副本——一次“缓存命中”——并读取数据。但这是 DMA 传输之前的旧的、陈旧的数据!CPU 完全不知道主内存中的“主副本”已被设备更新。这是一个缓存一致性问题。
在高端系统上,这个问题由硬件解决。内存总线是一致性互连的一部分,设备可以“嗅探”彼此的缓存活动,以确保每个人对内存的视图保持一致。但在许多更简单、嵌入式或较旧的系统上,I/O 路径是非一致性的。DMA 引擎和 CPU 缓存是两个互不通信的独立世界。
在这些非一致性系统上,软件——具体来说是设备驱动程序——必须扮演外交官的角色,强制执行一致性。
这种由软件管理的一致性是可行的,但其代价可能惊人地高昂。在一项分析中,以编程方式使一个 256 KiB 缓冲区的每一行缓存失效所花费的时间超过了 200,000 个 CPU 周期。看到新数据第一个字节的总延迟,比一开始就将缓冲区映射为不可缓存(这会强制所有访问绕过缓存直接访问内存)高出 100 多倍。这揭示了一个深层次的权衡:要么使用可缓存缓冲区以在重复的 CPU 访问中获得高性能,但代价是巨大的手动一致性开销;要么使用不可缓存缓冲区以获得简单性和低单次访问延迟,但代价是所有 CPU 访问的性能都很差。即使在复杂的虚拟化环境中,由软件管理一致性的相同原则也适用,其中客户机操作系统的驱动程序最终负责这些缓存维护操作。
从一个简单的委托思想出发,DMA 的概念展开成一幅丰富的计算机科学原理图谱——并行性、资源争用、虚拟内存和数据一致性。这是一个完美的例子,说明一个简单而强大的思想如何与现代计算机系统的每一层相互作用,揭示了使高性能计算成为可能的隐藏复杂性和优雅解决方案。
要真正欣赏直接内存访问的精妙之处,我们必须看到它的实际应用。在理解了其原理之后,我们现在可以踏上一段旅程,在现代世界的各个角落寻找它的踪迹,从你桌上的设备到推动科学前沿的超级计算机。DMA 不仅仅是一个工程上的注脚;它是一个基本的概念,它以前所未有的规模实现了效率、安全和计算能力。它是使我们的数字生活成为可能的、沉默而不知疲倦的功臣。
想象你是一位指挥家——CPU——正在指挥一个庞大的管弦乐队。你的小提琴部(硬盘)和你的铜管部(网卡)都需要根据他们的乐谱(数据)来演奏。作为指挥家,你会亲自跑到每个音乐家面前,把乐谱递给他们,然后等着他们演奏完再继续吗?当然不会!你会委托他人。你会让助手们——我们的 DMA 控制器——分发乐谱,让你能专注于指挥演出。
这正是你的计算机执行 I/O 时发生的情况。无论你是从固态硬盘打开一个大文件,还是从互联网加载一个高清视频,其底层过程都惊人地相似。在这两种情况下,CPU 都会发出一个命令:“从磁盘获取这个数据块”或“通过网络发送这个数据包”。然后,一个 DMA 控制器接管工作,在设备和主内存之间移动数据,从而解放 CPU 来管理其他任务。这种共享机制揭示了系统如何处理本质上不同类型的 I/O 的美妙统一性。
当然,这个故事也有其细微之处。当读取文件时,系统是很聪明的。它可能已经预料到你的请求,并将数据放在内存中一个称为页面缓存的特殊区域。如果你再次请求该数据,CPU 可以直接从这个缓存中检索它,而根本无需涉及磁盘或 DMA——一次缓存命中!这就像助手手头已经有了乐谱。然而,通过网络发送或接收数据总是涉及物理设备,因此总是涉及 DMA 来在网卡和内存之间移动数据。对于实时网络流,没有“缓存”可言。
让我们考虑一个更动态的例子:一台现代数码相机将高分辨率视频流传输到你的计算机。每一帧都是一个巨大的数据块,每秒有几十帧到达。强迫 CPU 复制每一帧的每一个像素会让它不堪重负。取而代之的是,我们使用一种“零拷贝”方法。相机的 DMA 控制器将帧数据直接写入应用程序可以立即访问的内存缓冲区。CPU 从不接触这些批量数据;它只管理整个过程。
为了让这场舞蹈顺利进行,需要一些优雅的编排。首先,你需要一个缓冲区流水线。当相机硬件(生产者)正在填充一个缓冲区时,应用程序(消费者)正在处理一个先前填充好的缓冲区,而其他缓冲区则排队等待硬件使用。这确保了流畅、连续的流程而不会丢帧。其次,这些内存缓冲区必须被锁定。计算机的内存管理器喜欢整理内存,在物理 RAM 中移动数据。锁定一个缓冲区就像在其物理内存页面上挂一个“请勿打扰”的牌子,禁止操作系统在 DMA 传输进行时移动它们。没有这个,DMA 控制器写入一个现在无效的地址,将会导致混乱。
至此,一个令人担忧的想法应该浮现出来。我们刚刚描述了一个世界,在这个世界里,各种硬件设备可以完全绕过 CPU,直接写入计算机内存的核心。这难道不是一个巨大的安全风险吗?是什么阻止了恶意设备覆盖操作系统内核或从内存中读取你的密码?
在早期,答案是“没什么”,所谓的 DMA 攻击是一个严重的威胁。现代的解决方案是一个名为输入输出内存管理单元(IOMMU)的杰出硬件。可以把它想象成针对每个 DMA 请求的专用护照管制和边境检查站。
正如 CPU 有一个 MMU 来将程序使用的虚拟地址转换为物理内存地址一样,IOMMU 也为设备做同样的事情。当操作系统想要允许一个网卡使用一个缓冲区时,它不只是告诉网卡缓冲区的物理地址。相反,它会编程 IOMMU 的页表,创建一个规则:“任何来自这个网卡、针对这个特殊设备地址的请求,都应被转换为那个特定的物理内存缓冲区。”设备只被给予这个特殊的设备地址,并在其自己隔离的虚拟世界中操作。
如果网卡试图访问其分配的虚拟空间之外的任何地址,IOMMU 硬件会直接拒绝该请求,并发出警报。它提供了关键的隔离,防止流氓或被攻破的设备在系统内存中自由游荡。
然而,IOMMU 并非魔杖。它必须被正确配置。一个懒惰或不正确的配置可能是灾难性的。考虑一台服务器,为了获得更高性能,它将一个物理设备的控制权直接传递给一个客户虚拟机。如果管理员用一个开放的“恒等映射”来配置 IOMMU,将所有设备请求 地转换为物理内存,他们实际上就关闭了护照管制。客户虚拟机随后可以命令该设备读取敏感的主机内核内存,完全打破客户机和主机之间的隔离。仅仅有硬件是不够的;操作系统必须明智地使用它。安全是硬件能力和软件策略之间持续的舞蹈。即使有完美配置的 IOMMU,漏洞也可能存在于系统启动的最早时刻,在操作系统有机会锁定一切之前,或者通过微妙的竞争条件,即一个设备在操作系统刚刚释放一个内存页面给其他用途后,继续向该页面写入数据 [@problem_id:3673369, @problem_id:3685766]。
IOMMU 的作用超越了安全,延伸到更深远的领域:它是虚拟现实的构建者。一个用户程序可能会在其虚拟地址空间中分配一个单一、巨大、连续的缓冲区。但在计算机的物理 RAM 中,这个缓冲区可能由几十个分散各处的小的、非连续的页面组成。一个简单的 DMA 控制器如何能将连续的数据流写入这个碎片化的缓冲区呢?
答案是分散-聚集 DMA 和 IOMMU 之间的完美协作。操作系统为 DMA 控制器提供一个分散-聚集列表,这就像一组指令:“将前 100 字节写入物理地址 A,接下来 100 字节写入物理地址 B,……” 或者,更优雅地,操作系统可以编程 IOMMU,为设备呈现一个简化的现实。它将分散的物理页面映射到一个对设备可见的单一、连续的虚拟范围。然后设备可以对这个虚拟范围执行一个简单的大规模 DMA 写入,而 IOMMU 硬件会自动处理将数据“分散”到正确的物理位置。
这种将复杂内存访问模式从 CPU 卸载的能力,为 DMA 充当专门的计算引擎打开了大门。想象一下,你需要转置一个存储在内存中的大矩阵。在一个行主序布局中,一列的元素在内存中相隔很远。与其让 CPU 费力地逐个读取每个元素,一个复杂的 SG-DMA 引擎可以被编程来完成这项工作。它可以被指示“读取一个元素,跳过 N 个字节,读取下一个,跳过 N 个字节……”并将结果连续写入,从而有效地读取一列并将其写为一行——这是转置的核心操作。CPU 被解放出来执行更复杂的计算。
这个原理是现代加速计算的基础。当一个强大的图形处理器(GPU)渲染一个场景或训练一个神经网络时,必须有大量数据流式传输给它。这是一个经典的 DMA 流水线问题,其性能是 PCIe 总线的吞吐量和人们能为缓冲负担得起的昂贵的、被锁定的内存量之间的微妙平衡。
到目前为止,我们的 DMA 故事一直局限于单台计算机。但是,如果我们可以将这种绕过 CPU 的数据移动原则扩展到整个网络呢?这就是远程直接内存访问(RDMA)的领域,它是高性能计算(HPC)的基石。
传统的网络通信涉及发送端和接收端的操作系统内核。数据从用户的应用程序复制到内核缓冲区,然后通过 DMA 移动到网卡。在另一端发生相反的过程。这种方式安全通用,但内核的参与和额外的复制增加了显著的延迟。RDMA 提供了一种激进的替代方案。它允许一台机器上的应用程序直接写入另一台机器上应用程序的内存中,无需任何内核参与,也无需任何复制。这就像给一个受信任的合作者一把钥匙,可以访问你家中一个特定的、预先安排好的邮箱。设置过程更复杂——你必须“注册”内存区域使其可用——但对于大规模数据传输,性能增益是巨大的。
现在,让我们迈出最后一步,将所有这些想法结合起来,这将是令人惊叹的。想象一个大规模的科学模拟——比如模拟一架新飞机机翼上的气流——运行在一个计算机集群上,每台计算机都有自己强大的 GPU。每个 GPU 处理问题的一部分,并且必须周期性地与其邻居交换边界数据。“主机暂存”路径会非常缓慢:GPU 到主机 RAM,主机 RAM 到网卡,跨越网络,网卡到远程主机 RAM,远程主机 RAM 到远程 GPU。这是一条曲折的旅程,包含四次独立的数据复制。
有了 GPUDirect RDMA,奇迹发生了。应用程序在“CUDA 感知”通信库的帮助下,指示第一台机器上的支持 RDMA 的网卡直接从 GPU 的内存中读取数据。数据飞越网络,第二台机器上的网卡将其直接写入第二个 GPU 的内存中。数据路径简化为 GPU → NIC → 网络 → NIC → GPU。两个 CPU 和两个主内存系统都被完全绕过。这是 DMA 的终极体现:从 GPU 到网卡的专用硬件交响曲,跨越网络直接通信,以解决一个单一的、巨大的问题。它证明了一个简单的原则——委托数据移动——当与虚拟内存、安全和网络层层结合时,可以扩展以创造出人类有史以来最强大的计算工具。