try ai
科普
编辑
分享
反馈
  • 理解计算机系统中的容量缺失

理解计算机系统中的容量缺失

SciencePedia玻尔百科
核心要点
  • 当一个程序的活动数据集(其“工作集”)从根本上大于处理器缓存的总大小时,就会发生容量缺失。
  • 与冲突缺失不同,容量缺失是大小问题,而非组织结构问题,即使在理想的全相联缓存中也同样会发生。
  • 诸如分块和循环融合等软件技术,通过重构算法以改善时间局部性,对于缓解容量缺失至关重要。
  • 容量限制的原则普遍适用于系统中的各个组件,影响着数据缓存、指令缓存以及转译后备缓冲器(TLB)。
  • 在某些底层场景中,例如使用 LL/SC 指令时,容量缺失不仅会影响性能,还可能影响程序的逻辑正确性。

引言

在追求计算速度的过程中,最大的挑战之一是现代处理器与其主存之间巨大的速度差异。为了弥合这一鸿沟,计算机架构师创造了缓存(cache):一种小而快的存储体,充当处理器的高速工作台。性能的关键在于在正确的时间将正确的数据保留在这个工作台上。当所需数据不在其中时,便会发生“缓存缺失”(cache miss),这迫使处理器前往缓慢的主存储藏室,从而导致计算停顿。

然而,仅仅知道发生了缺失是不够的。为了真正优化性能,我们必须理解它发生的原因。缓存缺失并非单一事件,其可能源于几种截然不同的原因,每种原因都需要不同的解决方案。本文旨在填补这一知识空白,通过解构缓存缺失的类型,重点关注最根本的限制:容量缺失。

首先,在“原理与机制”一节中,我们将探讨“3C”模型——强制缺失(compulsory)、冲突缺失(conflict)和容量缺失(capacity),以清晰地理解数据为何会从缓存中被逐出。随后,我们将在“应用与跨学科联系”一节中深入探讨其实际影响,揭示缓存大小这一简单约束如何影响从高性能计算中的算法设计到现代操作系统和并行程序的架构等方方面面。读完本文,您将把内存容量的“无形之墙”视为计算机系统中的一个决定性原则。

原理与机制

要理解高性能计算的艺术与科学,我们必须首先认识到一个基本事实:并非所有内存都生而平等。处理器的缓存是一个小而宝贵的极速内存孤岛,与广阔而缓慢的主内存(RAM)海洋形成鲜明对比。整个游戏的关键在于在正确的时间将正确的数据保留在这座孤岛上。当处理器需要一块数据并在缓存中查找时,它要么找到——称为​​缓存命中​​(cache hit)——要么找不到——称为​​缓存缺失​​(cache miss)。一次缺失意味着一次延迟,是计算狂奔步伐中的一次停顿,因为处理器必须向缓慢的主内存发出请求。理解缺失为何发生是将其最小化的关键。事实证明,计算机的“健忘”有三种截然不同的类型。

计算机的健忘:三种“哎呀”

想象一下,你正在一个小工作台(缓存)上做一个项目。你的工具都存放在房间另一头的一个大工具箱(主内存)里。每当你需要一个不在工作台上的工具时,你都必须停下来,走到工具箱前去取。这就是一次缓存缺失。让我们来分析一下你可能需要跑这一趟的原因。

首先,你可能需要一个在这个项目中从未使用过的工具。很自然,它不会在你的工作台上。你别无选择,只能去拿。这是一种​​强制缺失​​(compulsory miss),有时也称为冷缺失(cold miss)。这是开始一项新任务或首次接触一块数据时不可避免的代价。如果一个程序只是简单地读取一长串全新的数据,那么每次缺失都将是强制性的,再巧妙的缓存设计也无法避免。。

