跳转至

数据流图快速入门

🤖 Claw创作 - 本文由OpenClaw AI助手协助翻译,深入解析数据流图与缓存延迟对性能的影响

本文翻译自Fabian Giesen (ryg)的经典文章,通过数据流图分析程序性能,理解缓存延迟如何影响循环吞吐量,以及如何编写对缓存友好的代码。

📋 目录

🎯 引言

在编写"以太多方式读取位,第3部分"的过程中,我意识到我写了很多与位I/O完全无关的背景材料,这些材料确实值得单独写一篇文章。这就是那篇文章。

我关注的问题相当容易陈述:假设我们有一些C++代码,我们试图理解(或许改进)其性能。一个好的第一步是分析它,这会给我们一些提示哪些部分慢,但不一定是为什么。在基本层面上,任何类型的分析(或其他测量)都是描述性的,而不是预测性的:它可以告诉您现有系统的行为方式,但如果您正在设计一个超过几个下午工作量的东西,您可能没有时间或资源来实现5或6个完全不同的设计替代方案,选择恰好效果最好的一个,然后扔掉其余的。您应该能够从算法草图中做出明智的决策,而不必实际编写完整的实现。

我想特别强调的一点是,实验加上前后测量并不能替代有用的性能模型。这类测量可以告诉您改进了多少,但不能告诉您是否达到了应有的水平:如果我告诉您,通过调整一些配置文件,我成功地将Web服务器每秒服务的请求数翻了一番,这听起来很棒。如果我给您额外的信息,部署此修复后,我们现在达到了惊人的每秒1.5个请求;这听起来就不那么好了;拥有绝对的参考尺度很重要!

这对于微基准测试尤其如此。对于微基准测试,就像审判律师在交叉询问中一样,您永远不应该问一个您不知道答案的问题(或者至少对它有一个很好的了解)。现实世界的系统通常太复杂且相互交织,无法仅从表面测量中理解。如果您完全不知道系统如何工作,您不知道正确的问题是什么,也不知道如何提问,您得到的任何答案充其量是不透明的,如果不是 outright 垃圾。微基准测试是确认现有模型是现实良好近似的有用工具,但在构建这些模型时并不是很有帮助。

🖥️ 机器模型

因此,如果我们想比仅仅盯着C/C++代码并做一些 hand-waving 更深入,我们需要开始看一个稍微低一些的抽象层次,并定义一个比"语句一个接一个执行"更复杂的机器模型。如果您只对单个特定处理器感兴趣,一种选择是使用您能找到的该芯片的任何文档和工具,并详细分析您的代码。如果您愿意全力以赴进行微架构调整,那确实是正确的方法,但从查看C++代码来看,这是一个巨大的步骤,在大多数情况下完全是 overkill。

相反,我将要做的是使用一个简化的机器模型,它允许我们对简单的计算密集型循环的行为做出定量预测,这个模型简单易描述,但仍然为我们提供了更复杂场景的大量有用基础。以下是我将使用的:

  • 我们有一组无限的64位整数通用寄存器,我将用诸如rSomething的名称来引用。任何没有小写r前缀的"标识符"要么是符号常量,要么是标签之类的东西。

  • 我们有通常的64位整数算术和逻辑运算。所有操作可以在两个寄存器之间或一个寄存器和立即常量之间执行,结果放在另一个寄存器中。所有算术使用二进制补码。为简单起见,所有64位值都允许作为立即常量。

  • 有一个扁平的、字节粒度的64位地址空间,指针仅表示为整数。

  • 所有内存访问都需要显式的加载和存储操作。内存访问大小为8、16、32或64位,并且可以(为方便起见)使用小端或大端字节序,当请求时。其中一个是默认值,但两者成本相同。窄存储存储寄存器的最低有效位;窄加载零扩展到64位。加载和存储有一些常见的寻址模式(我将在使用时介绍)。支持未对齐的加载和存储。

  • 有无条件分支,只是跳转到给定位置,和有条件分支,比较寄存器与另一个寄存器或立即常量,如果条件为真,则分支到给定目的地。

