try ai
科普
编辑
分享
反馈
  • 缓存抖动

缓存抖动

SciencePedia玻尔百科
核心要点
  • 当活跃工作集的数据量超过缓存容量,或当内存访问模式导致过多的重复驱逐时,就会发生缓存抖动。
  • 这种性能崩溃是一个普遍原则,不仅影响 CPU 缓存,还影响操作系统页缓存、转译后备缓冲器(TLB)和 Web 服务器。
  • 在多核系统中,抖动表现为由数据真共享或单个缓存行的隐蔽伪共享引起的一致性风暴。
  • 有效的缓解需要采用整体的、缓存感知的系统设计方法,这会影响从数据结构和算法到操作系统调度的方方面面。

引言

在追求计算速度的征程中,内存缓存作为现代计算机体系结构的基石,充当着高速缓冲区,弥合了处理器与主存之间巨大的性能差距。当其正常工作时,这个系统就像一场无声而优雅的数据检索之舞,持续为处理器供给数据,使其保持高效。然而,当程序的内存访问节奏与缓存的结构发生冲突时,这场舞蹈可能演变成一场灾难性的性能崩溃,即所谓的“抖动”(thrashing)。本文旨在弥合“仅仅使用计算机”与“理解其性能为何会突然急剧下降”之间的关键知识鸿沟。通过探索缓存抖动,读者将对软件和硬件之间复杂的相互作用产生深刻的理解。接下来的章节将首先剖析抖动的基本原理和机制,从简单的容量失效到多核一致性的微妙混乱。随后,本文将探讨其多样化的应用和跨学科联系,揭示这一单一概念如何影响从科学计算、GPU 编程到实时操作系统设计的方方面面。

原理与机制

要理解被称为“抖动”的剧烈性能崩溃,我们必须首先领会使现代计算机高速运行的美妙而无声的舞蹈:局部性原理。想象一位大厨在厨房里工作。他最常需要的食材和工具——盐、胡椒、一把爱用的刀、一个搅拌碗——都放在伸手可及的台面上。这个台面就是​​缓存​​。而那些不常用的物品,比如一种特殊的香料或一个火鸡滴油管,则存放在远处一个巨大的储藏室里,也就是我们的​​主存​​。这位大厨之所以手脚麻利,是因为大多数时候,他接下来需要的东西已经摆在台面上了。这个简单而强大的思想被称为​​引用局部性​​。

这个原理有两种基本类型。首先是​​时间局部性​​,即时间上的重用:如果大厨刚用过盐,他很可能很快会再次需要它。其次是​​空间局部性​​,即空间上的重用:如果大厨拿起了盐,他很可能也需要胡椒,因为它们一起放在香料架上。计算机缓存利用了这一点,它不仅仅从内存中取一个字节,而是取回一整块相邻的数据,称为一个​​缓存行​​(cache line),这类似于一次性拿起整个香料架。当这场舞蹈节奏和谐时,处理器很少需要长途跋涉、缓慢地访问主存。但当编排出了问题时,会发生什么呢?

当音乐停止时:冲突与容量

这场舞蹈最简单的失败方式是直接冲突。想象一下,你的厨房台面上只有一个指定位置可以放一个大锅。现在,假设你的食谱要求你同时使用两个大锅,并交替使用它们。每次你需要其中一个时,都必须先把另一个从台面上搬回储藏室,然后再把你需要的那个取出来。你所有的时间都花在了交换锅上,而不是做饭。

这恰恰是缓存抖动最基本形式下发生的情况。一个简单的缓存可能会将不同的内存地址映射到同一个缓存槽位或“组”(set)中。如果一个程序反复交替访问两个恰好映射到直接映射缓存中同一个组的内存地址,比如 xxx 和 yyy,一场灾难就此展开。访问 xxx 会将其数据加载到缓存中。紧接着访问 yyy 时,为了给 yyy 腾出空间,xxx 被驱逐。随后对 xxx 的访问又会驱逐 yyy。每一次访问都会导致缓存失效。命中率骤降至零,系统的性能不再由快速的缓存命中时间决定,而是由极其缓慢的内存访问时间决定。​​平均内存访问时间(AMAT)​​是命中时间和失效时间的混合体,它会急剧膨胀到几乎等于完整的失效惩罚,从而使缓存的作用完全失效。