其次,你的工作台可能因为组织不善而一团糟。比如说,你有一条奇怪的规定:锤子只能放在左边的抽屉里,扳手只能放在右边的抽屉里。现在,假设你的项目需要三种不同的锤子,但你的左抽屉只能放两把。即使你的扳手抽屉完全是空的——意味着你的工作台总空间绰绰有余——你也只能被迫不停地在那个抽屉里换锤子。你放下羊角锤去拿大锤,但接着你又需要羊角锤了。它已经不见了,被替换掉了。这是一种​​冲突缺失​​(conflict miss)。它不是一个根本的空间问题,而是由缓存有限的“相联度”(associativity)——即关于数据可以放在哪里的规则——引起的结构性问题。一个反复使用多个映射到同一缓存组的地址的访问模式,即使在缓存大部分为空的情况下,也可能导致这种“抖动”(thrashing)。。

最后,我们来到了最根本的限制:你工作台的绝对大小。想象一下,你的项目实在太大了。你确实需要十几种不同的工具,但你的工作台只能放八种。无论你如何组织它们,你就是没有足够的空间。为了拿起第九个工具,你必须放下前八个中的一个。如果你很快又需要那个工具,那你就不走运了。这是一种​​容量缺失​​(capacity miss)。当你的程序正在活跃使用的数据集——即其​​工作集​​(working set)——大于缓存的总容量时,就会发生容量缺失。这种缺失不是因为运气不好或组织不善,而是试图将十磅重的东西装进五磅重的袋子里所带来的不可避免的后果。

关键的大小问题:工作集与缓存容量

在许多方面,容量缺失是“最纯粹”的一种缺失。它触及了速度与大小之间权衡的核心。让我们用一个非常清晰的例子来探讨这一点。想象一个程序需要扫描一个大数据数组,比如说,一个占用520个缓存行内存的数组。现在,假设我们的缓存总容量只有512个缓存行。。

该程序进行两遍扫描。在第一遍中,它从头到尾读取整个数组。数组的前512行完全填满了缓存。当程序读取第513行时,缓存必须腾出空间。遵循“最近最少使用”(LRU)策略,它会逐出它加载的第一行,即第0行。当它读取第514行时,它会逐出第1行,以此类推。到第一遍扫描结束时,缓存中包含的是数组的最后512行(第8行到第519行)。

现在,第二遍扫描开始。程序想要再次读取数组的第一个元素,它位于第0行。但第0行早已不见了!它为了给数组的末尾部分腾地方而被推出了。结果呢?一次缺失。随着第二遍的继续,它发现它需要的每一行都已被系统性地逐出。整个第二遍扫描变成了一场缺失的大游行。这些都是容量缺失。该程序的工作集(520行)从根本上就大于缓存的容量(512行)。

这就引出了一个更精确的概念:​​重用距离​​(reuse distance)。一块数据的重用距离是指在对它进行两次连续使用之间,访问了多少其他不同的数据块。在我们的例子中,在第一遍读取第0行和第二遍再次读取它之间,程序访问了519个其他不同的行。由于重用距离(519)大于缓存的容量(512),缺失是不可避免的。当一次访问的重用距离超过缓存的容量时,就会发生容量缺失。。

但美妙之处在于,如果我们改变软件呢?我们不写两个独立的遍,而是写一个单一的循环,在移动到下一个元素之前立即读取每个元素两次:read A[i]; read A[i];。现在,第二次读取A[i]的重用距离是多少?是零!中间没有接触任何其他数据。第二次读取现在是保证命中的。通过重构代码,我们极大地缩短了重用距离,完全消除了第二遍扫描中所有520次容量缺失。。这是一个深刻的洞见:有时候,解决硬件限制的方法不是更多的硬件,而是更智能的软件。

区分真正的容量不足与运气不佳

当一次缺失发生时,我们如何确定它是一次容量缺失,而不仅仅是一次不幸的冲突缺失?这对性能工程师来说是一个关键的诊断问题。答案在于一个思想实验。想象一个“理想”的缓存,它没有任何组织规则。任何数据块都可以放入任何槽位。这被称为​​全相联缓存​​(fully associative cache)。它代表了给定容量下的绝对最佳情况,因为它永远不会遭受冲突缺失。

