
在对计算速度不懈的追求中,几乎没有哪个瓶颈比内存访问更根本。从海量数据库、人工智能模型到科学模拟,现代应用程序的数据密集程度前所未有,给 CPU 与物理内存之间的桥梁带来了巨大压力。这种性能差距不仅关乎内存带宽,更与操作系统管理的复杂地址转换机制紧密相连。当一个应用程序的内存足迹超出了系统高效映射它的能力时,性能就会戛然而止。
本文深入探讨一种旨在解决这一问题的强大优化技术:巨页(huge pages)。通过从根本上改变内存管理的单位,巨页提供了一种显著提升性能的方法,但也引入了一系列复杂的权衡。为了理解这项技术,我们将首先探讨其核心概念。《原理与机制》一章将揭开虚拟内存系统的神秘面纱,解释转译后备缓冲器(TLB)的关键作用,并详细说明使用更大的页面尺寸如何能极大地提高效率。随后,《应用与跨学科联系》一章将审视在虚拟化、云计算到高性能科学研究等不同领域使用巨页的实际影响和挑战。
在计算世界中,最优雅且强大的幻象之一便是虚拟内存。你运行的每个程序都仿佛独占了整个计算机内存,这片内存广阔、纯净且连续。当然,这只是一个精心构建的假象。实际上,物理内存(你机器里的 RAM 芯片)是一个混乱的共享空间,许多不同程序的片段散落其中。操作系统(OS)与一个名为内存管理单元(MMU)的特殊硬件协同工作,扮演着一位伟大的魔术师,将你的程序使用的整洁的虚拟地址,转换成数据实际所在的杂乱的物理地址。
这种转换是通过将内存切割成固定大小的块——即页面(pages)——来完成的。这就像一本书,每一页可以物理上存放在图书馆的任何地方,但有一个主索引——页表(page table)——告诉你去哪里找第 1 页、第 2 页等等。每当你的处理器需要获取一条指令或一段数据时,它都必须执行这种转换:从虚拟页号到物理页面位置。如果每次内存访问都必须查阅位于相对较慢的主内存中的主页表,我们的计算机将会陷入停顿。其性能损失将是灾难性的。
为了避免这场灾难,处理器设计师加入了一项至关重要的优化:一个位于 CPU 芯片上,体积小但速度极快的内存,称为转译后备缓冲器(Translation Lookaside Buffer),或简称 TLB。TLB 是一个缓存,但它缓存的不是数据,而是*地址转换*。它会记住最近使用过的虚拟到物理页面的映射。当你的程序访问一个内存地址时,CPU 首先检查 TLB。如果转换信息在那里(即 TLB 命中),查找几乎是瞬时的,程序继续全速运行。如果信息不在那里(即 TLB 未命中),CPU 必须通过主内存执行一次缓慢、多步骤的“页表遍历”,以找到正确的转换,然后才能访问数据。因此,任何高性能系统的目标都很简单:最大化 TLB 命中率。
但在这里我们遇到了一个根本性的瓶颈。TLB 很小。它只能容纳少量条目——也许几十个或几百个,而不是数百万个。这个限制引出了一个关键概念:TLB 覆盖范围(TLB reach)。TLB 覆盖范围是 TLB 在任何时刻能够映射的总内存量。它是一个简单的乘积:
从科学模拟、数据库系统到你开着许多标签页的网页浏览器,现代应用程序拥有巨大的内存足迹,或称“工作集”,可以轻松跨越数吉字节。让我们来看一个典型的系统。几十年来,标准页面大小一直是 KiB。如果一个 TLB 有 256 个条目,它的覆盖范围仅为 ,即 1 MiB。如果你的应用程序正活跃地使用 100 MiB 的数据,其工作集就是 TLB 覆盖范围的一百倍。结果是一场性能噩梦。程序不断访问其转换信息不在 TLB 中的页面,导致 TLB 未命中风暴。
如果我们不能轻易地让 TLB 变得更大(因为那样会使其变慢且更耗电),那么我们还能在公式中调整哪个杠杆呢?答案是页面大小。
这就是巨页背后那个优美而简单的想法。如果除了使用 KiB 的页面,操作系统还可以使用更大的页面,比如说 MiB,会怎么样?让我们重新审视 TLB 覆盖范围的计算。一个 MiB 的页面比一个 KiB 的页面大 倍()。通过使用一个 MiB 的巨页,一个 TLB 条目现在可以映射一个大 倍的内存区域。对于工作集庞大的应用程序来说,这极大地增加了内存访问在 TLB 中找到其转换的概率,从而大幅减少了代价高昂的未命中次数。
当然,无论是在物理学还是在计算机科学中,都没有免费的午餐。巨页的主要缺点是一个叫做内部碎片(internal fragmentation)的问题。当操作系统分配内存时,它必须以页面为单位进行。如果一个程序请求少量内存,比如 KiB,操作系统必须给它一个完整的页面。对于 KiB 的页面,它会分配三个页面(总共 KiB),只有 KiB 会被浪费。但如果操作系统被迫为这个小分配使用一个 MiB 的巨页,那么将有惊人数量的内存——超过页面大小的 ——被分配但未使用。这就像为了邮寄一封信而不得不买下一个完整的集装箱。在 TLB 性能和内存使用效率之间的这种权衡,是操作系统必须管理的核心动态。
现代操作系统足够复杂,不会强迫用户做出“全有或全无”的选择。它们采用了一套丰富的策略和机制来两全其美,在有益时使用巨页,在不适用时使用小页。
一个常见的策略是混合使用不同大小的页面。假设一个程序的工作集为 MiB。操作系统可以采取一种贪心策略:尝试用 MiB 的巨页覆盖尽可能多的工作集,因为它们对 TLB 来说最高效。然而,系统可能会有限制;例如,可能只有一部分内存有资格用于巨页,或者硬件本身可能只有有限数量的 TLB 条目为巨页保留。在一种可能的情景下,操作系统可能会使用 个巨页来映射 MiB 的工作集。剩下的 MiB 则由 个小的 KiB 页面来覆盖。这种混合方法寻求一种平衡,用巨页处理大而连续的工作集的主体部分,同时保留小页的灵活性以处理剩余部分或较小的分配。
操作系统如何决定何时使用巨页?主要有两种方法。第一种是显式的:应用程序开发者如果知道他们的程序会受益,可以使用像 Linux 中的 hugetlbfs 这样的特殊 API,从预先配置的巨页池中明确请求内存。这提供了最大的控制权,但需要手动操作。
第二种,也是可以说更优雅的方法,是透明巨页(Transparent Huge Pages, THP)。在这里,操作系统变成了一个主动的侦探。它会自动尝试为应用程序使用巨页,而程序员甚至无需知情。当一个应用程序开始访问内存时,它最初会触发标准 KiB 页面的缺页中断。操作系统缺页处理程序会跟踪这些事件。如果它注意到一种模式——许多缺页中断发生在一个 MiB 对齐的内存区域内——它就会推断该应用程序可能正在使用一个大而密集的内存区域。此时,它可以尝试将这组小页面“提升”为一个单一的巨页映射。
这个提升过程是一项工程奇迹,但也充满了风险。如果在操作系统考虑提升时,同一程序中的另一个线程更改了该 MiB 区域中一小块内存的保护权限(例如,使用 mprotect 系统调用使其变为只读),该怎么办?如果一些小页面是先前 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 调用产生的“写时复制”页面,又该怎么办?操作系统不能简单地创建一个具有统一权限的巨页。一个健壮的 THP 实现必须极其谨慎。它必须锁定相关的数据结构,仔细验证整个 MiB 范围是否属于一个具有兼容权限的单一内存区域,并确保没有现有的小页面存在冲突状态。如果任何检查失败,它必须安全地放弃提升尝试,并退回到使用小页面。这种验证与同步的复杂舞蹈对于维护虚拟内存幻象的正确性至关重要。
巨页的生命是动态的,由操作系统通过创建、提升,有时还有降级的循环来管理。
规整与创建: 巨页需要一种稀缺资源:大块的、连续的空闲物理内存。随着系统运行,其内存趋向于碎片化——小的分配和释放操作将空闲内存切割成类似瑞士奶酪的状态。为了解决这个问题,操作系统会运行一个名为内存规整(memory compaction)的后台进程。这个进程会小心地重定位现有的小页面,将它们整理在一起,就像解决一个滑块拼图一样,以开辟出足够大的连续空闲块来用作巨页。内核内部持续进行着一场战斗:小分配产生的碎片化速率试图破坏巨页的可用性,而规整的速率则努力创造它。操作系统必须智能地调整规整的频率,以维持健康的空闲巨页供应,同时又不过多地消耗 CPU 时间在规整过程本身上。无法找到连续块是一个真实存在的风险;如果一次分配因碎片化而退回到使用小页面,预期的性能增益就会降低,因为应用程序在其内存的那一部分将遭受更高的 TLB 未命中率。
降级与抖动: 当应用程序的内存访问模式改变时会发生什么?一个曾经被密集访问的区域可能会变得稀疏。将这个区域保持为巨页映射会因内部碎片而造成浪费。为了处理这种情况,操作系统可以降级(demote)或将一个巨页拆分成 512 个独立的小页面。内核可以通过周期性地检查与子页面关联的硬件“已访问”位来检测这种稀疏性。
这引入了一个新的挑战:抖动(thrashing)。如果系统过于激进,一次暂时的访问平静可能会触发一次代价高昂的降级,而片刻之后当访问再次变得密集时,又会紧跟着一次代价高昂的重新提升。为了避免这种情况,操作系统使用了控制论的原理。其中之一是滞后效应(hysteresis):为提升和降级使用不同的、不重叠的阈值。例如,仅当超过 80% 的子页面处于活动状态时才进行提升,但仅当少于 20% 处于活动状态时才进行降级。这个“死区”可以防止快速振荡。另一种技术是通过使用持久性(persistence)要求(模式必须连续保持几个检查周期)或通过计算平滑趋势,如指数加权移动平均(EWMA),来过滤嘈杂的瞬时访问数据,然后再做出决定。这些技术确保操作系统响应的是行为的持久变化,而不是暂时的噪声。
这整个复杂的机制,从 TLB 覆盖范围到内存规整和降级启发式算法,展示了现代操作系统的深奥之处。它是一个看不见的性能引擎,总是在幕后默默工作。这是一个充满优美权衡的系统,其中“把页面变大”这个简单的想法,演变成了一场关于预测、测量和控制的复杂而动态的舞蹈——所有这一切都是为了支撑我们应用程序所依赖的那个无缝、快速的无限内存幻象。
理解了我们的计算机如何使用虚拟地址“地图”来导航其内存的原理后,我们可能会倾向于认为巨页只是一个简单的技巧——一种追求速度的巧妙优化。但这就像说望远镜只是一个让东西看起来更大的技巧一样。事实,正如科学中常有的情况,要远为优美和深刻。改变我们地图的比例不仅改变了我们的速度,它还改变了可能性。它迫使我们面对新的、微妙的挑战,而在解决这些挑战的过程中,它将计算领域中看似不相关的分支联系起来,从在你桌面上运行电子游戏和人工智能,到在超级计算机上模拟地球气候。
让我们踏上一段旅程,看看“在内存地图中使用更大的页面”这一个想法,是如何在技术世界中激起涟漪的。
从本质上讲,巨页带来的性能提升源于减少了转译后备缓冲器(TLB)的工作量——这是处理器用于记录最近地址转换的微小但至关重要的“备忘单”。想象一个程序需要访问遍布内存各处的大量小数据片段。这就像一个快递员要去不同社区的数百个房子送信。如果快递员的地图一次只显示一条街道(一个基本页面),他会不停地停下来加载新的地图区域。这是一种稀疏内存布局,对 TLB 来说是一场噩梦,会导致大量的未命中。现在,想象一个程序,所有数据都紧密地打包在一个连续的块中,一个密集数组。这就像一个快递员要给一条长长的高速公路上的每家每户送货。他只需加载一次地图,就能在很长一段时间内使用。
巨页赋予了我们一种能力,即使是有些分散的布局,也能像对待一条单一的大高速公路一样处理。通过使用一个 的巨页而不是一个 的基本页面,我们实际上是在告诉 TLB 加载整个地区的地图,而不仅仅是一条街道的。对于需要接触许多不同内存位置的工作负载来说,这是一个颠覆性的改变。我们可能只会有几次 TLB 未命中,而不是数千次,从而极大地加速了程序。
这种原始力量不仅仅是学术上的好奇心。思考一下驱动现代人工智能的大型语言模型(LLM)。为了运作,这些模型必须将巨大的参数表——有时大小达到数吉字节——加载到内存中。当你向 AI 提问时,推理过程可能需要从这个巨大表格中的数百万个不同位置读取数据。使用巨页来映射这些数据意味着处理器花在查找转换上的时间更少,而花在实际计算上的时间更多。当然,这里有一个权衡:以不可分割的 大块预留内存可能灵活性较差,并导致空间浪费,这是我们必须权衡的成本,以换取更少的页表条目和更快的查找速度。
如果巨页那么好,为什么我们不把它用于所有事情?故事在这里变得有趣了。许多现代操作系统都有一种称为透明巨页(THP)的机制,它就像一个热情的助手,自动尝试寻找连续的 页面,并将它们“提升”成一个单一的 巨页。
对于具有可预测、顺序内存访问的工作负载——比如流式传输一个大型视频文件——这个助手就是英雄。它无缝地提供了巨页的性能优势,而无需程序员动一根手指。但如果工作负载是混乱的,内存访问模式随机跳跃,就像一个追逐指针的数据库呢?在这种情况下,我们热情的助手可能会变成恶棍。它可能会花费巨大的精力尝试移动内存(规整),以创建一个连续的 块,从而暂停应用程序并引入不可预测的延迟。对于那些对一致、低延迟至关重要的应用程序来说,这些规整造成的停顿可能是毁灭性的。事实上,对于这样的工作负载,完全禁用巨页可能会产生更好、更可预测的性能,即使平均吞吐量略低。
在现代云环境中,这种紧张关系被放大了,因为应用程序在具有严格内存限制的容器内运行。在这个有限的空间里,助手疯狂的规整尝试可能导致应用程序在其内存上限附近挣扎,触发代价高昂的内存回收操作和性能尖峰。这里的解决方案不是一把大锤,而是一把手术刀。程序员不是为整个系统打开或关闭 THP,而是可以使用像 madvise 这样的系统调用来给操作系统一些提示。他们可以将大型、稳定的数据结构(如一个长寿命的堆)标记为 MADV_HUGEPAGE 以获得好处,同时将高度动态、短寿命的内存区域标记为 MADV_NOHUGEPAGE,告诉那个热情的助手不要管它们。这种细致的、由应用程序引导的方法是驯服 THP 这头野兽并榨取最大性能的关键。
改变我们地图的页面大小所带来的后果,远远超出了单个应用程序的性能。这个决定会在我们计算系统的整个架构中回响。
虚拟化: 虚拟机(VM)是一个“地图的地图”的世界。VM 内部的客户机操作系统有自己的虚拟地址映射,它认为这个映射指向物理硬件。但这个“客户机物理”内存本身是另一个由宿主机 hypervisor 管理的虚拟映射。一次内存访问可能需要两级查找:一次是为客户机的地图,另一次是为宿主机的地图。这种双层页表遍历是开销的主要来源。巨页提供了一种惊人的简化。通过在宿主机的映射(第二级)中使用巨页,我们可以用一个单一、高效的转换覆盖大片“客户机物理”内存区域,从而有效地缩短了代价高昂的页表遍历,并使虚拟化效率大大提高。
多处理器系统: 考虑一台拥有多个处理器插槽的大型服务器,每个插槽都有自己的本地内存库。这是一种非统一内存访问(NUMA)架构。访问本地内存速度快;访问连接到另一个处理器的内存则速度慢。一个采用“首次接触”策略的操作系统会巧妙地将内存页面分配给首先请求它的处理器。对于小的 页面,这工作得很好,将数据放在其使用者附近。但当我们使用一个 的巨页时会发生什么?如果两个处理器需要共享该巨页内的数据,整个页面必须分配给其中一个。这意味着另一个处理器现在被迫对其在该页面内的所有工作进行缓慢的远程访问。这种现象,一种页面级别的“伪共享”,是一个优美的例子,说明了一个尺度的优化如何在另一个尺度上造成瓶颈。
存储与文件系统: 巨页的原则甚至延伸到我们与存储交互的方式。随着超高速持久性内存的出现,像直接访问(DAX)这样的技术允许我们将一个文件直接映射到我们的地址空间,绕过旧的页缓存。要用巨页来实现这一点,需要一场对齐的交响乐。虚拟地址、文件内的偏移量以及存储设备上的物理位置必须全部与巨页大小完美对齐。如果任何一部分错位,优化就会失败。这表明,从最高层的软件一直到底层的物理硬件,都必须尊重连续性和对齐的原则。
与任何强大的工具一样,巨页的引入也带来了新的、意想不到的挑战,推动工程师们设计出越来越巧妙的解决方案。
一个有趣的冲突出现在内存安全工具上。内存清理工具(Memory sanitizers)通常通过在分配区域的两侧放置未映射的“保护页”来工作。任何访问这些页面的尝试都会触发一个错误,从而捕获越界错误。这对于细粒度的 页面来说是完美的。但是你无法在一个单片的 巨页内部放置一个微小的未映射保护页,而不破坏它并失去其好处。一个天真的解决方案?用两个完全未映射的巨页作为保护,包围分配的巨页。这“行得通”,但代价是浪费数兆字节的虚拟地址空间,甚至可能浪费物理内存,仅仅为了保护一个小小的分配,这展示了粒度冲突的滑稽而又昂贵的后果。
操作系统本身也必须变得更智能。当内存不足时,操作系统必须驱逐页面。驱逐整个巨页看起来很简单,但如果其 512 个组成基本页面中只有一个是真正的“热点”(频繁使用),该怎么办?一个复杂的页面置换算法会查看巨页内部,根据其内容的“热度”对其进行评分,并可能选择将其拆分(降级),而不是驱逐一个包含一个关键数据但大部分是“冷”的巨页。
这些挑战和解决方案在高性能计算(HPC)领域表现得最为明显。想象一个大规模的科学模拟——比如模拟地震后的地震波——运行在一台拥有数千个处理器的超级计算机上。每个处理器处理一个巨大的共享数据集的一小部分,该数据集为了速度而被内存映射。由于问题的物理特性,每个处理器的访问模式都是稀疏的,在共享文件中跳跃很长的距离。结果是一场完美风暴:
在这个宏大的挑战中,简单的内存映射方法惨遭失败。解决方案需要对 I/O 策略进行彻底的重新思考。程序员必须要么重构他们的代码,使其在适合 TLB 覆盖范围的分块数据上工作,要么更常见地,放弃直接映射,转而使用像 MPI-IO 这样的专用库。这些库充当总协调员,收集所有小的、分散的写请求,并智能地将它们重组为对文件系统的几次大的、连续的写入。正是在这里,在我们计算能力的绝对极限处,我们看到了全貌:巨页不是万能的灵丹妙药,而是一台复杂机器上的一个强大旋钮,必须与算法、系统软件和硬件架构协同调优,才能实现真正的性能。
从一个简单的加速,到一个复杂的权衡之舞,巨页的故事就像是计算本身故事的一面镜子。它告诉我们,没有什么可以替代对基本原理的理解,真正的优雅不在于盲目应用规则,而在于巧妙地驾驭支配我们数字世界的原则。