AMAT=th+(Miss Rate×tm)\text{AMAT} = t_{h} + (\text{Miss Rate} \times t_{m})AMAT=th​+(Miss Rate×tm​)

当失效率接近 1 时,AMAT 接近 th+tmt_{h} + t_{m}th​+tm​,即一次完整内存访问的时间。

另一个显而易见的失败模式是纯粹的容量问题。如果你的食谱突然需要同时使用三十种不同的食材,但你的台面只能放二十种呢?无论这些位置安排得多好,你都注定要不停地往返于储藏室。这就引出了程序​​工作集​​(working set)的概念:即程序在短时间内为取得进展而需要访问的数据和指令的集合。如果一个程序的活跃工作集大于缓存容量,它将遭受持续不断的​​容量失效​​。

我们可以用一个简单的“读教科书”的比喻来模拟这种情况。假设一个程序读取一个长长的、连续的指令“章节”,并且每隔一定数量的指令(τ\tauτ),它就必须回头参考一个大小为 nnn 的“笔记”子程序。为了让“笔记”在下次使用时仍留在缓存中,缓存必须足够大,不仅要能容纳笔记本身,还要能容纳在此期间读取的所有不同的“章节”文本。避免抖动所需的最小缓存大小 Mmin⁡M_{\min}Mmin​ 大约是整个工作集的大小:即笔记加上刚刚读取的章节部分。如果缓存小于这个大小,笔记的开头部分将被章节文本的末尾部分驱逐,每次调用子程序都会引发一连串的缓存失效。

步幅的微妙专制

你可能会认为,增加缓存的“灵活性”——允许多个内存位置映射到同一个组,这种设计称为​​组相联​​(set-associativity)——就能解决这些问题。如果我们的厨房台面允许,比如说,4个锅共享一个区域而不是只有1个,那么简单的冲突应该会消失。事实也的确如此。但是,一种更微妙、更有趣的专制可能源于我们程序的模式。

考虑科学计算中一个常见的操作:以固定的​​步幅​​(stride)扫描内存中的一个大数组,访问每第 ttt 个元素。你可能期望这些访问会均匀地分布在缓存的各个组中。但根据步幅大小、缓存几何结构和内存布局之间的关系,可能会发生一种奇异的现象:你所有的内存访问都可能“别名”或“冲突”到极少数的几个组中。

想象一下,以某种步幅遍历一个数组,使得每次访问都反复落在,比如说,第 0、8、16 和 24 组。即使你的缓存有数千个组,你实际上只使用了其中一小部分。在这几个组内的数据工作集很快就会增长到超过缓存的相联度(例如,对于一个4路组相联缓存,超过4个条目)。结果就是抖动。缓存的其余部分空置无用,而这几个不堪重负的组则因持续的驱逐而不堪重负。这是一个深刻的教训:缓存性能不仅关乎你访问了多少数据,还关乎这些访问的结构和节奏。令人惊奇的是,我们可以利用基础数论来预测这些“病态步幅”。关键在于步幅(以缓存行为单位)和组数之间的最大公约数(gcd⁡\gcdgcd)。当这个 gcd⁡\gcdgcd 值很大时,被利用的组数就很少,抖动的风险就很高。这揭示了​​缓存感知编程​​的重要性,即算法和数据结构的设计要与缓存“友好相处”,例如确保数据缓冲区的大小不会与缓存大小形成病态的对齐关系。

两个系统的故事:普遍原则

这种抖动现象并非 CPU 缓存所独有。它是资源竞争的一个普遍原则。让我们退后一步,看看两个看似不同的系统。

