
在追求更快计算速度的道路上,人们的目光常常聚焦于日益强大的处理器。然而,在这场竞速中,一个沉默的伙伴——内存系统,却常常决定着真正的进步速度。计算机“思考”的速度与它获取“思考”所需数据的速度之间的差距日益扩大,已经形成了一个关键瓶颈,影响着从科学研究到日常应用的方方面面。本文将直面这一根本性挑战,探讨为何看似强大的系统常常因为内存访问的限制而表现不佳。
我们将揭开内存性能这个复杂世界的神秘面纱。在第一部分“原理与机制”中,我们将阐述决定性能的核心概念,例如计算受限任务与内存受限任务的关键区别、用于性能分析的优雅的 Roofline 模型,以及延迟、带宽和缓存的复杂作用。在这一理论基础之上,“应用与跨学科联系”部分将展示这些原理在真实世界场景中的具体表现,从科学模拟、GPU 编程到大数据的挑战。我们的旅程将从建立对现代计算核心矛盾——计算与数据通信之间微妙平衡的直观理解开始。
想象一下,你有一位能以闪电般速度切菜的大厨,一位真正的烹饪天才。但如果他的厨房助手一次只能给他拿来一个洋葱,并且还得步行到一个街区外的仓库去取,那会怎么样?无论这位大厨有多快,沙拉的制作效率都会惨不忍睹。简而言之,这就是现代计算的核心矛盾:计算与通信之间永恒的博弈。计算机处理器就是我们的大厨,每秒能执行数十亿次计算。内存系统则是厨房助手,负责获取处理器所需的数据(即食材)。任何任务的整体性能,不仅取决于处理器“切菜”的速度,还取决于我们能否有效地为其供应数据。
让我们来做一个物理学家钟爱的思想实验。想象一下,我们用一个假想的、未来的处理器替换掉计算机的处理器。这个新的 CPU 速度无限快,你给它的任何计算都耗时为零。然而,为了让事情更有趣,我们还要对内存系统做一个改动:这台未来机器完全没有片上缓存。缓存是一个紧邻处理器的小型、极快的内存缓冲区,就像厨师的私人调料架。我们的机器没有调料架;每一个食材,无论多小,都必须从主内存仓库(RAM)中获取,而在我们的实验中,主内存的速度与今天的硬件相同。
当我们在这样的机器上运行一个复杂的科学模拟程序时会发生什么?比如,一个涉及密集矩阵乘法的密度泛函理论(DFT)代码。你可能会认为,有了无限快的处理器,程序会瞬间完成。但现实恰恰相反,代码的运行速度很可能会慢得多。为什么?因为处理器尽管拥有无限的速度,却几乎把所有时间都花在了等待上。等待数据。它进行计算所需的每一个数字,都需要长途跋涉到主内存去取。代码中那些曾经受限于处理器速度的计算密集型部分,现在完全被内存速度所束缚。无限的处理能力毫无用处,因为“厨房”总是空的。
这个思想实验揭示了最基本的性能原理:一个程序的性能总是受限于瓶颈,而性能由系统中最慢的部分决定。性能存在两大上限:计算上限(你能多快地计算)和内存上限(你能多快地供应数据)。你的应用程序的性能,取决于这两个上限中较低的那个。
为了从直觉转向更严谨的理解,我们可以使用一个非常简单而强大的概念工具,名为 Roofline 模型。它为“我的应用程序是计算受限还是内存受限?”这个问题提供了一个可视化的答案。该模型仅建立在三个关键要素之上。
首先是峰值计算性能,我们称之为 ,以每秒浮点运算次数(FLOP/s)为单位。这是“计算上限”,即我们处理器的最大理论速度。在我们的性能图上,它是一条水平线;你的计算速度绝不可能超过这个值。
其次是峰值内存带宽,我们称之为 ,以每秒字节数(bytes/second)为单位。这并非一个硬性上限,而是一个斜率。它告诉我们数据从内存中移出的最大速率。要进行任何计算,你都需要数据。所需数据越多,获取时间就越长,这便限制了你的性能。
第三个,也是最有趣的要素是算术强度,记为 。这是你的算法的属性,而非机器的属性。它是执行的总浮点运算次数与从主内存移动的总数据字节数之比。 算术强度高的算法是“节俭”的;它每获取一个字节的数据就能进行大量计算。矩阵乘法就是一个经典的例子。算术强度低的算法则是“数据饥渴”的,它不断需要新数据,比如对一个长向量的元素求和。
Roofline 模型指出,可达到的性能 是计算上限和内存上限中的较小者: 请注意,受内存限制的性能 是一条斜率为 的直线,随着算术强度 的增加而上升。这条斜线与平坦的计算上限相交处有一个关键点。这个点被称为机器平衡点或临界算术强度,。我们可以通过令两个极限相等来找到它:,由此得出 。
这个单一的数字 揭示了机器本身的特性。对于一个峰值吞吐量为 GFLOPS、内存带宽为 GB/s 的处理器,其临界值为 FLOP/byte。如果你的算法算术强度 ,它就是内存受限的;其性能受到倾斜的内存上限的限制。提升其性能的唯一方法是增加带宽,或者更巧妙地,增加其算术强度。如果你的算法 ,它就是计算受限的;它处于平坦的计算上限之下,其性能仅受处理器原始能力的限制。
到目前为止,我们讨论了带宽——数据流动的速率。但在我们的故事中,还有另一个同等重要的角色:延迟。如果带宽是高速公路的宽度,那么延迟就是一辆车从入口行驶到出口所需的时间,即使高速公路是空的。主内存(DRAM)有一个奇特的特性:几十年来,其带宽大幅增加,但其延迟的改善却慢得多。从 DRAM 访问数据需要时间——大约在几十到几百纳秒的量级。对于一个时钟周期仅为纳秒几分之一的现代处理器来说,这简直是永恒。这就像我们的大厨为了那一个洋葱要等上整整一个小时。
处理器如何应对这深渊般的延迟?它不会等待。它会做些别的事情。如果处理器需要从内存中获取一块数据,它会发出请求,然后不会停顿,而是转而处理另一个不依赖该数据的任务。当数据最终到达时,它再切换回来。这种能够同时拥有多个“在途”内存请求的能力被称为内存级并行(MLP)。
有一个优美而深刻的关系支配着这种行为,它源于排队论领域,被称为 Little's Law。该定律指出,一个系统中的平均项目数()等于平均到达率()乘以项目在系统中花费的平均时间()。对于我们的内存系统,这可以转化为: 为了达到峰值内存带宽 ,我们需要以一定的速率发出请求。如果每个请求获取 字节,那么所需的速率是 。将此代入 Little's Law,我们得到完全隐藏内存延迟并使带宽饱和所需的最小 MLP: 对于一个典型的系统,若 GB/s, ns,且缓存行大小 字节,所需的 MLP 为 。这意味着处理器必须始终保持至少 个独立的内存请求在途,才能达到其宣称的峰值带宽。如果一个单线程程序在等待内存时找不到 件独立的事情来做,它就无法达到峰值带宽。其性能将不是受限于带宽,而是受限于延迟。
这导致了一个关键的区别:并非所有内存受限的应用都是一样的。在一个场景中,我们分析了一个数值核心,发现其持续内存带宽仅为机器峰值能力的约 。这是一个典型的延迟受限应用的标志。这就像一条 10 车道的高速公路上只有一辆车。高速公路有充足的容量(带宽),但性能却由那一辆车的行驶时间(延迟)决定。相比之下,一个正在流式传输大量数据并充分利用内存总线的应用是带宽受限的。理解这一差异对于优化至关重要:对于延迟受限的代码,你必须专注于减少或隐藏内存延迟;而对于带宽受限的代码,你必须专注于减少传输的数据总量。
我们已经看到主内存速度很慢,我们需要并行来隐藏其延迟。但如果我们能完全避免访问主内存呢?这就是内存层次结构的魔术。现代处理器不仅仅有一个单一的“内存仓库”;它们有一系列更小、更快、更近的“储藏室”,称为缓存(L1、L2、L3)。
缓存之所以有效,是因为大多数程序都具有一个基本属性:局部性原理。
缓存就是为利用这一点而设计的。当处理器请求数据时,它获取的不仅仅是那一个字节。一整块相邻的数据,称为缓存行(通常为 64 字节),会被带入缓存。如果程序表现出良好的局部性,它的下一个请求将是针对已在快速缓存中的数据,从而产生一次“缓存命中”。这避免了漫长而痛苦的主内存之旅。
缓存真正的魔力在于它们从根本上改变了算法的算术强度。请记住, 定义为每字节从主内存传输的浮点运算次数。通过将数据存储在缓存中并重用它,我们可以在没有任何主内存流量的情况下执行许多计算。这就是“缓存感知”编程的目标,通常使用诸如分块或平铺(tiling or blocking)之类的技术。
我们有时可以在实践中看到这种惊人的效果。考虑一个求解器算法,其理论上的算术复杂度为 。我们期望其运行时间与问题规模 的平方成正比。然而,在真实机器上测量时,发现其运行时间按 的比例增长。这似乎不合逻辑!运行时间的增长速度怎么可能慢于操作次数?答案就在于内存系统。这种亚二次方的扩展意味着系统是内存受限的,并且总内存流量按 的比例增长。这是一个有效的缓存分块策略的标志。随着问题规模 的增长,算法在缓存内重用数据的效率越来越高,从而增加了其有效算术强度并“弯曲”了性能曲线。
理解计算、缓存和内存之间的这种微妙平衡是一回事;操控它则是另一回事。有时,一个理论上看起来很棒的优化,在实践中可能会产生灾难性的后果。
想象一位程序员正在开发一个复杂的 GPU 内核。为了暴露更多的指令级并行(ILP),他们决定激进地展开一个循环。这是一个标准的编译器优化,它将多个循环迭代平铺成一条直线,为处理器提供更多可独立执行的指令。问题是,这也急剧增加了需要同时“存活”的变量数量,从而增加了寄存器压力。寄存器是可能的最快存储,甚至比 L1 缓存还快,但它们的数量非常有限。
在某个这样的场景中,这种激进的展开将每个线程的寄存器使用量从 64 个增加到 128 个。虽然这并未违反单个线程的硬件限制,但它减少了可以在单个多处理器上并发运行的线程数量。更具灾难性的是,编译器因寄存器耗尽,被迫将它们溢出。溢出意味着将寄存器的内容临时存储到……你猜对了,主内存中。
后果是毁灭性的。原始内核是内存受限的,算术强度为 FLOP/字节。而“优化”后的版本,由于加载和存储溢出寄存器所产生的持续流量,其每次迭代的总内存流量从 20 字节跃升至 52 字节。其算术强度骤降至仅 FLOP/字节。结果是性能下降了 倍。一个旨在改善计算的善意优化,却造成了严重的内存瓶颈。
这种意想不到的权衡原则无处不在。考虑即时内存压缩。这似乎是个好主意:在通过内存总线发送数据之前对其进行压缩以节省带宽。但另一端的解压硬件会增加少量延迟。这值得吗?我们可以进行盈亏平衡分析。对于一个给定的系统,我们可以计算出确切的压缩因子 ,在该点,传输节省的时间恰好被增加的解压延迟所抵消。对于一个拥有 64 字节缓存行、25 GB/s 带宽和 0.5 ns 解压延迟的系统,该盈亏平衡点约为 的压缩因子。如果你的压缩效果优于此,你就赢了。如果不是,你就输了。性能工程就是驾驭这些无数权衡的艺术。
到目前为止,我们的旅程一直将主内存视为一个单一、均匀的实体。是时候打破这最后的幻想了。在大型多处理器服务器系统中,情况往往并非如此。这类系统通常采用非一致性内存访问(NUMA)架构。在 NUMA 机器中,每个处理器插槽都有其自己的“本地”内存库。处理器可以快速访问其本地内存。但它也可以通过较慢的互连通信来访问连接到另一个处理器插槽的“远程”内存。现在,内存有了地理属性:数据有其位置,距离很重要。
这为粗心的人制造了新的、微妙的陷阱。想象一下运行一个异地(out-of-place)算法,你从输入数组 A 读取数据并写入输出数组 B。程序员可能认为将数组 A 放在节点 0 上,将数组 B 放在节点 1 上是很聪明的做法,希望将“负载分散”到两个内存控制器上。结果往往是性能灾难。
罪魁祸首是我们曾暗示过的一种底层缓存策略:写分配(write-allocate)。当节点 0 上的处理器尝试写入数组 B(位于节点 1)中的某个位置时,它首先检查自己的缓存。由于这是它第一次写入该位置,所以会发生缓存未命中。写分配策略规定,在写入发生之前,必须将包含该位置的整个缓存行取入本地缓存。这意味着节点 0 上的处理器必须通过慢速互连向节点 1 发出一个读请求。节点 1 将缓存行发送回来。只有这样,节点 0 上的处理器才能将数据写入其本地缓存。一个本意简单的远程写入,变成了一个同步的往返操作:远程读取后跟本地写入。整个过程都被互连带宽所瓶颈。
处理这种复杂性是操作系统调度器的工作。一个现代的 NUMA 感知调度器必须极其复杂。它不能只是盲目地放置线程。它必须像一个物流大师一样行事,考虑每个线程的内存占用 ()、其带宽需求 ()、每个 NUMA 节点的容量 ()、每个节点的当前带宽负载,甚至哪些线程在共享数据。放置决策可能涉及最小化一个复杂的评分函数,该函数权衡远程访问的惩罚与带宽争用的惩罚,同时还要遵守严格的内存容量限制。这就是大规模内存性能的复杂现实。
从简单的 Roofline 模型到复杂的 NUMA 地理学,我们对内存性能的理解不断加深。但我们如何应用这些知识呢?在现实世界中,我们如何诊断一个缓慢的应用程序?我们化身为侦探。
现代处理器配备了性能监控单元(PMU),这是一系列令人眼花缭乱的硬件计数器,可以测量数百种不同的事件:已退役的指令、各级缓存未命中次数、已用内存带宽等等。通过设计精心的实验,我们可以利用这些线索来揭开真正的瓶颈。
我们的程序是核心受限还是内存受限?我们可以直接测试这一点。我们可以做一个实验,在保持内存速度不变的情况下改变处理器的频率。如果程序的吞吐量与核心频率成线性关系,那么它很可能是核心受限的。如果其性能几乎没有变化,这明确表明处理器正把时间花在等待内存上。在第二个实验中,我们可以在后台运行一个“内存大盗”程序,它会消耗已知比例的内存带宽。如果我们应用程序的性能下降了,就证实了它确实在争夺内存带宽。
通过将这些实验技术与我们已探讨的概念模型相结合,我们可以拼凑出我们应用程序性能的故事。我们可以确定它是计算受限还是内存受限;它是在遭受延迟还是带宽的限制;它的缓存使用是否有效;以及它是否掉入了复杂内存架构的陷阱。从一个慢程序到一个快程序的旅程,是一场发现之旅,由这些优美而统一的性能原则所指引。
在我们迄今的旅程中,我们探讨了内存性能的基本原理——延迟、带宽与处理器计算能力之间的相互作用,这些都被 Roofline 模型优雅地捕捉。然而,这些概念并非仅仅是抽象的理论。它们是现代计算几乎所有领域性能的无形主宰者,从我们屏幕上炫目的图形到我们这个时代的重大科学发现。现在,让我们进入这些领域,看看对内存性能的深刻理解如何揭示计算科学中的挑战与成就。
我们使用的计算机的核心是一种设计上极为简洁却影响深远的设计:冯·诺依曼架构。指令——计算机遵循的“程序”——和数据——它操作的“事实”——都存放在同一个统一的内存中。可以把它想象成我们自己的大脑,它从同一个生物记忆库中检索习得的程序(如何骑自行车)和原始事实(法国的首都是哪里)。
然而,这种统一的设计造成了一个根本性的瓶颈。处理器必须不断地通过同一个共享的内存通道来获取指令和数据。对有限资源——内存带宽——的这种争夺,就是著名的冯·诺依曼瓶颈。即使一个处理器每秒能够执行数十亿条指令,它的实际性能也常常受限于它能多快地被“喂饱”。一个简单的模型显示,所需的总带宽是指令获取所需带宽和数据读写所需带宽的总和。如果这个总和超过了可用带宽,处理器就必须等待,其强大的速度也就被浪费了。这个最初的原则表明,即使是一个看似更高效的假想分离通道架构,也可能在某一个通道上遇到瓶颈,如果工作负载不均衡,就会导致整体性能下降。这一个基础性的限制,塑造了之后的一切。
如果内存是一个瓶颈,那么高性能计算的艺术就是数据移动的编排艺术。一个算法的性能不仅取决于它执行的算术运算数量,还取决于其内存访问的模式。
考虑从一个复杂的概率分布中生成随机数的任务,这是统计学和模拟中常见的问题。人们可能用两种方式来解决这个问题。第一种是程序化的方法:从一个均匀分布的随机数开始,进行一系列计算(例如,使用牛顿法)来转换它。这涉及一个紧凑的算术循环,如果其代码和常数能装入处理器的快速缓存,它就成为一个计算受限任务的典范。这就像一个工匠,所有工具都触手可及,以最高速度工作。
第二种是基于表格的方法:预先计算一个精细输入网格的答案,并将它们存储在一个巨大的表格中。要查找一个新值,只需查表即可。这看似巧妙,但如果表格太大而无法装入缓存——这通常是事实——就会导致灾难。例如,标准的二分查找会跳到表格的中间,然后是四分之一处,再然后是八分之三处,依此类推。每一次跳转都像是跳到主内存中的一个随机位置,几乎肯定会导致代价高昂的缓存未命中。这是一种随机访问模式,就像要求图书管理员为每一个查询都从巨大图书馆的不同角落取书一样。性能不再受 CPU 限制,而是受这些内存访问的延迟限制。
但算法洞察力的魔力就在于此。如果我们能收集一批查询并先对它们进行排序,我们就能改变这个问题。图书管理员不再是疯狂地来回奔跑,而是可以沿着一条通道平静地走下去,按顺序拿起所需的书籍。这个随机的、受延迟限制的过程,变成了一个平滑的、顺序流式处理过程,仅受原始内存带宽的限制。这种“编排”上的简单改变可以带来数量级的速度提升,表明我们如何访问数据与如何处理数据同等重要。
科学领域的许多重大挑战——从天气预报、计算流体动力学(CFD)到模拟地质应力——都依赖于在网格上求解微分方程。在这些模拟中,一个常见的计算模式是模板更新(stencil update),即网格上一个点的新值取决于其直接邻居的旧值[@problem-id:3329328]。
模板更新的朴素实现效率极低。为了更新一个点,它从主内存中获取其邻居。然后,为了更新紧邻的下一个点,它又会重新获取许多相同的邻居。这类似于读完书的一章,写下一句关于它的感想,然后为了写下一句感想而重读整章。冗余数据移动量是惊人的。
解决方案是高性能计算的一个基石:分块(或平铺,tiling/blocking)。我们不是一次性扫过整个网格,而是将其分解成可以完全容纳在处理器快速缓存中的小块。我们将一个小块加载到缓存中,并对该块内的点执行所有必要的更新,然后再移动到下一个块。这种数据局部性原则,就像一位画家在移到下一处之前,完全画好一幅巨大壁画的一个小方块。
通过这样做,我们最大化了对已在手边的数据所做的工作。用 Roofline 模型的语言来说,分块显著提高了运算强度——浮点运算次数与从主内存移动的字节数之比。这使得应用程序能够攀登倾斜的内存带宽“屋顶”,解锁先前无法达到的性能水平,使其更接近处理器真正的计算峰值[@problem_id:3254623, @problem_id:3329328]。
图形处理单元(GPU)提供了惊人的计算能力,拥有数千个并行工作的核心。然而,这种能力只授予那些遵守严格“内存契约”的人。违反条款,性能就会骤降。
一个绝佳的例子来自计算化学和分子动力学(MD)模拟的世界。一个典型的 MD 代码有不同特性的不同部分。计算键合力(化学键的拉伸和弯曲)是一种局部操作,具有高算术强度——在少量原子上进行大量计算。它通常是计算受限的。相比之下,通过粒子网格埃瓦尔德(PME)等方法计算长程静电力涉及大型 3D 快速傅里叶变换(FFT),这会在整个模拟盒子中 shuffling(洗牌)大量数据。这部分的算术强度低,通常是内存受限的。
现在,想象一下升级到一个新的 GPU,其计算核心数量翻倍,但内存带宽相同。计算受限的键合力计算部分会看到巨大的加速。但内存受限的 PME 部分几乎没有任何改善;它已经受限于未变的内存带宽。这揭示了一个深刻的真理:一个平衡的系统至关重要,理解应用程序的瓶颈对于预测其如何扩展至关重要。
GPU 内存契约中最重要的条款是内存合并。GPU 对称为 warp 的线程组进行操作。当一个 warp 中的所有线程访问一个连续的内存块时,效率最高。可以把内存总线想象成一辆 literal(字面意义上的)巴士,它想一次性接上 32 名工人组成的整个团队。如果他们都在公交站整齐地排队,一次行程就足够了。这就是合并访问。如果他们分散在全城各处,巴士就必须进行多次单独的行程。这就是非合并访问,它通过有效降低可用内存带宽来摧毁性能。它不会改变算法的 FLOP 计数,但通过增加传输的字节数,它压低了运算强度,并将应用程序钉在 Roofline 模型最低、最慢的部分。
契约的另一部分涉及实用主义。例如,在计算机图形学中,我们可能不需要 32 位浮点数的高精度来存储顶点的位置。通过使用 16 位表示,我们可以将内存流量减半,从而有效地将内存带宽加倍。只要由此产生的微小误差在视觉上可以接受,这种权衡就可以带来帧率的显著提升——这种加速可以通过阿姆达尔定律(Amdahl's law)预测。
并非所有问题都像在规则网格上进行模板计算那样规整。 “大数据”世界通常以海量、非结构化和不规则的数据集为特征。一个典型的例子是网络图,一个由数十亿页面和超链接组成的庞大网络。分析此图的一个核心任务是计算 PageRank,这是驱动 Google 原始搜索引擎的算法。
PageRank 算法的核心是稀疏矩阵向量乘法(SpMV)。这涉及遍历图的链接。对于给定的网页,该算法需要访问所有链接到它的页面的数据。这些链接页面,实际上,位于内存中的随机位置。这是终极的“图书管理员”噩梦:一个具有内在不规则数据访问模式的问题。每次访问都可能是一次缓存未命中,迫使进行一次缓慢的主内存之旅。运算强度极低。这类问题几乎总是严重受限于内存带宽,它们的性能证明了一个事实:有些问题从根本上比其他问题更难加速。
我们已经看到内存性能如何支配单个算法和计算核心。最后一步是综合这些知识,以理解和预测整个复杂科学应用程序的行为。
让我们回到模拟世界,这次是计算地质力学。一个真实的模拟可能会使用带代数多重网格(AMG)预条件子的 GMRES 方法来求解一个巨大的线性方程组。该求解器的一次迭代是不同组件的交响乐:SpMV 操作、复杂的 AMG V-循环以及众多的向量更新。每个组件都有其自己的运算强度和内存流量。
通过仔细地为每个部分建模并将它们加在一起,我们可以为一次完整的迭代构建一个整体的性能模型。该模型包括节点内时间——由 Roofline 模型决定,对于这样一个复杂的求解器几乎可以肯定是内存受限的——以及至关重要的,并行系统的通信时间。这包括同步处理器的网络延迟和在它们之间交换数据的网络带宽。
这个综合模型使我们能够回答深刻而实际的问题。例如:“对于这个问题,哪种硬件更好,CPU 还是 GPU?” 模型揭示答案并非绝对;它取决于问题规模 。对于小问题,GPU 更高的开销(如内核启动时间)可能使 CPU 成为更好的选择。但随着问题的增长,GPU 远超对手的内存带宽成为主导因素,它最终会超过 CPU。该模型甚至可以预测 GPU 成为更快机器的精确交叉点 。
这是我们应用理解的巅峰。我们从优化一个单一循环,发展到做出关于硬件采购的战略决策,并预测整个科学工作流程的性能。我们看到,内存性能的原则不仅仅是让代码运行得更快;它们关乎揭示计算的根本限制,并指引我们走向未来的发现之路。