代码将以伪C形式编写,每行最多一条指令。以下是一个简要示例,展示了我心中的想法:

loop: // 标签
 rFoo = rBar | 1; // 按位逻辑或
 rFoo = lsl(rFoo, 3); // 逻辑左移
 rBar = asr(rBar, rBaz); // 算术右移
 rMem = load64LE(rBase + rFoo); // 小端加载
 store16BE(rDest + 3, rMem); // 大端存储
 rCount = rCount - 1; // 基本算术
 if rCount != 0 goto loop; // 分支

移位使用显式助记符,因为存在不同类型的右移,并且在这个抽象层次上,寄存器通常被视为无类型的位包。我将在我们遇到时介绍其他操作和寻址模式。我们到目前为止看到的非常接近经典的RISC指令集,尽管我将允许比一些更 minimalist 的设计更大的寻址模式集,并要求在所有加载和存储上支持未对齐访问。它在精神上也接近您期望在现代编译器后端早期看到的IR(中间表示):比LLVM IR稍微低级一些,与早期阶段的LLVM Machine IR或GCC RTL相当。

这个模型要求我们明确区分保存在寄存器中的值和内存访问,并将控制流扁平化为由分支连接的基本块。但它仍然相对容易查看一小段C++代码,并例如弄清楚它归结为多少条算术指令:只需计算操作数。

作为下一步,我们现在可以指定一个虚拟处理器来配合我们的指令集,但我不想真正深入那个细节层次;与实际架构的工作方式相同,我将要求运行程序的结果(我们模型中最终的寄存器和内存内容)必须如同我们按顺序一条一条执行指令一样(as-if规则)。除此之外,积极的实现可以随意削减角落,只要不被抓住。我们将假设我们处于一个环境中——编译器/工具和处理器本身的组合——使用流水线并尝试提取指令级并行性以实现更高的性能,特别是:

  • 指令可以彼此独立启动,并需要一些时钟周期来完成。对于一条指令开始执行,它所依赖的所有操作数都需要已经被计算出来。只要尊重依赖关系,所有重新排序都是有效的。

  • 有一些限制W("宽度")关于每个时钟周期我们可以启动多少新指令。在飞行中的指令不会相互干扰;只要我们有足够的独立工作,我们每个周期可以启动W条新指令。我们将把W视为变量。

  • 内存操作有4个周期的延迟,意味着加载的结果在加载发出后4个周期可用,而读取先前存储写入的字节的加载可以在存储后4个周期发出。如果您想知道,这是L1缓存命中的相当典型的延迟。

  • 分支(有条件或无条件)算作一条指令,但它们的延迟是可变的。无条件分支或易于预测的分支(如长时间运行循环中的循环计数器)的有效延迟为0个周期,意味着被分支到的指令可以与分支本身同时发出。不可预测的分支具有非零成本,取决于它们有多不可预测——我甚至不会尝试在这里更精确。

  • 其他每条指令的延迟为1个时钟周期,意味着结果在下一个周期可用。

这个模型可以理解为近似于数据流架构、具有非常大 issue 窗口(和无限快速前端)的乱序机器,或运行由 Sufficiently Smart Scheduler 编译的代码的静态调度顺序机器。(实际存在的那种;例如,实现软件流水线的编译器。)

此外,我假设虽然有显式的控制流(与纯数据流机器不同),但有一个分支预测机制,允许机器任意提前猜测采取的控制流路径。当这些猜测正确时,分支实际上是免费的,除了仍然占用一个指令槽,在此期间机器检查其预测是否正确。当猜测不正确时,机器恢复所有沿错误猜测路径进行的计算,并需要一些时钟周期来恢复。如果分支预测这个想法对您来说是新的,我会向您推荐Dan Luu关于这个主题的优秀文章,它解释了计算机如何以及为什么会这样做。

这些模型假设的最终结果是,虽然控制流存在,但它处于次要地位:其唯一可观察的效果是,当我们猜错时,它有时会导致我们丢弃一堆工作并短暂暂停以恢复。另一方面,数据流——指令之间的依赖关系,以及满足这些依赖关系所需的时间——是 front and center。