首先,考虑一个 Web 内容服务器,其缓存可以容纳 500 个热门项目。它服务的请求中,有 85% 是针对一个包含 2,000 个“热门”项目的集合。活跃工作集(2,000 个热门项目)是缓存容量(500)的四倍。在标准的最近最少使用(LRU)策略下,缓存中在任何时候都只会包含热门项目集的随机 25% 样本。命中率将不会是理想的 85%,而是会崩溃到大约 0.85×5002000=0.21250.85 \times \frac{500}{2000} = 0.21250.85×2000500​=0.2125。服务器发生抖动,不断地从磁盘获取项目,结果在它们被再次请求之前就被驱逐了。

其次,考虑一个拥有 10,000 个单位(页)物理内存的操作系统,试图运行三个进程,它们的工作集分别需要 4,000、5,000 和 3,500 页。总需求为 12,500 页。操作系统根本没有足够的内存。它将处于持续的​​分页活动​​(paging)状态,疯狂地在 RAM 和磁盘之间移动数据。CPU 大部分时间将处于空闲状态,等待磁盘,系统会感觉像被冻结了一样。这是典型的操作系统级抖动。

故事是相同的。无论是 Web 缓存还是操作系统内存管理器,当活跃工作集超过资源容量时,性能不会平稳下降——而是会断崖式下跌。解决方案必须针对根本原因:要么增加资源(购买更多 RAM、更大的缓存),要么更实际地,减少负载。对于操作系统来说,这意味着挂起其中一个进程以释放内存。对于 Web 缓存来说,这可能意味着使用更智能的​​准入控制​​,只缓存那些已证明其受欢迎程度的项目(例如,在第二次命中时),从而防止缓存被低效用项目污染。

多核之乱:一致性与共享

在现代多核处理器的世界里,出现了一种新的、更为恶劣的抖动维度。多个 CPU 核心通常共享某些级别的缓存,一个关键挑战随之而来:确保所有核心看到一致(或​​coherent​​)的内存视图。如果一个核心写入某个内存位置,所有其他在自己的缓存中持有该数据副本的核心都必须被通知,它们的副本现在已经过时了。基于失效的一致性协议通过发送消息来处理这个问题,这些消息强制其他核心丢弃它们的旧副本。这个机制虽然必要,但可能成为极其低效的根源。

这导致了两种由共享引起的抖动:

  1. ​​真共享​​(True Sharing):想象多个线程试图更新一个共享变量,比如一个计数器或一个随机数生成器种子。每当一个线程执行写操作,缓存一致性协议就会启动。它向所有持有该缓存行副本的其他核心发送失效消息。然后,该缓存行通过互连网络“弹跳”到执行写操作的核心。当下一个核心想要写入时,这个过程会重复。单个缓存行在核心之间剧烈地来回穿梭,造成一场一致性流量风暴,并有效地将并行线程串行化。这就是​​一致性抖动​​(coherence thrashing)。

  2. ​​伪共享​​(False Sharing):这种形式更为隐蔽。假设我们很聪明,为每个线程分配了各自的私有数据进行操作,从而避免了真共享。然而,如果这些独立的变量恰好位于同一个缓存行上,硬件就无法区分它们。线程1对其变量 A 的写操作将触发整个缓存行的失效。如果线程2的独立变量 B 恰好在同一行上,它的副本也会被失效,迫使其从内存中进行昂贵的重新获取。这些线程在逻辑上没有共享数据,但它们在“伪”共享一个缓存行。解决方法通常是添加​​填充​​(padding)——故意浪费一点空间,以确保每个线程的关键数据都位于其自己的私有缓存行上。

对抗一致性抖动的斗争是高性能并行软件设计的核心。一个简单的​​测试并设置(TAS)自旋锁​​,其中所有等待的线程反复检查同一个锁变量,是制造一致性抖动的完美配方。当锁被释放时,一次写操作会向所有等待的核心广播失效消息。相比之下,一个更复杂的基于队列的锁,如 ​​Mellor-Crummey and Scott (MCS) 锁​​,让每个线程在自己的私有标志上自旋。锁通过一次单一的、有针对性的写操作,优雅地从一个线程传递到下一个线程。失效风暴被安静的点对点消息所取代,极大地提高了可扩展性。同样的逻辑也适用于操作系统线程调度:为了最小化共享缓存上的抖动,将一个内存密集型线程与一个轻量级线程配对,远比将两个重度消费者组合在一起要好得多。