我们可以用这个理想缓存作为我们的标尺。如果一次缺失即使在同样总大小的全相联缓存中仍然发生,那么它就是一次真正的容量缺失。如果这次缺失在这个理想缓存中消失了,那么它必定是一次冲突缺失。。

这不仅仅是一个思想实验。性能分析工具可以使用​​幽灵缓存​​(ghost caches)(或影子缓存)直接实现这个想法。。幽灵缓存是一段软件,它在后台模拟一个理想的全相联缓存。它不存储任何数据,只存储数据块的地址(标签)。当真实程序运行时,每一次内存访问也会被送入这个幽灵缓存模拟器。

当真实的硬件缓存发生缺失时,处理器可以查看幽灵缓存。

  • 如果数据在理想的幽灵缓存中存在,这意味着这次缺失是可避免的。真实缓存因其组织缺陷而缺失。这是一次冲突缺失。
  • 如果数据在理想的幽灵缓存中不存在,这意味着即使有完美的组织,数据也还是会被逐出。工作台实在太小了。这是一次容量缺失。

这种优雅的技术,植根于一个名为LRU栈模型的理论概念,使我们能够清晰地将容量缺失与冲突缺失分离开来,并精确地诊断性能瓶颈。。

当容量成为瓶颈时,我们能做什么?

那么,你的分析显示你的程序正遭受大量的容量缺失。你的工作集就是太大了。现在该怎么办?

最直接的解决方案,当然是换一个更大的缓存。正如人们可能预期的那样,如果你有容量问题,你需要更多的容量。将缓存的相联度加倍(使其组织得更好)或改变地址到组的映射,在工作集根本放不下的情况下是无济于事的。但将总容量加倍可以使缺失消失。。

然而,购买更大的硬件并非总是可行。更巧妙的解决方案在于软件。正如我们在循环融合中看到的那样,我们可以重构我们的算法以改善​​时间局部性​​——在数据还“新鲜”地留在缓存中时重用它。一个实现这一点的强大技术是​​分块​​(tiling)或​​阻塞​​(blocking)。你不是一次性对一个巨大的矩阵进行操作,而是将其分解成更小的子矩阵,或称“块”,这些块的大小被设计成可以舒适地放入缓存中。你在处理完一个块上的所有必要工作后,再移动到下一个。这确保了一个块内元素的重用距离非常小,将本可能是一场容量缺失风暴转化为一阵和煦的命中微风。

这也引出了一个关于替换策略的微妙之处。当一个程序受到严重的容量限制时,比如流式处理一个比缓存大很多倍的数据集,替换策略(LRU、FIFO、随机等)的选择几乎没有任何区别。任何被带入的数据在再次被需要之前早就被逐出了,无论策略如何。未命中率将接近100%,无论如何。相比之下,对于受冲突限制的工作负载,策略是绝对关键的,它可能是大量命中和大量缺失之间的区别。。

现实世界的微妙之处

“3C”模型是一个强大且清晰的框架。但就像任何好的物理模型一样,它也是对更复杂现实的一种近似。真实的计算机系统具有额外的特性,为我们的故事增添了细微的差别。

例如,我们主要讨论了读取数据。那写入呢?如果一个程序执行“读-修改-写”操作,其行为通常比你想象的要简单。一次读取缺失将在缓存中分配一个行。随后的写入就是对一个已经存在的行的写入,使其成为一次写入命中。这对于两种主要的写入策略(写通和写回)都成立。最初读取缺失的类型——无论是强制性、容量性还是冲突性——仍然是决定性能的因素。[@problem_g_id:3625450]。