📊 数据流图

为什么这种强调?因为数据流和数据依赖可以被视为特定计算结构的基本表达,无论它是在小型顺序机器、较大的超标量乱序CPU、GPU还是硬件(无论是手工焊接的数字电路、FPGA还是ASIC)上完成的。数据流和跟踪数据依赖的形状是机器本身和针对它们的编译器的组织原则。

这些依赖关系自然以图形形式表达,单个操作是节点,数据依赖由有向边表示。在本文中,我将让依赖操作指向它们依赖的操作,有向边标有它们的延迟。为了减少混乱,我只有在延迟不是1时才写延迟数字。

有了所有这些介绍,为了看看这一切的意义,让我们从一个简单的、短的玩具程序开始,它只是对存储在rCurPtr(开始指向第一个元素)和rEndPtr(指向最后一个元素之后的一个)中的两个指针划定的某个数组中的64位整数求和,习惯的C++迭代器风格。

🔢 数组求和示例

loop:
 rCurInt = load64(rCurPtr); // 加载
 rSum = rSum + rCurInt; // 求和
 rCurPtr = rCurPtr + 8; // 前进
 if rCurPtr != rEndPtr goto loop; // 完成?

我们从当前指针加载一个64位整数,将其添加到当前运行总和寄存器rSum中,将指针递增8字节(因为我们获取了一个64位整数),然后循环直到完成。现在假设我们运行这个程序进行短暂的6次迭代,并绘制相应的数据流图(点击查看完整尺寸版本):

数组求和数据流图

注意我按它们可以执行的最早周期将节点分组为层级。第一次迭代的"加载"和"前进"可以立即执行;第一次迭代的"完成?"检查更新后的rCurPtr,这仅在1个周期后才知道;第一次迭代的"求和"需要等待加载完成,这意味着它只能在整整4个周期后开始。

正如我们所见,在前四个周期中,我们所做的只是不断发出更多加载并前进指针。直到第4个周期,第一次加载的结果才可用,所以我们实际上可以做一些求和。之后,每个周期再完成一次加载,允许我们依次将另一个整数添加到运行总和中。如果我们让这个过程持续更长时间,所有中间迭代都会像周期4和5那样:在我们的稳定状态中,我们每个周期发出循环中所有四条指令的副本,但来自不同的迭代。

我们可以从中得出一些结论:首先,我们可以看到这个四条指令的循环实现了每个时钟周期将一个整数添加到总和的稳态吞吐量。我们需要几个周期进入稳定状态,然后在最后再需要几个周期排出流水线,但如果我们从周期0开始并继续运行N次迭代,那么最终总和将在周期N+4完成。其次,即使我说我们的模型具有无限前瞻性,并且可以自由地每个周期发出任意多条指令,我们"只"最终每个周期最多使用4条指令。这里的限制器最终是地址递增("前进");我们每次加载后递增指针,根据我们的成本模型,这个递增需要一个周期的延迟,因此循环下一次迭代中的加载(想要使用更新后的指针)最早可以在下一个周期开始。

这是一个关键点:这个循环中延迟最长的指令肯定是加载,为4个周期。但这不是限制因素;我们可以围绕加载进行调度,稍后再进行求和。这里实际的问题是指针前进;程序顺序中在它之后的每条指令都直接或间接依赖于它,因此,它的1个周期延迟决定了下一次循环迭代可以开始的时间。我们说它在关键路径上。特别是在循环中,我们通常区分迭代内依赖(同一迭代内指令之间的依赖,比如"求和0"依赖于"加载0")和迭代间或循环携带的依赖(比如"求和1"依赖于"求和0",或"加载1"依赖于"前进0")。迭代内依赖可能会延迟该迭代内的指令很多,但迭代间依赖决定了我们多快可以开始循环的下一次迭代,这通常更重要,因为它倾向于打开更多独立指令来处理。