层层嵌套:基础设施的抖动

抖动最令人费解的方面在于它是一个递归问题。缓存不仅用于程序数据;它们被广泛用于加速计算机的基本机制。而这些机制本身也可能发生抖动。

为了执行一条引用内存的指令,处理器必须将一个​​虚拟地址​​(程序看到的地址)转换为一个​​物理地址​​(在 RAM 中的实际位置)。这个转换过程涉及读取一系列称为​​页表​​的数据结构。因为从内存中读取这些数据很慢,处理器使用一个特殊的、快速的缓存来存放最近的翻译结果,即​​转译后备缓冲器(TLB)​​。但如果发生 TLB 失效会怎样?处理器必须通过从内存中读取页表来执行一次“页表遍历”(page walk)。而为了加速那个过程,现代 CPU 又有另一层缓存,通常称为​​页表遍历缓存​​,专门用于存放页表条目。

这些页表遍历缓存会发生抖动吗?绝对会。可以构建这样一种“完美风暴”:两个进程通过交错访问精心选择的虚拟地址,使其页表条目映射到页表遍历缓存中的相同组。然后它们开始相互驱逐对方的翻译数据,使虚拟内存本身的基础设施发生抖动。

这个问题的分形特性以另一种微妙的形式出现在​​虚拟索引、物理标记(VIPT)​​的缓存中。在这些常见的设计中,缓存索引来自虚拟地址,但标记检查使用物理地址。这产生了一个危险的可能性:两个映射到同一个物理页的不同虚拟地址(这种情况称为“别名”)最终可能具有不同的缓存索引。这将导致相同的物理数据被浪费地存储在两个不同的缓存位置,从而引发抖动。解决方案是一个硬件-软件协同设计的美妙范例,称为​​页着色​​(page coloring)。操作系统智能地控制将哪些物理页分配给虚拟地址,确保物理地址中影响缓存索引的位(“颜色”)与虚拟地址的相应位相匹配。这种协作防止了别名,并展示了对抗抖动的斗争是如何深刻地融入现代计算系统的结构之中的。

从简单的冲突到步幅的专制,从操作系统的内存压力到伪共享的混乱,抖动是在许多不同机器中出现的同一个幽灵。它是一个系统的工作负载与其资源节奏失调的标志。理解其原理不仅仅是一项学术练习;它是在复杂、并行和深度分层的现代计算世界中释放真正性能的关键。

应用与跨学科联系

现在我们已经探索了缓存的内部工作原理,你可能会想:“这是一个巧妙的工程设计,是计算机内部的一些管道装置。”但这样想就只见树木,不见森林了!缓存不仅仅是一个组件;它是一个上演着宏大而微妙戏剧的舞台。缓存的原理,特别是抖动的病态现象,并不仅限于硬件设计的深奥世界。它们回响在计算的每一个层面,从我们编写一个简单循环的方式,到大型数据中心的架构,再到关乎生死的实时系统的设计。它是一个统一的概念,一把在最意想不到的地方解锁性能的秘钥。让我们踏上旅程,看看它将我们带向何方。

最简单的罪过:忽视内存布局

我们陷入缓存低效最基本的方式,就是完全不考虑我们的数据在广阔、线性的内存空间中是如何布局的。想象你有一个巨大的棋盘,你需要检查每一个方格。你可以一行一行地走,这是一条平滑、连续的路径。或者,你可以先检查每一行的第一个方格,然后是每一行的第二个方格,以此类推。第二条路径涉及大量的跳跃!

计算机的内存就像那个棋盘,一行接一行地存储在一条长线上。当你访问一块数据时,缓存出于乐观,不会只抓取那一个片段。它会抓取一整“缓存行”的相邻数据,赌你很快就会需要它。这被称为​​空间局部性​​。