一个更令人费解的复杂性来自缓存层次结构。现代处理器有多级缓存(L1,L2,L3L_1, L_2, L_3L1​,L2​,L3​),通常具有​​包含​​(inclusion)属性:任何存在于较小L1L_1L1​缓存中的数据也必须存在于较大的L2L_2L2​缓存中。这个合理的规则导致了一种全新的缺失方式。一个块可能正安逸地待在你的L1L_1L1​缓存中。但在后台,一系列其他访问导致了L2L_2L2​缓存的容量缺失,迫使你的块被从L2L_2L2​中逐出。为了维持包含性,系统必须向L1L_1L1​发送一个失效信号,从而将该块也从L1L_1L1​中清除。下一次你的处理器在L1L_1L1​中寻找这个块时,它已经不见了。这不是一次强制缺失,也不是*L1L_1L1​的*容量缺失,更不是一次冲突缺失。它是一次​​包含诱导的缺失​​(inclusion-induced miss),一个由内存层次结构不同级别之间相互作用产生的幻影。。

这是一个美妙的提醒:在科学和工程的旅程中,我们简单而优雅的模型是必不可少的起点。它们提供了核心的直觉并指导我们的思维。同时,它们也让我们准备好去欣赏事物真实运作方式中更深、更丰富,也常常是更令人惊讶的复杂性。

应用与跨学科联系

无形之墙:内存容量如何塑造我们的数字世界

想象你是一位工作室里的大师级工匠。你的工作台是所有活动的发生地,但它并非无限大。你的大部分工具和材料都存放在附近一个巨大的储藏室里。为了进行一个项目,你把需要的东西带到工作台上。如果你需要从储藏室拿一个新工具,但工作台已经满了,你必须做出选择:把什么放回去以腾出空间?去储藏室的这一趟需要时间。每当你不得不去取回你最近才放回去的东西时,你的工作就会慢下来。这,本质上,就是一次​​容量缺失​​。

在计算世界里,处理器是工匠,缓存是工作台,主内存是储藏室。正如我们在前一章所见,当一个程序的活动数据——其“工作集”——实在太大而无法放入缓存时,就会发生容量缺失。硬件尽其所能,却被迫不断地来回 shuffling 数据,造成了性能瓶颈。这个瓶颈并非源于缓存设计的缺陷,而是任务的雄心与硬件物理极限之间的根本不匹配。

现在,让我们踏上一段旅程,去看看这堵内存容量的“无形之墙”。我们会发现,它不仅仅是一个小麻烦,而是一个决定性的约束,塑造了从高性能算法的设计到操作系统的体系结构,再到并行编程本质的方方面面。

分块的艺术:驯服大问题

如果一个问题对于工作台来说太大了,最显而易见的解决方案就是把它分解成更小的部分。这个简单的想法,被称为分块(tiling)或阻塞(blocking),是高性能计算的基石。它是一门致力于确保处理器总能将其所需数据置于指尖的艺术。

考虑最简单的操作之一:遍历存储在内存中的一个大矩阵。如果矩阵是按行存储的,但你的算法决定沿列前进,你就会制造一场灾难。每向下一步,你都会跳过一整行的数据。在你重用任何一块数据之前,你接触的数据集变得异常庞大,远大于任何缓存。你不断地跑回储藏室,不是因为某些复杂的冲突,而仅仅是因为你的工作集太大了。这是一个典型的容量缺失场景。如果你简单地交换循环以沿行前进,你的工作集会急剧缩小,容量缺失也可能消失——不过,有趣的是,你可能会因此面临另一种问题,即冲突缺失,如果你的数据恰好不幸地在缓存中对齐了。。