好消息是,W=4实际上是当前(截至本文撰写时的2018年初)乱序设计中每个周期解码/退休指令的相当典型的数量,这里的指令混合(1次加载、1次分支、2条算术指令)也是一种很可能能够在现实的4宽解码/退休设计上并行发出的指令混合。虽然许多机器可以在短时间内发出比这多得多的指令,但每个周期4条指令的稳定状态绝对是好消息。因此,即使我们没有充分利用我们理论机器的无限并行计算能力,在实际意义上,我们做得还不错,尽管在真实机器上我们可能希望应用一些更多的转换;见下文。

因为这些真实世界的机器不能同时启动任意数量的指令,我们有另一个关注点:吞吐量。假设我们在一个W=2的处理器上运行相同的循环,即每个周期只能启动两条指令。因为我们的循环有4条指令,这意味着我们不可能比每两个时钟周期更频繁地开始新的循环迭代,限制因素不是数据依赖,而是我们想象中的处理器每个时钟周期可以执行的指令数量;我们是吞吐量受限的。在W=3的机器上,我们也是吞吐量受限的,每个时钟周期发出3条新指令的稳定状态,我们可以每4/3≈1.33个周期开始处理一次新的迭代。

🔗 链表求和示例

对于下一个例子,我们将看看已经变成每个人最喜欢的数据结构 punching-bag,链表。让我们做与之前完全相同的任务,只是这次整数存储在单向链表中,而不是布局为数组。我们首先存储一个64位整数,然后存储一个64位指针指向下一个元素,列表的末尾由存储在rEndPtr中的特殊值表示,如前所述。我们还假设列表至少有1个元素。相应的程序如下所示:

loop:
 rCurInt = load64(rCurPtr); // 加载整数
 rSum = rSum + rCurInt; // 求和
 rCurPtr = load64(rCurPtr + 8); // 加载下一个
 if rCurPtr != rEndPtr goto loop; // 完成?

与之前非常相似,只是这次,我们不是递增指针,而是进行另一次加载以获取"下一个"指针。如果我们进行这一行更改,数据流图会发生以下情况:

链表求和数据流图

从连续数组切换到链表意味着我们必须等待加载完成才能开始下一次迭代。因为加载在我们的模型中有4个周期的延迟,这意味着我们不能比每4个周期更频繁地开始新的迭代。对于我们的4条指令循环,我们甚至不需要任何指令级并行性来达到该目标;我们不妨每个周期只执行一条指令,仍然达到相同的整体吞吐量。

现在,这个例子,其短的4条指令循环,是相当极端的;如果我们的循环总共有12条指令,并且工作得很好,相同的数字很可能最终平均每个时钟周期3条指令,这还不错。但这里的基本问题是一个 nasty 的问题:因为我们最长延迟的指令在迭代之间的关键路径上,它最终决定了整体循环吞吐量。

⏱️ 缓存延迟的影响

在我们的模型中,我们仍然主要关注计算密集型代码,内存访问非常简单:没有具有不同缓存级别的内存层次结构,所有内存访问花费相同的时间。如果我们有一个更现实的模型,我们还必须处理一些内存访问花费比4个周期长得多的时间来完成的事实。例如,假设我们有三个缓存级别,并且在底部,DRAM。坚持4的幂次主题,假设L1缓存命中需要4个周期(即我们当前的内存访问延迟),L2命中需要16个周期,L3命中需要64个周期,实际的内存访问需要256个周期——就目前而言,截至本文撰写时,这些数字对于高频桌面CPU在中等内存子系统负载下大致在正确的数量级上。

找到工作让机器在其他方面忙碌接下来的4个周期(L1命中)通常不是一个大问题,除非我们有一个非常短的循环和不利的依赖结构,如上面的例子。完全覆盖L1未命中但L2命中的16个周期有点棘手,需要更大的乱序窗口,但当前的乱序CPU有那些窗口,并且只要有足够的其他独立工作并且沿途没有太多难以预测的分支,事情就会顺利进行。对于L3缓存命中,我们通常很难找到足够的独立工作来在等待结果期间让核心有用地忙碌,如果我们实际上一直未命中到DRAM,那么在我们的当前模型中,机器几乎肯定会停滞;也就是说,有许多周期根本没有执行任何指令,就像上面图中的间隙一样。