考虑一个存储在内存中的矩阵。标准的“行主序”布局意味着同一行的元素在内存中是相邻的。遍历一行就像沿着棋盘平稳地行走——一个美妙的、缓存友好的操作。但如果你需要按列来处理矩阵呢?要从一列中的一个元素移动到下一个元素,你必须跳过一整行的数据。如果一行的宽度超过一个缓存行——而且几乎总是如此——那么每一次访问都会落入不同的缓存行。缓存的乐观预取被浪费了;对于你请求的每一个数字,系统都必须进行一次缓慢的主存之旅。这还不完全是抖动,但它是一个前奏,一场代价高昂的步幅内存访问之舞,让性能跪地求饶。

这不仅仅是教科书上的练习。这个原理是科学计算的核心。在用于模拟从桥梁到飞机等一切事物的有限元法(FEM)中,工程师们从数千个微小的单元矩阵组装成一个巨大的全局“刚度矩阵”。这个组装过程涉及将数据从小的局部矩阵散布到巨大的全局矩阵中。如果全局矩阵以行导向格式(如压缩稀疏行 CSR)存储,一个天真的组装算法可能会在内存中到处跳跃,以杂乱无章的顺序写入不同的行。然而,一个聪明的算法会做得更好。它首先对每个元素数据的目标地址进行排序,然后以优美、顺序、逐行的方式执行写操作。它使访问模式遵循数据的布局,驯服了内存这头猛兽,将潜在的缓存噩梦变成了一场平滑、高效的操作。原理是相同的:了解你的内存布局!

当多即是少:并行世界中的抖动

在追求性能的道路上,我们的第一直觉是同时做更多的事情。我们使用拥有数千个核心的强大图形处理单元(GPU),或者我们告诉编译器展开我们的循环以在每个周期执行更多指令。通常,这会创造奇迹。但缓存对这种蛮力方法施加了一个微妙而深刻的限制。有时,试图做得更多反而会让一切变得更慢。

想象一下,一群工人在一条装配线上,都共享一个狭小的工作台(L1 缓存)。如果只有几个工人,他们可以协调并在工作台上保留他们需要的工具。但如果你让太多的工人挤在这条线上呢?他们开始互相碰撞,每个人刚拿起一个工具,另一个工人就立刻把它抢走,为自己的工具腾出空间。工作台上一片混乱,每个人花在取工具上的时间比工作的时间还多。

这正是 GPU 上可能发生的情况。GPU 性能的一个关键是“占用率”(occupancy)——保持尽可能多的线程(组织成“线程束”warps)处于活动状态,以隐藏从内存中获取数据所需的时间。因此,我们试图最大化占用率。但每个线程束都有一个它需要在快速的 L1 缓存中就近使用的“工作集”。当我们增加越来越多的线程束时,它们合并的工作集可能会变得比缓存本身还要大。突然之间,缓存开始抖动。线程束开始驱逐彼此的数据,缓存失效率急剧上升,整个处理器因内存请求而窒息,陷入停顿。令人惊讶的结果是,存在一个最佳占用率——一个“甜蜜点”。将并行度推过这个点是适得其反的;性能不仅不会稳定,反而会崩溃。多并不总是更好。

这个原则甚至适用于代码本身!编译器使用一种名为“循环展开”的技巧来减少循环开销并暴露更多指令以供并行执行。它可能会将一个一次处理一项的循环转变为一次处理四项或八项的循环。但指令本身必须存放在某个地方——指令缓存。当你越来越激进地展开一个循环时,该循环的代码会变得越来越大。最终,展开的循环可能会变得太大而无法容纳在指令缓存中。处理器在执行循环的过程中,发现它需要的下一条指令已经被驱逐了。它必须停顿下来,从主存中再次获取它,然后一次又一次地重复这个过程。这个本意是用来加速的优化,却成了指令缓存抖动的根源,性能因此受损。

系统的反击:宏观尺度上的抖动

到目前为止,我们谈论的都是 CPU 私有的小缓存。但缓存原理是分形的;它在操作系统这个更大的尺度上再次出现。操作系统使用主存(RAM)的一大块作为“页缓存”,用来存放从慢速磁盘读取的文件片段。在这里,RAM 是“快速”缓存,而磁盘是“慢速”仓库。就像 CPU 缓存一样,页缓存也会抖动。