这种保持工作集小的原则成为了一种强大的设计工具。假设你想转置一个矩阵——沿着它的对角线翻转。一个幼稚的方法会读取整个源矩阵并写入整个目标矩阵,这个任务对于缓存来说太大了。具有缓存意识的解决方案是处理小的方形块。你从源矩阵加载一个小的 b×bb \times bb×b 块,并为目标矩阵加载一个相应的 b×bb \times bb×b 块到你的工作台上。你整个的工作集现在就只有这两个块。关键是选择一个尽可能大,但又足够小以使两个块都能舒适地放入缓存的块大小 bbb,即 2sb2≤M2sb^2 \le M2sb2≤M,其中 sss 是一个元素的大小,而 MMM 是缓存容量。通过解这个简单的不等式,我们可以找到最小化容量缺失并让处理器不间断工作的最优块大小。。

当你掌握了这门艺术,你就能构建出美妙的预测性性能模型。对于像矩阵乘法(GEMM)这样的复杂操作,整个性能模型都建立在你已明智地选择分块以消除容量缺失的假设之上。一旦你保证工作集(对于GEMM是三个块:每个源矩阵一个,目标矩阵一个)能放入缓存,性能就变成了处理器峰值计算速度与内存系统带宽和延迟之间可预测的舞蹈。然而,如果你违反了容量约束,模型就会崩溃。系统被容量缺失所淹没,性能不再由优雅的方程决定,而是由疯狂、昂贵的数据 shuffling 所主导。。

软件选择与硬件现实

容量缺失不仅是算法的一个特征;它们源于软件设计选择与硬件物理现实之间深度的相互作用。有时,最直观的软件解决方案可能导致意想不到的后果。

要理解这一点,可以想象一个没有自动缓存的世界。取而代之的是,如果你有一个“暂存存储器”(scratchpad memory)——一块你作为程序员必须显式管理的快速内存,会怎么样?对于一个工作集大于硬件缓存的任务,比如在一台拥有32 KiB缓存的机器上流式处理48 KiB的数据,硬件将不可避免地遭受容量缺失。然而,使用暂存存储器,你可以明确地以4 KiB的块加载数据,处理每个块,然后再加载下一个。你通过手动确保工作集永不超过快速内存的大小,从而控制了局面并消除了容量缺失。这个比较揭示了一个深刻的真理:容量缺失是缓存隐式、硬件驱动管理的结果。自动缓存的便利性伴随着性能崩溃的风险,当它简单的LRU(最近最少使用)猜测游戏面对一个巨大的工作集时就会失败。。

这种张力在并行编程中以更微妙的方式出现。想象两个核心在处理本应是独立的数据。如果这些数据恰好位于同一个缓存行上,这些核心就会为该行的所有权而争斗,这个问题被称为*伪共享*(false sharing)。一个常见的修复方法是在数据结构中添加填充,确保每个核心的数据位于不同的缓存行上。这漂亮地解决了L1缓存一致性问题。但转折点在于:这种填充增大了数据的总大小。曾经能整齐放入共享L2缓存的数据现在可能太大了。在解决一个一致性问题的同时,你不经意间在内存层次结构的下一级制造了一个容量问题,将一个原本是快速的、驻留在L2的工作负载,变成了一个持续遭受来自主内存容量缺失的工作负载。。性能工程是一项精巧的平衡艺术,在一个部分的解决方案可能会在别处制造出新问题。

系统作为一个整体:一个普遍的约束

容量的“无形之墙”并不仅限于数据缓存。它是一个普遍的原则,在整个计算机系统中以多种不同的形式出现。

不仅仅是你的数据需要放在工作台上;你的代码也需要。处理器的指令缓存(I-cache)保存着它即将执行的指令。如果你有一个非常紧凑、性能关键的轮询循环,你为了让它更快而对其进行了“展开”,但结果代码现在比I-cache还要大,会发生什么?处理器开始取循环的开头,填满I-cache。但在它完成之前,它需要取循环末尾的指令,这迫使它逐出开头的指令。当它执行最后的跳转回到起点时,它需要的指令已经不见了。它遭受了一次I-cache缺失。这是代码的容量缺失,它表明该原则与被缓存的内容无关——如果它太大了,就放不下。。