因为链表有这种将内存访问延迟放在关键路径上的 nasty 习惯,它们有"因为它们对缓存不好"而慢的名声。现在虽然大多数有缓存的CPU肯定更希望您顺序迭代数组,但我们必须小心我们如何思考它。为了详细说明,假设我们有另一个求和内核,这次处理指向整数的指针数组,以计算指向值的总和。

loop:
 rCurIntPtr = load64(rCurPtr); // 加载指针
 rCurInt = load64(rCurIntPtr); // 加载整数
 rSum = rSum + rCurInt; // 求和
 rCurPtr = rCurPtr + 8; // 前进
 if rCurPtr != rEndPtr goto loop; // 完成?

这次,我将修剪数据流图,只显示当前迭代及其与早期和后期迭代的直接依赖关系,否则这些更复杂的图会很快变得混乱和不可读:

数组间接求和数据流图

快速查看该图显示我们,来自不同迭代的相同指令的副本都间隔1个周期;这意味着在稳定状态,我们将再次每个时钟周期执行一次循环迭代,这次发出5条指令而不是4条(因为循环中有5条指令)。就像链表情况一样,这里的指针间接允许我们(如果我们愿意)跳遍内存(沿途可能发生缓存未命中),但有一个关键区别:在这个设置中,我们可以在等待第一次内存访问完成时,继续设置未来的循环迭代并启动更多加载。

为了解释我的意思,让我们假装每一个"加载整数"都未命中L1缓存,但命中L2缓存,所以它的实际延迟是16个周期,而不是4。但16个周期的延迟只是在发出加载和获得结果之间需要16个周期;我们可以在此期间继续发出其他加载。所以唯一发生的事情是,上图中的"求和k"发生得晚12个周期。在稳定状态,我们仍然每个时钟周期启动两次新加载;其中一些最终花费更长时间,但这并不妨碍我们每个周期开始新的循环迭代。

链表和间接求和示例都有机会(如果它们愿意)跳遍内存;但在链表情况下,我们需要等待前一次加载的结果才能开始下一次,而在间接求和情况下,我们可以很好地重叠不同迭代的等待时间。因此,在间接求和情况下,达到最终总和的额外延迟基本上由我们遇到的最差单次迭代决定,而在链表情况下,每一次缓存未命中都使我们的最终结果更晚(并花费我们吞吐量)。

根本问题不是链表遍历可能最终大量未命中缓存;虽然这不理想(并可能在其他方面花费我们),但更严重的问题是任何这样的缓存未命中都阻止我们在其他地方取得进展。有很多缓存未命中不一定是个问题,如果我们能重叠它们;如果我们有很长一段时间不能做其他事情,因为其他所有我们能做的事情都依赖于那一次缓存未命中的加载,那就是个问题。

事实上,当我们遇到这种问题时,我们最好的选择就是完全切换到做其他事情。这就是具有同时多线程/硬件线程("超线程")的CPU和基本上所有GPU所做的:构建机器使其可以处理来自多个指令流(线程)的指令,然后如果其中一个线程现在没有真正取得进展,因为它正在等待什么,就暂时处理其他事情。如果我们有足够的线程,那么我们 hopefully 可以填补这些间隙,并且总是有一些有用的事情要做。如果我们有许多线程并且并不真正担心时间切片引起的额外延迟,这种权衡是值得的,这就是为什么这种方法在 throughput-centric 架构中特别受欢迎,这些架构不担心轻微的延迟增加。

🔄 循环展开优化

但让我们回到我们原始的整数求和代码一秒钟:

loop:
 rCurInt = load64(rCurPtr); // 加载
 rSum = rSum + rCurInt; // 求和
 rCurPtr = rCurPtr + 8; // 前进
 if rCurPtr != rEndPtr goto loop; // 完成?

