
在计算世界中,我们通常将内存想象成一种简单、统一的资源,任何数据都可以以相同的速度访问。虽然这种抽象在小型设备上是成立的,但在支撑我们数字基础设施的大型服务器中却不尽然。这些机器是处理器和内存条的复杂集合体,物理距离成为一个关键的性能因素。这就产生了非均匀内存访问(NUMA),这是一个基本的架构现实,即访问“本地”内存速度很快,但访问另一处理器内存条上的“远程”内存则明显要慢。内存布局固有的这种“不均匀性”带来了一个重大挑战:当最基本的操作——访问数据——的成本并非恒定时,软件如何才能高效、可预测地运行?
本文深入探讨 NUMA 局部性原理,探索现代系统如何应对距离的暴政。我们将从硬件的物理限制出发,一路探寻为掌控这些限制而设计的复杂软件策略。在接下来的章节中,您将对系统性能的这一关键方面获得深刻的理解。
原理与机制 将解构 NUMA 的核心问题,审视操作系统用于管理它的基本策略,如线程布局、数据复制,以及在局部性和全系统负载均衡这一关键权衡之间取得平衡。
应用与跨学科联系 将揭示 NUMA 在整个计算领域产生的连锁反应,展示其对虚拟化、高性能计算、I/O 子系统乃至基础算法和数据结构设计的深远影响。
在入门计算机科学的纯净世界里,内存是一个简单、抽象的概念——一个由无数信箱组成的巨大、统一的阵列,每个信箱都可以即时访问。但物理世界并非如此井然有序。光速不是无限的,将数据从处理器传输到内存芯片的信号必须经过真实的物理距离。在像手机这样的单芯片系统上,这段路程短到难以想象,统一访问的假象得以维持。但当我们将规模扩大到计算领域的巨头——驱动我们数字世界的服务器——我们就会遇到一个基本事实。这些机器不是单一实体,而是硅片的联邦,通常由多个独立的处理器芯片(即插槽)组成,每个插槽都有自己的本地内存条。
想象一个庞大的专业厨房,里面有几个厨师工作站。每个工作站(一个插槽)都有自己的一组核心(厨师)和自己的本地冰箱(本地内存),里面存放着常用的食材。访问这个本地内存是快速而高效的。然而,还有一个供所有人共享的主储藏室。如果A工作站的厨师需要存放在B工作站冰箱里的食材,他们必须穿过整个厨房。这段路程需要时间。这次行程是一次远程内存访问,它不可避免地比伸手从本地冰箱取东西要慢。
这就是非均匀内存访问(NUMA)的本质。它不是一个 bug 或缺陷,而是在构建大型计算机时物理学和工程学不可避免的后果。访问内存所需的时间是不均匀的;它取决于处理器与内存条之间的物理距离。内存布局的这种“不均匀性”既是挑战也是机遇。一个不了解这种地理分布的程序将表现得不可预测,其速度取决于其数据恰好落在何处。但一个理解这种布局的程序——或者更重要的,一个操作系统——可以编排出一场美妙的计算交响乐,将线程和数据放在一起,以最大限度地减少这些代价高昂的跨厨房之旅。
操作系统(OS)扮演着厨房主厨或总管的角色,决定哪些厨师在哪个工作站工作,以及将食材存放在哪里。其目标是让整个厨房尽可能高效。让我们来探讨它的一些基本策略。
设想一条简单的装配线:一个生产者线程准备数据,一个消费者线程处理它。如果操作系统将生产者放在插槽 上,将消费者放在插槽 上,那么数据应该存放在哪里?一个常见且合理的默认策略是首次接触:数据的内存被分配在首次请求它的线程所在的插槽上。在这种情况下,插槽 上的生产者创建了数据,所以数据落在插槽 的本地内存中。生产者的工作速度很快。但是插槽 上的消费者现在必须为它需要的每一份数据执行一次远程访问。性能损失与它进行的远程访问次数成正比。解决方案简单而深刻:协同部署。一个智能的操作系统会将生产者和消费者线程及其共享数据都放在同一个插槽上,从而消除此交互的所有远程访问开销,并显著加快流水线的速度。
但对于像只读食谱一样被许多线程共享的数据该怎么办呢?假设插槽 和插槽 上的线程都需要读取同一个数据页,该数据页起始于插槽 。操作系统有三个主要选择:
哪种策略最好?这取决于访问模式。如果线程交替访问的次数很少,重复迁移页面的开销可能低于复制它的初始成本。但如果它们交替访问很多次,复制的一次性成本很快就会因消除了反复迁移的成本而被摊销。存在一个盈亏平衡点——一个交替次数 ——超过这个点,复制就成了明显的赢家。一个智能的操作系统可以监控访问模式并做出这种动态权衡,决定是传递书本更便宜,还是直接复印一份更划算。
协同部署和复制这些简单的秘诀在孤立的环境中效果很好。但真实的服务器是一个拥挤而混乱的厨房,有几十个线程在争夺资源。在这里,操作系统的工作变成了一场在相互冲突的目标之间进行的精妙平衡。
最基本的冲突之一存在于局部性与负载均衡之间。像 Linux 的完全公平调度器(CFS)这样的操作系统调度器力求公平,确保所有可运行的线程都能获得其应有的 CPU 时间份额。如果插槽 的工作负载过重,而插槽 有空闲的核心,公平原则要求将一个线程从 移动到 。但如果该线程的内存全都在插槽 上呢?这一移动改善了负载均衡,却破坏了内存局部性,可能导致线程即使独占一个核心运行也变得更慢。这就是 NUMA 调度的核心困境:一个全局“公平”的决策可能在局部是灾难性的。
如果管理不善,这种紧张关系可能导致灾难性的失败。想象一个调度器在积极地尝试平衡负载。它看到不平衡,于是采用推式迁移,主动将 12 个线程从过载的插槽 移动到空闲的插槽 。然而,这些线程仍然需要它们位于插槽 内存中的数据。突然之间,连接厨房工作站的走廊——插槽间互联结构——被远程内存请求淹没。12 个线程中的每一个都产生了每秒数 GB 的流量。这股数据洪流会使物理链路饱和,导致互连流量拥堵。整个系统慢得像爬行,不是因为 CPU 繁忙,而是因为通信路径堵塞了。一种更智能的方法,拉式迁移,允许空闲核心窃取工作,但如果它被限制在自己的插槽内拉取工作,它就能在本地维持负载均衡,而不会有跨插槽拥塞的风险。
此外,移动一个线程并非没有成本。当一个线程运行时,它会“预热”其所在插槽上的缓存,用它的工作集数据填充它们。将线程迁移到另一个插槽,就像把一个厨师调到一个全新的、冰冷的工作站。他所有精心布置的工具和食材都没了。线程会遭受一连串的缓存未命中——即冷缓存迁移成本——因为它痛苦地将自己的工作集重新取回到新的缓存中。一个明智的调度器将迁移视为一种代价高昂的最后手段。只有当它预期通过不等在长运行队列中而节省的时间大于它为冷缓存和远程访问所付出的性能代价时,它才会移动一个任务。
面对这种复杂性,现代操作系统不依赖单一的技巧。它部署了一套复杂的、多层次的策略,看起来非常像一场军事行动。
情报收集: 操作系统使用称为性能监控单元(PMU)的特殊硬件电路来监视线程。它测量缓存未命中等统计数据,以及至关重要的是,一个缓存未命中是由本地还是远程 DRAM 服务的。这使得它能够建立一个访问模式矩阵 ,该矩阵量化了线程 访问节点 上内存的频率。
战略规划: 有了这些数据,以及系统拓扑结构图(,从节点 到节点 的成本),操作系统就可以将线程布局任务构建成一个正式的优化问题。目标是找到一个线程到节点的分配方案,以最小化总预期远程访问成本,同时受限于没有节点过载的约束。这是一个经典的、被称为最小成本流或运输问题的问题,存在高效的算法来解决它。
战术执行(亲和性层级): 然后,操作系统使用分层方法执行其计划。
内存的非均匀性在整个操作系统中引起涟漪,与其他子系统产生迷人且常常是反直觉的交互。
考虑 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 系统调用,这是类 Unix 系统的基石,一个进程通过它创建自身的副本。为了高效,操作系统使用了一种名为写时复制(COW)的技巧。最初,父进程和子进程共享相同的物理内存页,并标记为只读。只有当其中一个尝试写入页面时,操作系统才会介入,为写入者创建一个私有副本,然后允许写入继续。
在 NUMA 世界里,这个看似简单的机制充满了危险。想象一个在节点 0 上的父进程 fork 了一个子进程,调度器将该子进程放在节点 1 上。共享页面位于节点 0 上。子进程执行数百次读取,所有这些都是远程且缓慢的。然后,它执行第一次写操作。COW 机制被触发。操作系统应该在哪里为子进程分配新的私有页面呢?一个幼稚的策略可能会将其放在原始节点,即节点 0 上。结果呢?这个子进程在其剩余的生命周期里都将背负着远程内存访问的负担。最优策略是首次接触本地分配:操作系统识别到写操作来自节点 1 上的子进程,并在节点 1 上分配新页面。这个简单的、具有局部性意识的决策确保了子进程未来对其私有数据的所有访问都是快速和本地的。
即使是像内存碎片——空闲内存被分割成小的、无法使用的块——这样的经典问题,也因 NUMA 而变得更糟。一个应用程序可能会为硬件设备请求一个大的、物理上连续的内存块(一个 DMA 缓冲区)。操作系统可能会发现所有节点上的空闲内存总和绰绰有余。然而,请求要求该块完全位于单一节点内。如果碎片化导致没有单个节点拥有足够大的连续空间,分配就会失败。NUMA 边界就像无法穿透的墙,阻止系统整合其空闲空间,有效地制造出碎片化的内存孤岛,并减小了系统能够提供的最大块大小。
对 NUMA 局部性的探索揭示了现代计算机的性能不仅仅关乎原始时钟速度。它是一场与物理学的精妙舞蹈,受制于距离的暴政。管理这一点需要操作系统不仅仅是一个资源分配器;它必须是一个智能的指挥家。通过观察、预测,并通过一个复杂的分层策略体系——从温和的推动到严格的强制执行——来采取行动,它在一个不均匀的硅片版图上指挥着一场由线程和数据组成的复杂交响乐。其美妙之处不在于单一、完美的解决方案,而在于那个适应性强、多方面、且深具原则性的系统,它努力让一个非均匀的世界尽可能地感觉像一个无缝的整体。
现在我们已经把机器拆开,理解了它那奇特、不均衡的内存,让我们再把它组装起来,看看当我们尝试使用它时会发生什么。我们已经了解了非均匀内存访问的原理——有些内存“近”而快,而另一些内存“远”而慢。这个简单的事实,这种对单一、统一内存池这一舒适幻觉的偏离,在整个计算世界掀起了涟漪。它迫使我们重新思考一切,从操作系统最深的角落到最宏大的科学模拟。其结果有时是灾难性的,常常是出人意料的,但总是美妙的,揭示了软件与硬件之间错综复杂的舞蹈。让我们踏上一段旅程,从系统的最底层开始,看看这一个理念是如何改变一切的。
操作系统(OS)是管理所有硬件资源的总 puppeteer(操纵者)。但当舞台本身不平坦时会发生什么?操作系统必须首先学会驾驭这个失衡的世界,然后才能期望指导运行于其上的应用程序。如果操作系统内核本身很笨拙,不断让自己的核心去够取远方内存节点上的数据,整个系统就会慢得像爬行一样。
因此,一个聪明的操作系统会以 NUMA 为念构建其内部结构。想一想它管理自己内存的方式,用于像文件描述符或网络数据包这样小而频繁使用的对象。它不是使用单一的全局内存池,而是在每个 NUMA 节点上维护这些对象的独立缓存,使用所谓的每节点 slab 分配器。当节点 A 上的一个 CPU 核心需要一个新的内核对象时,它会从本地缓存中获取一个,该缓存由节点 A 上的物理内存支持。这个简单的规则确保了内核自身的内务工作保持快速和本地,防止操作系统被自己绊倒。
这种意识必须延伸到外围设备——系统的眼睛和耳朵。想象一个高速网卡,数据在上面嗡嗡作响,物理上插在插槽 A 的主板上。它接收到的每一位数据都必须通过直接内存访问(DMA)放入内存。但是,如果等待这些数据的应用程序线程正在插槽 B 的一个核心上运行,其内存分配在节点 B 上呢?如果网卡天真地将数据放入应用程序在节点 B 的内存中,那么每一次数据包传输都需要一次缓慢、昂贵的跨插槽链路之旅。对于一台繁忙的服务器来说,这是一个灾难的配方。
最优策略是反直觉的:操作系统驱动程序应该强制网卡的所有内存缓冲区——它的描述符环和数据包池——都驻留在网卡的本地节点,即节点 A。这意味着来自网卡的 DMA 操作总是快如闪电且是本地的。现在,当节点 B 上的应用程序需要数据时,是 CPU 执行一次单一的远程读取。这是一种远比用持续不断的微小 DMA 传输淹没插槽间链路更好的权衡。这个教训是深刻的:将单一、高层次的任务(CPU 的数据请求)跨越慢速链路移动,通常比移动支持它的所有低层次琐碎通信(DMA)要好。
同样的逻辑也适用于当今快得惊人的存储设备,比如那些使用非易失性内存快递(NVMe)的设备。这些设备可以使用多个硬件队列同时处理成千上万的 I/O 请求。操作系统必须是一个狡猾的媒人,将系统的众多 CPU 核心分配给这些队列。一种天真的方法可能是让所有 CPU 使用所有队列,造成一场混乱的自由竞争。一个具有 NUMA 意识的操作系统会做一些更聪明的事情:它对硬件队列进行分区,为每个 NUMA 节点上的 CPU 分配一组本地队列。节点 A 上的核心只向同样位于节点 A 上的队列提交其 I/O 请求。这最大限度地减少了争用,并确保用于管理 I/O 的数据结构总是被本地访问,再次使系统的底层管道保持高效和快速。
如果说 NUMA 使物理世界变得复杂,那么虚拟化增加的间接层则可能将这种复杂性变成一场性能噩梦。在云计算的世界里,一台物理服务器托管着许多虚拟机(VM),而虚拟机监视器(hypervisor)——管理虚拟机的软件——扮演着操作系统的角色,但却是为其他操作系统服务的。
想象一下这个完全合理却又灾难性的场景。一个虚拟机监视器将一台虚拟机的虚拟 CPU(vCPU)固定在插槽 B 的物理核心上,而虚拟机的内存则从节点 B 的 RAM 中分配。这很好;虚拟机的内部世界是 NUMA 本地的。然而,为了给这台虚拟机提供超高速网络,我们使用设备直通来授予它对一个物理网卡的直接控制权。但这张卡恰好插在插槽 A 的一个插槽上。
结果是一场性能大灾难。每当网卡接收到一个数据包,它的 DMA 传输就必须从插槽 A 跨越到虚拟机在插槽 B 的内存。每当网卡需要用中断通知虚拟机时,该信号必须从插槽 A 跨越到插槽 B 上的 vCPU。数据路径和控制路径都被拉伸在缓慢的插槽间链路上。虚拟机一直处于需要跨越整个机器来获取其最关键资源的状态。
解决方案,当然是校准对齐。虚拟机监视器必须足够聪明,能够将虚拟机的 vCPU 和内存迁移到插槽 A,将处理器、内存和设备统一成一个快乐的本地大家庭。这种协同部署的行为可以立即提升性能,因为它消除了每一次 I/O 操作上的 NUMA 惩罚。
但如果虚拟机监视器能得到帮助呢?现代系统允许一种美妙的合作形式,称为半虚拟化。虚拟机内的客户操作系统知道自己的哪些数据是重要的,可以向虚拟机监视器传递“提示”。它可以提供一个局部性地图,基本上是说:“亲爱的虚拟机监视器,我的大部分重要工作都发生在您放置在物理节点 A 上的数据上。”有了这些知识,虚拟机监视器就可以智能地将虚拟机的 vCPU 调度到插槽 A 的物理核心上,从而修复 NUMA 的错位,并显著减少插槽间链路上的流量。
在高性能计算(HPC)领域,科学家们模拟从星系碰撞到蛋白质折叠的一切事物,从硬件中榨取每一滴性能都至关重要。在这里,NUMA 不仅仅是一个需要避免的麻烦;它是一个必须被积极利用的核心架构原则。
指导哲学变成了以数据为中心的计算。我们不再是让 CPU 决定做什么然后去取必要的数据,而是看数据在哪里,然后把计算任务发送到那里。考虑一个常见的操作:根据一个索引数组 I 更新一个大数组 Y 中分散位置的元素。如果 Y 数组被分区到两个 NUMA 节点上,一个天真的并行循环会让一个节点上的线程不断地向另一个节点的内存写入。一种具有 NUMA 意识的方法会首先重构问题。它将工作本身分成两个桶:一个用于所有要更新到节点 0 内存的更新,另一个用于所有要更新到节点 1 内存的更新。然后,第一个桶里的工作交给在节点 0 上运行的线程,第二个桶里的工作交给在节点 1 上运行的线程。这确保了所有昂贵的写操作都是本地的。
在工作和局部性之间取得平衡这个主题是并行程序员持续的挣扎。想象一下使用像 OpenMP 这样的编程模型来并行化一个循环。你有几种选择来安排循环迭代在你的线程间的调度:
static(静态)调度为每个线程分配一个固定的、连续的工作块。如果数据以同样的方式分区,这对 NUMA 局部性来说非常好。但如果工作不平衡——如果某些块的计算难度远大于其他块——那些分到简单块的线程会提前完成并空闲,而其他线程则在辛苦工作。dynamic(动态)调度将工作变成一个共享池,线程在空闲时随时抓取下一个可用的迭代。这实现了完美的负载均衡。然而,它对局部性来说可能是一场灾难,因为来自插槽 A 的线程可能会抓取一份数据在插槽 B 上的工作。guided(引导)调度提供了一种优雅的折中方案。它开始时给线程分配大块(促进局部性),然后逐渐减小块的大小,在最后用小块来平衡剩余的工作。对于许多存在负载不平衡的问题,这种混合方法被证明是最快的,它巧妙地在保持所有核心忙碌和保持其内存访问本地化之间进行了权衡。操作系统调度器也面临类似的困境。考虑一个科学计算代码,其中一个“热点”存在于数据的一部分,计算非常密集。如果我们使用硬亲和性将线程永久地固定在它们主要数据分区所在的 NUMA 节点的核心上,我们会得到很好的局部性。但分配到热点的线程将会过载,造成瓶颈。如果我们使用软亲和性,操作系统被允许迁移线程。例如,它可以从一个安静的节点移动一个空闲的线程来帮助处理热点。这有助于平衡负载,但被迁移的线程现在将为其所有的内存访问支付 NUMA 惩罚。哪种更好?答案取决于不平衡的严重程度与远程访问的成本。有时,即使有 NUMA 带来的减速,从远程节点调来额外的援手是更快完成工作的唯一方法。
NUMA 的影响一直延伸到基础算法的设计,并一直延伸到高级编程语言。
在最底层,NUMA 局部性与保持所有处理器缓存同步的缓存一致性协议相互作用。想象一下在一个巨大的数组中进行并行搜索。数组被分区,每个核心搜索自己的段落。因为没有核心触碰另一个核心的数据,数组读取是完全本地的,并且不产生跨插槽的一致性流量。每块数据对应的缓存行都以 Exclusive(独占)状态被取入本地核心的缓存中。但是当一个线程找到目标时会发生什么呢?它必须通过翻转一个共享的终止标志来通知所有其他线程。这个单一的写操作是一个广播事件。它触发一次请求所有权的读取(Read-For-Ownership, RFO),向所有其他拥有该标志副本的核心发送跨插槽链路的无效化消息。那些核心在下一次检查时,会遭受一次缓存未命中,并且不得不从远程节点重新获取标志的新值。这说明了并行性的两面性:可以完美扩展的“易于并行”部分,以及那个引发大量跨插槽通信的同步点。
即使是使用像 Java 或 C# 这样“安全”的高级托管语言的程序员也不能忽视 NUMA。这些语言使用垃圾回收器(GC)来自动管理内存。当 GC 运行时,它通常会采用“世界暂停”(Stop-The-World, STW)的暂停方式,即所有应用程序线程被冻结,一组 GC 工作线程活跃起来清理内存。目标是使这个暂停尽可能短且可预测。为了实现这一点,运行时系统必须使用硬亲和性来固定 GC 线程。这可以防止操作系统迁移它们。它们应该被固定在应用程序大部分内存(堆)所在的 NUMA 节点的核心上,理想情况下是那些不经常被硬件中断打扰的核心。这个策略既避免了远程内存访问的性能惩罚,也避免了因抢占而产生的不可预测的延迟,从而导致更短、更一致的 GC 暂停。
基本的数据结构也需要 NUMA 感知的设计。如果你有一个庞大的树状数据结构,比如数据库中的搜索树,它不可避免地会跨越多个 NUMA 节点。从根到叶的遍历可能需要多次在插槽之间跳转。一个聪明的策略是,在每个 NUMA 节点的本地内存中复制树的顶层——即每次遍历都会访问到的树干和主分支。在某个深度以下,子树再被分区并分配给特定的节点。这需要为复制付出一些额外的内存成本,但它保证了每次搜索的初始部分都是快速和本地的,并且在整个遍历过程中最多只发生一次昂贵的跨插槽跳转。
要真正领会 NUMA 局部性的无所不包的本质,让我们来看一个宏大的挑战性问题,比如一个模拟波在地壳中传播的大规模地球物理学模拟。为了在现代超级计算机上高效运行,必须精心策划一场优化的交响乐,而 NUMA 意识是其统一的主题。
在最高级别(MPI): 全球地球域不是被切成薄片或长条,而是切成近似立方体的三维块。这种分解最小化了表面积与体积的比率,从而最小化了在不同节点上运行的 MPI 进程之间需要交换的数据量。
在进程级别: 我们智能地映射 MPI 进程。如果一个计算节点有两个插槽,我们就在每个插槽上放置一个进程,给每个进程自己的 NUMA 域。
在线程级别(OpenMP): 在每个 MPI 进程内部,我们使用多个线程。这些线程以紧凑亲和性被固定,意味着它们都被限制在它们父进程的 NUMA 插槽的核心上运行。“首次接触”策略确保当这些线程分配它们那部分模拟网格时,内存物理上被放置在它们的本地节点上。
在算法级别: 更新网格的循环不是简单的线性扫描。它们被分块或“平铺”,使得一小块网格的工作集能装入单个核心的快速 L2 缓存中。代码在移动到下一块之前完全处理完这一块,从而最大化数据重用并最小化对主内存的访问。
在 I/O 级别: 当模拟周期性地将其状态保存在一个巨大的检查点文件中时,它不是让成千上万的线程独立地写入文件。相反,它使用集体的 MPI-I/O。每个节点上几个指定的“聚合器”线程从所有其他本地线程收集数据,并向并行文件系统执行大的、连续的写入,这是存储系统喜爱的模式。
这个完整的、分层的策略——从全局问题的形状到单个核心上的缓存分块——证明了 NUMA 原理的力量和普遍性。它表明,在规模上实现性能不是靠单一的技巧,而是靠一个整体设计,其中软件栈的每一层都与底层硬件的物理现实和谐共存。“并非所有内存生而平等”这一简单事实,迫使我们遵守一种纪律,追求一种优雅,从而更深刻地理解计算本身。