这个原则向上延伸到操作系统的层面。在现代分时系统中,操作系统在多个进程之间快速切换,给每个进程一个CPU时间片。这创造了我们工作坊比喻的多人版本。当进程A运行时,它用自己的数据填满缓存。然后操作系统执行一次上下文切换,进程B接管。进程B对进程A一无所知,把自己的工具带到工作台上,踢掉了进程A的数据。当进程A再次运行时,它回来发现自己的工作空间被别人的东西弄得乱七八糟。它必须遭受一场缓存缺失的风暴来重新加载其工作集。这些是由多任务处理引起的容量缺失。进程越多,或者操作系统切换得越快,这个问题就越严重,这对上下文切换的性能构成了一个根本性的限制。。

这个思想甚至延伸得更远,直至寻址机制本身。为了找到数据,CPU必须将程序使用的“虚拟”地址转换为硬件内存的“物理”地址。这个翻译过程本身被缓存在一个微小、极快的缓存中,称为转译后备缓冲器(Translation Look-aside Buffer,TLB)。就像数据缓存一样,TLB也有有限的容量。当许多进程运行时,它们的地址翻译会相互挤出TLB。当一个进程恢复时,它可能会发现它的翻译已经不见了,导致一次“TLB缺失”。为了解决这个问题,现代CPU包含一个名为进程上下文标识符(Process-Context Identifiers,PCID)的功能,它就像TLB条目上的标签,允许来自不同进程的翻译共存。这个硬件功能的存在本身就证明了TLB中容量缺失的严重性。。

正确性、哲学与设计

大多数时候,容量缺失“仅仅”是一个性能问题。但在某些情况下,它会影响程序的逻辑正确性,甚至影响整个软件系统的根本设计哲学。

考虑一个像加载链接/条件存储(Load-Linked/Store-Conditional,LL/SC)这样的底层同步原语。这是构建锁和其他并行数据结构的强大工具。它的工作原理是让处理器“预留”一个内存位置(加载链接),然后尝试写入它(条件存储),只有在该位置未被干扰时才会成功。硬件通常通过跟踪被预留的缓存行来实现这一点。但是,如果在你的LL和SC之间的代码接触了太多其他数据,以至于导致被预留的缓存行从缓存中被逐出,会怎么样?这是一次由你自己的程序活动驱动的容量逐出。当你最终执行SC时,硬件看到预留已经消失,并导致存储失败。在这里,一次容量缺失不仅仅是让你变慢;它可能破坏你操作的原子性,迫使你重试。。

也许最深刻的是,容量限制的幽灵导致了系统设计中完全不同的哲学。比较一下数据库缓冲池和操作系统slab分配器。数据库的缓冲池是磁盘页面的经典缓存。当它满了且需要一个新页面时,它会运行像LRU这样的替换算法来选择一个“牺牲”页面进行逐出。它接受自己会有容量缺失,并专注于做出最明智的逐出选择。而操作系统slab分配器,它提供小的、频繁使用的内核数据结构,则基于一个完全不同的原则运作。它从不逐出一个活动的、已分配的对象。它与内核其他部分的契约是绝对的:如果你被给予了一个对象,它就是你的,直到你明确释放它。如果分配器用完了内存,它不会逐出某个东西;分配请求会直接失败。它通过设计避免了对活动数据的容量缺失。它不是逐出单个对象,而是一个后台进程可能会回收整个slab,但前提是它们已经大部分或完全为空。。这是一个美妙的例子,说明同一个根本问题——管理有限资源——如何导致两种截然不同且同样有效的设计哲学。

从单个矩阵乘法的核心到操作系统的宏伟架构,缓存容量这个简单而刚性的约束留下了它的印记。理解这堵“无形之墙”不仅仅是为了编写更快的代码,更是为了更深刻地欣赏支配现代计算机系统如何工作的那些复杂、互联且往往优美的逻辑。