我们这里有一个四指令的内核。在这四条中,两条("加载"和"求和")做我们想要完成的实际工作,而"前进"和"完成?"只是实现循环本身,本质上是开销。这种循环是展开的主要目标,我们将循环的两次或更多次迭代合并为一次以减少开销比例。让我们现在不担心设置或当数组中的元素数为奇数时该做什么,只关注循环的"肉"。那么2×展开的版本可能如下所示:

loop:
 rCurInt = load64(rCurPtr); // 加载偶数
 rSum = rSum + rCurInt; // 求和偶数
 rCurInt = load64(rCurPtr + 8); // 加载奇数
 rSum = rSum + rCurInt; // 求和奇数
 rCurPtr = rCurPtr + 16; // 前进
 if rCurPtr != rEndPtr goto loop; // 完成?

其数据流图如下:

2倍展开数组求和数据流图

注意,即使我在一次迭代中两次写入rCurInt,这构成了写后写(WAW)或"输出依赖",但第一次和第二次rCurInt的加载和求和之间没有实际的数据流,因此加载可以很好地并行发出。

这还不错:我们现在每次迭代有两次加载,花费6N条指令对2N个整数求和,意味着我们每条求和的整数花费3条指令,而我们原始的内核花费4条。这是一个改进,并且(除其他外)意味着虽然我们原始的整数求和循环需要一台每个时钟周期维持4条指令的机器才能达到全吞吐量,但我们现在可以在只做3条指令每时钟周期的较小机器上达到相同的吞吐量。这绝对是进展。

然而,有一个问题:如果我们查看图表,我们可以看到我们确实可以每个时钟周期开始一对新加载,但求和有问题:我们在循环中有两次依赖的加法,并且从"求和偶数k"和"求和偶数k+1"之间的关系可以看出,计算的实际求和部分仍然每次迭代花费2个周期。在我们具有无限前瞻性的理想化数据流机器上,这只是意味着所有加载都将被预加载,然后计算最终总和的加法以自己的速度进行;结果最终将可用,但仍然需要略多于2N个周期,不比我们在循环的原始版本中快。在更现实的机器上(只能前瞻有限数量的指令),我们最终将无法开始新的循环迭代,直到一些旧的循环迭代完成。无论如何,我们已经从每个周期将一个整数添加到总和变为每两个周期将两个整数添加到总和。我们可能花费更少的指令来这样做,这是一个不错的安慰奖,但这不是我们想要的!

发生的情况是,展开改变了关键路径。以前,迭代之间的关键路径经过指针前进(或者更准确地说,有两条关键路径,一条经过指针前进,一条经过求和,它们长度相同)。现在我们每个项目做一半的前进次数,这不再是问题;但我们现在顺序求和这些整数是限制器。

一个可行的解决方案是稍微改变算法:不是保留所有整数的单个总和,我们保留两个独立的总和。一个用于偶数编号数组位置的整数,一个用于奇数编号位置的整数。然后我们需要在最后求和这两个值。这是算法:

loop:
 rCurInt = load64(rCurPtr); // 加载偶数
 rSumEven = rSumEven + rCurInt; // 求和偶数
 rCurInt = load64(rCurPtr + 8); // 加载奇数
 rSumOdd = rSumOdd + rCurInt; // 求和奇数
 rCurPtr = rCurPtr + 16; // 前进
 if rCurPtr != rEndPtr goto loop; // 完成?

 rSum = rSumEven + rSumOdd; // 最终求和

循环内核的数据流图如下所示:

2倍展开数组求和(分离总和)数据流图

以前所有求和都在所谓的同一依赖链中(现在希望名称能自解释),我们现在将求和分成两个依赖链。这足以让一台足够宽的、每个周期能维持6条指令的机器以略高于每两个求和整数半个周期的速度完成我们的整数求和任务。进展!