想象一下,你的任务是在一台只有 48 GiB 可用 RAM 的机器上扫描一个 800 GiB 的日志文件。你可能会天真地将整个文件映射到内存中然后开始读取。操作系统将开始用文件的开头部分填充 48 GiB 的页缓存。但当你读到第 50 GB 时,需要空间的操作系统已经开始从缓存中驱逐前几个 GB 的数据了。对于简单的顺序扫描,这并非灾难,因为你不会再需要那些旧数据。但如果你的访问模式更复杂呢?或者如果多个进程同时在做这件事呢?页缓存可能会把所有时间都花在从磁盘读取数据上,然后在片刻之后就把它扔掉,这种情况被称为​​页缓存抖动​​。聪明的程序员可以避免这种情况。他们可以按已知能装入内存的块来处理文件,更重要的是,他们可以给操作系统一些提示——使用像 madvise 这样的系统调用——告诉它:“我用完这块文件了,你可以回收它。”这就像在每一步之后整理你的工作台,它将一个抖动的烂摊子变成了一个高效的流式处理过程。

当这些不同层次的缓存相互作用时,会出现最美妙——也最危险——的场景。考虑外排序问题,即你必须对一个大到无法装入内存的数据集进行排序。一种标准技术是 kkk 路合并,你从磁盘上的 kkk 个已排序的块中读取数据,并将它们合并成一个单一的排序输出。一个复杂的应用程序可能会管理自己的输入缓冲区以重叠 I/O 和计算。与此同时,操作系统也在试图帮忙,通过在其页缓存中缓存来自那 kkk 个流的数据。

但现在我们有了一个潜在的冲突。应用程序从流 1 中读取一点,然后从流 2 中读取一点,依此类推,直到流 kkk。从操作系统的角度来看,这看起来像是对 kkk 个不同文件的循环访问模式。如果操作系统试图为所有 kkk 个流保持“热”状态的总数据量(其预读窗口)大于页缓存,那么操作系统的最近最少使用(LRU)驱逐策略将灾难性地失败。当操作系统回到流 1 时,它为其预取的数据早已被为其他流的数据腾出空间而驱逐了!页缓存发生抖动,提供零收益,而应用程序自己的缓冲区则持有数据的第二个副本。我们为没有收益的事情付出了双倍的内存成本。专家的解决方案是深刻的:告诉操作系统别挡道。通过使用“直接 I/O”,应用程序完全绕过页缓存并完全控制 I/O,从而消除了抖动和冗余缓存。这是一种认识:对于某些访问模式,通用的操作系统缓存弊大于利。

精妙的舞蹈:调度、公平性与干扰

在现代多核处理器中,在不同核心上运行的任务并非孤立存在。它们共享资源,最显著的是末级缓存(LLC)。这种共享产生了一种微妙且通常不可见的干扰形式。

想象两个被调度在 CPU 上运行的任务。任务 A 是一个“好邻居”——它的工作集很小,能很好地放入缓存中。任务 B 是一个“坏邻居”——它是一个流式应用程序,它横扫内存,在运行时将共享缓存中的所有东西都冲刷出去。比例份额调度器的工作是按任务的“权重”比例分配 CPU 时间。假设我们有两个低权重的任务,我们的好邻居任务 A 和另一个计算密集型任务 C。它们被给予相等的 CPU 时间份额。然而,一个高权重的“坏邻居”任务也在运行。每当任务 A 在坏邻居之后立即运行时,它发现缓存已被清空。它遭受了一场缓存失效风暴,并且在它的时间片内几乎没有取得任何进展。任务 C 对缓存状态不那么敏感,因此不受影响。一天下来,尽管它们获得了相同数量的 CPU 时间,任务 A 完成的工作量远少于任务 C。这“公平”吗?一个标准的调度器会说“是”;它交付了所承诺的 CPU 时间。但从用户的角度来看,答案是否定的。这种“吵闹邻居”现象,即一个任务的内存行为降低了另一个任务的性能,是现代操作系统设计中的一个深层问题,并挑战了我们对公平性的定义。

在为从飞行控制到医疗设备等一切提供动力的实时系统中,这种干扰成为一个生死攸关的问题。在这些系统中,满足截止日期不是一个建议;它是一个要求。考虑一个高优先级任务 τ1\tau_1τ1​ 和一个中优先级任务 τ2\tau_2τ2​,它们的内存访问模式恰好冲突,导致每当一个抢占另一个时就会发生抖动。像最早截止期优先(EDF)这样的调度器在理论上被证明是最佳的,但它不了解缓存。它可能会频繁地抢占 τ2\tau_2τ2​ 以运行一个新到达的、更高优先级的 τ1\tau_1τ1​ 任务。每次抢占都会引发一阵缓存抖动,增加了宝贵的微秒级开销。这种开销会累积,导致任务错过其截止日期,从而导致系统故障。解决方案是一个巧妙的权衡:我们可以使 τ2\tau_2τ2​ 暂时不可被 τ1\tau_1τ1​ 抢占。这违反了 EDF 调度的“纯粹”最优性,但防止了抖动,减少了开销,并最终使系统可调度和安全。这是一个为了实用的、现实世界的稳定性而牺牲理论纯粹性的美妙例子。

宏伟设计:为内存层次结构塑造算法

我们旅程的最终教训是,我们不能将内存视为一个平坦、统一的空间。我们必须在设计算法和数据结构时考虑到缓存的特性。

这方面的一个经典表现是“结构数组”(AoS)与“数组结构”(SoA)的困境。假设你正在处理多通道音频。你可以将数据存储为一个时间样本的数组,其中每个样本是一个包含所有通道值的结构(AoS)。或者,你可以为每个通道的时间样本设置一个单独的数组(SoA)。如果你的算法一次性处理所有通道的一个时间片,AoS 布局是完美的;你需要的所有数据在内存中都是连续的。在这种情况下,SoA 布局将是一场灾难,迫使你以大步幅在内存中跳跃,导致冲突失效。没有一种布局是普遍更优的;正确的选择完全取决于你算法的访问模式。数据布局即是算法设计。

也许这种协同设计最精妙的例子来自数值线性代数领域。用于求解大型稀疏方程组的多前沿方法涉及遍历一个称为“消元树”的数学结构。算法的工作集中在与该树中节点相关的所谓“前沿矩阵”上。一个关键步骤是从其子节点的贡献中组装父节点的前沿矩阵。现在,假设树中的两个兄弟节点都有非常大的前沿矩阵,大到它们无法同时放入缓存中。一种“广度优先”的树遍历方法,看似自然,将涉及在第一个兄弟节点上做一点工作,然后切换到第二个,然后再回到第一个,依此类推。每次切换都会迫使另一个兄弟节点的巨大前沿矩阵被从缓存中驱逐,并在稍后重新加载——这是典型的抖动。缓存感知的解决方案是使用“后序”(或深度优先)遍历。你完成与一个兄弟节点相关的所有工作——一次性加载其前沿矩阵,并为其所有子节点的贡献重用它——然后再去碰另一个兄弟节点。这改变了抽象树的遍历顺序,一个纯粹的算法选择,从根本上改变了内存访问模式,并实现了巨大的性能提升 ([@problem-id:3542730])。

内存的交响曲

因此我们看到,卑微的缓存不仅仅是管道。它是塑造整个计算领域性能的无形之手。缓存抖动是当我们的算法与硬件的物理现实不和谐时产生的不协和音。但是,当我们理解了它的原理,我们就可以将这种不协和音转变为一首交响曲。我们可以设计出与内存层次结构优雅共舞的数据结构、算法、调度器和整个系统。其美妙之处在于,看到这一个简单思想——将你正在使用的东西放在近处——在从单个矩阵乘法到实时操作系统的调度等各个层面回响和共鸣,揭示了计算机科学深刻而优雅的统一性。