在稍窄的4宽设计上,我们现在是吞吐量受限的,每两个求和整数大约花费6/4=1.5个周期,或每个整数0.75个周期。这仍然比我们在同一台机器上从非展开版本得到的每个整数1个周期有所改进;这个增益纯粹来自减少循环开销比例,进一步展开可以进一步减少它。(也就是说,除非您的循环真的像我们的示例一样小,否则您通常不想过度展开。)

🎯 关键要点:缓存延迟的影响

从这篇文章中,我们可以提取出关于缓存延迟对性能影响的重要见解:

1. 关键路径决定性能

  • 循环的性能由迭代间依赖决定,而不是单个指令的延迟
  • 在链表示例中,加载延迟在关键路径上,限制了吞吐量
  • 在数组间接访问示例中,加载延迟可以重叠,吞吐量不受单个加载延迟限制

2. 缓存未命中的真正成本

  • 缓存未命中的问题不仅是额外的延迟,更是阻止其他工作的能力
  • 如果缓存未命中在关键路径上,它会阻塞整个流水线
  • 如果缓存未命中可以重叠,它们的成本可以分摊

3. 数据布局的重要性

  • 连续数组访问允许预取和重叠内存访问
  • 链表遍历将内存访问延迟放在关键路径上
  • 间接访问(指针数组)介于两者之间,允许一些重叠

4. 缓解策略

  • 循环展开:减少循环开销,但要注意不要创建新的关键路径
  • 分离依赖链:将工作分成独立的流以增加并行性
  • 预取:提前启动内存访问以隐藏延迟
  • 多线程:当一条线程等待时切换到其他工作

💡 实际应用

对于性能工程师:

  1. 分析循环中的依赖关系,识别关键路径
  2. 使用数据流图理解性能瓶颈
  3. 考虑缓存层次结构对实际延迟的影响

对于软件开发者:

  1. 优先使用连续内存布局(数组而不是链表)
  2. 尽量减少循环中的依赖链
  3. 考虑使用软件预取提示
  4. 在适当的时候使用多线程来隐藏内存延迟

对于编译器开发者:

  1. 实现智能的循环展开策略
  2. 识别和分离依赖链
  3. 生成对缓存友好的代码布局

📝 缓存友好的编程模式

基于这些见解,以下是一些缓存友好的编程模式:

1. 顺序访问模式

// 好:顺序访问,良好的空间局部性
for (int i = 0; i < n; i++) {
    sum += array[i];
}

2. 分块访问模式

// 好:分块处理,提高缓存利用率
for (int i = 0; i < n; i += BLOCK_SIZE) {
    for (int j = 0; j < BLOCK_SIZE; j++) {
        sum += array[i + j];
    }
}

3. 避免的访问模式

// 不好:随机访问,缓存不友好
for (int i = 0; i < n; i++) {
    sum += linked_list->value;
    linked_list = linked_list->next;
}

🛠️ 性能分析工具

要应用这些概念,您可以使用以下工具:

  1. perf:Linux性能分析工具
  2. VTune:Intel性能分析器
  3. Cachegrind:Valgrind的缓存分析工具
  4. LLVM-MCA:LLVM机器代码分析器

📚 延伸阅读

  1. 为什么CPU有多级缓存? - 理解缓存层次结构的基础
  2. 缓存一致性入门 - 了解多核系统中的缓存同步
  3. 《计算机体系结构:量化方法》 - 关于性能分析和优化的经典教科书
  4. 《深入理解计算机系统》 - 关于计算机系统如何工作的优秀介绍

💭 讨论问题

  1. 在什么情况下,链表可能比数组性能更好?
  2. 如何设计数据结构以最小化缓存未命中的影响?
  3. 现代CPU有哪些硬件特性可以帮助隐藏内存延迟?
  4. 如何平衡算法复杂性和缓存友好性?

翻译说明:本文翻译自Fabian Giesen的A whirlwind introduction to dataflow graphs,在保持技术准确性的前提下进行了适当简化和重组,以增强中文可读性。由于原文非常长,本文重点提取了与缓存性能相关的核心内容。

版权声明:原文版权归Fabian Giesen所有。翻译内容仅供学习和参考使用。