如何编写最能利用 CPU 缓存来提高性能的代码?

Posted

技术标签:

【中文标题】如何编写最能利用 CPU 缓存来提高性能的代码?【英文标题】:How does one write code that best utilizes the CPU cache to improve performance? 【发布时间】:2010-10-20 06:44:04 【问题描述】:

这听起来像是一个主观问题,但我正在寻找的是具体的实例,您可能已经遇到过与此相关的情况。

    如何使代码、缓存有效/缓存友好(更多的缓存命中,尽可能少的缓存未命中)?从两个角度来看,数据缓存&程序缓存(指令缓存), 即一个人的代码中与数据结构和代码结构相关的东西,应该注意什么以使其缓存有效。

    是否有任何特定的数据结构必须使用/避免,或者是否有特定的方式来访问该结构的成员等...以使代码缓存有效。

    是否有任何程序构造(if、for、switch、break、goto、...)、代码流(for 在 if、if 在 for 等...)中应该遵循/避免在这件事上?

我期待听到与制作缓存高效代码相关的个人经验。它可以是任何编程语言(C、C++、Assembly...)、任何硬件目标(ARM、Intel、PowerPC...)、任何操作系统(Windows、Linux、S ymbian...)等。 .

多样性将有助于更好地深入理解它。

【问题讨论】:

作为介绍,本次演讲提供了一个很好的概述youtu.be/BP6NxVxDQIs 上述缩短的 URL 似乎不再有效,这是演讲的完整 URL:youtube.com/watch?v=BP6NxVxDQIs 【参考方案1】:

缓存的存在是为了减少 CPU 等待内存请求完成时停止的次数(避免内存延迟),并且作为第二个效果,可能会减少整体需要传输的数据量(保留内存带宽)。

避免遭受内存获取延迟的技术通常是首先要考虑的事情,有时会大有帮助。有限的内存带宽也是一个限制因素,特别是对于许多线程想要使用内存总线的多核和多线程应用程序。一组不同的技术有助于解决后一个问题。

改善空间局部性意味着您确保每个缓存行在映射到缓存后被完全使用。当我们查看各种标准基准时,我们发现其中很大一部分未能在缓存行被驱逐之前使用 100% 的提取缓存行。

提高缓存行利用率在三个方面有帮助:

它倾向于在缓存中容纳更多有用的数据,从本质上增加了有效缓存大小。 它倾向于在同一缓存行中容纳更多有用的数据,从而增加在缓存中找到请求数据的可能性。 它降低了内存带宽要求,因为提取次数会更少。

常用技术有:

使用较小的数据类型 组织数据以避免对齐漏洞(通过减小大小对结构成员进行排序是一种方法) 请注意标准动态内存分配器,它可能会在内存预热时引入漏洞并在内存中散布数据。 确保在热循环中实际使用了所有相邻数据。否则,请考虑将数据结构分解为热组件和冷组件,以便热循环使用热数据。 避免使用表现出不规则访问模式的算法和数据结构,并支持线性数据结构。

我们还应该注意,除了使用缓存之外,还有其他方法可以隐藏内存延迟。

现代 CPU:通常有一个或多个硬件预取器。他们训练缓存中的未命中并尝试发现规律。例如,在对后续缓存行进行几次未命中后,硬件预取器将开始将缓存行提取到缓存中,从而预测应用程序的需求。如果你有一个常规的访问模式,硬件预取器通常会做得很好。如果您的程序没有显示常规访问模式,您可以通过自己添加预取指令来改进。

以这样一种方式重新组合指令,使那些总是在缓存中未命中的指令彼此靠近,CPU 有时可能会重叠这些提取,以便应用程序仅承受一次延迟命中(内存级并行度 )。

要降低整体内存总线压力,您必须着手解决所谓的时间局部性。这意味着您必须在数据尚未从缓存中清除时重用它。

合并涉及相同数据的循环(循环融合),并采用称为 tilingblocking 的重写技术都力求避免这些额外的内存获取。

虽然此重写练习有一些经验法则,但您通常必须仔细考虑循环携带的数据依赖性,以确保您不会影响程序的语义。

这些东西在多核世界中真正得到了回报,在添加第二个线程后,您通常不会看到太多的吞吐量改进。

【讨论】:

当我们查看各种标准基准时,我们发现其中很大一部分未能在缓存行被驱逐之前使用 100% 的已获取缓存行。 请问什么样的分析工具可以为您提供此类信息,以及如何提供? “组织数据以避免对齐漏洞(通过减小大小对结构成员进行排序是一种方法)” - 为什么编译器本身不优化它?为什么编译器不能总是“通过减小大小对成员进行排序”?保持成员不排序有什么好处? 我不知道起源,但首先,成员顺序在网络通信中至关重要,您可能希望通过网络逐字节发送整个结构。 @javapowered 编译器可能能够做到这一点,具体取决于语言,但我不确定他们中的任何一个是否这样做。你不能在 C 中这样做的原因是,通过基地址+偏移量而不是名称来寻址成员是完全有效的,这意味着重新排序成员会完全破坏程序。【参考方案2】:

我不敢相信没有更多的答案。无论如何,一个经典的例子是“从里到外”迭代一个多维数组:

pseudocode
for (i = 0 to size)
  for (j = 0 to size)
    do something with ary[j][i]

高速缓存效率低下的原因是,当您访问单个内存地址时,现代 CPU 会从主内存加载具有“近”内存地址的高速缓存行。我们在内部循环中遍历数组中的“j”(外部)行,因此对于内部循环的每次行程,缓存行将导致刷新并加载靠近 [ j][i] 条目。如果将其更改为等效项:

for (i = 0 to size)
  for (j = 0 to size)
    do something with ary[i][j]

它会运行得更快。

【讨论】:

回到大学时,我们有一个关于矩阵乘法的作业。事实证明,首先对“列”矩阵进行转置,然后将行乘以行而不是行乘以列更快。 实际上,大多数现代编译器都可以自己解决这个问题(开启优化) @ykaganovich 这也是 Ulrich Dreppers 文章中的示例:lwn.net/Articles/255364 我不确定这是否总是正确的——如果整个数组都适合 L1 缓存(通常是 32k!),那么两个订单将具有相同数量的缓存命中和未命中。我猜也许内存预取可能会产生一些影响。当然很高兴得到纠正。 如果顺序无关紧要,谁会选择此代码的第一个版本?【参考方案3】:

基本规则实际上相当简单。棘手的地方在于它们如何应用于您的代码。

缓存的工作原理有两个:时间局部性和空间局部性。 前者的想法是,如果您最近使用了某个数据块,您可能很快就会再次需要它。后者意味着如果您最近使用地址 X 的数据,您可能很快就会需要地址 X+1。

缓存试图通过记住最近使用的数据块来适应这一点。它使用缓存线运行,通常大小为 128 字节左右,因此即使您只需要一个字节,包含它的整个缓存线也会被拉入缓存。因此,如果您之后需要以下字节,它已经在缓存中。

这意味着您总是希望自己的代码尽可能地利用这两种形式的局部性。不要跳过所有的内存。在一个小区域做尽可能多的工作,然后转移到下一个区域,在那里做尽可能多的工作。

一个简单的例子是 1800 的答案显示的 2D 数组遍历。如果你一次遍历它一行,你就是按顺序读取内存。如果按列执行,您将读取一个条目,然后跳转到完全不同的位置(下一行的开头),读取一个条目,然后再次跳转。当你最终回到第一行时,它将不再在缓存中。

这同样适用于代码。跳转或分支意味着缓存使用效率较低(因为您不是按顺序读取指令,而是跳转到不同的地址)。当然,小的 if 语句可能不会改变任何东西(你只是跳过了几个字节,所以你仍然会在缓存区域内结束),但是函数调用通常意味着你正在跳转到一个完全不同的可能不会被缓存的地址。除非它最近被调用。

不过,指令缓存的使用通常不是问题。您通常需要担心的是数据缓存。

在结构或类中,所有成员都连续布置,这很好。在一个数组中,所有条目也是连续布局的。在链表中,每个节点都分配在完全不同的位置,这很糟糕。指针通常倾向于指向不相关的地址,如果取消引用它可能会导致缓存未命中。

如果您想利用多个内核,它会变得非常有趣,因为通常情况下,一次只有一个 CPU 可能在其 L1 缓存中具有任何给定地址。因此,如果两个内核不断访问同一个地址,则会导致不断的缓存未命中,因为它们正在争夺地址。

【讨论】:

+1,好的实用建议。一个补充:时间局部性和空间局部性相结合表明,例如,对于矩阵运算,最好将它们拆分成更小的矩阵,这些矩阵完全适合缓存行,或者其行/列适合缓存行。我记得这样做是为了实现 multidim 的可视化。数据。它给裤子带来了一些严重的影响。最好记住缓存确实包含多个“行”;) 您说一次只能有 1 个 CPU 在 L1 缓存中具有给定地址——我假设您的意思是缓存行而不是地址。此外,我听说过至少一个 CPU 正在执行写入操作时出现错误共享问题,但如果两者都仅执行读取操作则不会。那么“访问”实际上是指写入吗? @JosephGarvin:是的,我的意思是写。你是对的,多个核心可以同时在其一级缓存中拥有相同的缓存行,但是当一个核心写入这些地址时,它在所有其他一级缓存中都会失效,然后他们必须重新加载它才能这样做任何东西。抱歉,措辞不准确(错误)。 :)【参考方案4】:

如果您对内存和软件的交互方式感兴趣,我建议您阅读 Ulrich Drepper 撰写的由 9 部分组成的文章 What every programmer should know about memory。它也可以通过a 104-page PDF 获得。

与这个问题特别相关的部分可能是Part 2(CPU 缓存)和Part 5(程序员可以做什么 - 缓存优化)。

【讨论】:

您应该添加文章要点的摘要。 读得很好,但这里必须提到的另一本书是Hennessy, Patterson, Computer Architecture, A Quantitiative Approach,它今天已经出版了第五版。【参考方案5】:

除了数据访问模式,缓存友好代码的一个主要因素是数据大小。更少的数据意味着更多的数据适合缓存。

这主要是内存对齐数据结构的一个因素。 “传统”智慧说数据结构必须在字边界对齐,因为 CPU 只能访问整个字,如果一个字包含多个值,则必须做额外的工作(读取-修改-写入而不是简单的写入) .但是缓存可以完全使这个论点无效。

类似地,Java 布尔数组为每个值使用一个完整字节,以便允许直接对单个值进行操作。如果您使用实际位,您可以将数据大小减少 8 倍,但是访问单个值会变得更加复杂,需要进行位移和掩码操作(BitSet 类会为您执行此操作)。但是,由于缓存效应,当数组很大时,这仍然比使用 boolean[] 快得多。 IIRC I 曾经通过这种方式实现了 2 或 3 倍的加速。

【讨论】:

【参考方案6】:

缓存最有效的数据结构是数组。如果您的数据结构按顺序排列,因为 CPU 一次从主内存中读取整个缓存行(通常为 32 字节或更多),则缓存效果最佳。

任何以随机顺序访问内存的算法都会破坏缓存,因为它总是需要新的缓存行来容纳随机访问的内存。另一方面,在数组中顺序运行的算法是最好的,因为:

    它使 CPU 有机会预读,例如推测性地将更多内存放入缓存中,稍后将访问。这种预读可显着提升性能。

    在大型数组上运行紧密循环还允许 CPU 缓存循环中执行的代码,并且在大多数情况下,您可以完全从缓存内存中执行算法,而不必阻塞外部内存访问。

【讨论】:

@Grover:关于您的第 2 点。可以说,如果在一个紧密循环中,为每个循环计数调用一个函数,那么它将完全获取新代码并导致缓存未命中,而不是如果你可以将函数作为代码放在 for 循环本身中,没有函数调用,由于缓存未命中更少,它会更快? 是和不是。新函数将被加载到缓存中。如果有足够的缓存空间,那么在第二次迭代时,它已经在缓存中具有该功能,因此没有理由再次重新加载它。所以它在第一次调用时就很成功。在 C/C++ 中,您可以要求编译器使用适当的段将函数彼此相邻放置。 还有一点需要注意:如果你在循环外调用并且没有足够的缓存空间,那么新函数无论如何都会被加载到缓存中。甚至可能会发生原始循环将被抛出缓存的情况。在这种情况下,每次迭代调用最多会产生三种惩罚:一种是加载调用目标,另一种是重新加载循环。如果循环头与调用返回地址不在同一缓存行中,则为第三个。在这种情况下,跳转到循环头也需要新的内存访问。【参考方案7】:

我在游戏引擎中看到的一个示例是将数据从对象中移出并放入它们自己的数组中。受物理影响的游戏对象可能还附加了许多其他数据。但是在物理更新循环期间,所有引擎关心的都是关于位置、速度、质量、边界框等的数据。所以所有这些都被放置到它自己的数组中,并尽可能针对 SSE 进行优化。

因此,在物理循环期间,物理数据使用向量数学以数组顺序进行处理。游戏对象使用它们的对象 ID 作为各种数组的索引。它不是指针,因为如果必须重新定位数组,指针可能会失效。

这在很多方面都违反了面向对象的设计模式,但通过将需要在相同循环中操作的数据放在一起,使代码运行速度大大加快。

这个例子可能已经过时了,因为我认为大多数现代游戏都使用像 Havok 这样的预构建物理引擎。

【讨论】:

+1 一点也不过时。这是为游戏引擎组织数据的最佳方式 - 使数据块连续,并在继续下一个(例如物理)之前执行所有给定类型的操作(例如 AI),以利用缓存的邻近性/局部性参考。 我在几周前的某个视频中看到了这个确切的例子,但后来失去了它的链接/不记得如何找到它。还记得你在哪里看到这个例子吗? @will: 不,我不记得这是在哪里。 这就是实体组件系统的想法(ECS:en.wikipedia.org/wiki/Entity_component_system)。将数据存储为数组结构,而不是 OOP 实践鼓励的更传统的结构数组。【参考方案8】:

只有一篇文章涉及到它,但是在进程之间共享数据时出现了一个大问题。您希望避免多个进程同时尝试修改同一个缓存行。这里要注意的是“错误”共享,其中两个相邻的数据结构共享一个高速缓存行,并且对其中一个的修改会使另一个的高速缓存行无效。这会导致高速缓存行在共享多处理器系统上的数据的处理器高速缓存之间不必要地来回移动。避免这种情况的一种方法是对齐和填充数据结构以将它们放在不同的行上。

【讨论】:

【参考方案9】:

用户1800 INFORMATION对“经典示例”的评论(评论太长)

我想检查两个迭代顺序(“外部”和“内部”)的时间差异,所以我用一个大型二维数组做了一个简单的实验:

measure::start();
for ( int y = 0; y < N; ++y )
for ( int x = 0; x < N; ++x )
    sum += A[ x + y*N ];
measure::stop();

第二种情况是交换了for 循环。

较慢的版本(“x first”)为 0.88 秒,较快的版本为 0.06 秒。这就是缓存的力量:)

我使用了gcc -O2,但循环仍然没有优化。 Ricardo 的评论“大多数现代编译器可以自己解决这个问题”并不成立

【讨论】:

不确定我明白了。在这两个示例中,您仍在访问 for 循环中的每个变量。为什么一种方式比另一种方式快? 最终让我直观地了解它的影响:) @EdwardCorlew 这是因为它们被访问的顺序。 y-first 顺序更快,因为它按顺序访问数据。当请求第一个条目时,L1 缓存会加载整个缓存行,其中包括请求的 int 加上接下来的 15 个(假设是 64 字节的缓存行),因此没有等待下一个 15 的 CPU 停顿。 x -first order 比较慢,因为访问的元素不是顺序的,并且大概 N 足够大,以至于被访问的内存总是在 L1 缓存之外,因此每个操作都会停止。【参考方案10】:

我可以回答 (2),在 C++ 世界中,链表很容易杀死 CPU 缓存。在可能的情况下,数组是更好的解决方案。没有经验知道这是否适用于其他语言,但很容易想象会出现同样的问题。

【讨论】:

@Andrew:结构怎么样。它们缓存效率高吗?它们是否有任何大小限制以提高缓存效率? 一个结构是一个单一的内存块,所以只要它不超过你的缓存大小,你就不会看到影响。只有当您拥有一组结构(或类)时,您才会看到缓存命中,这取决于您组织集合的方式。数组将对象相互对接(很好),但链表可以在整个地址空间中包含对象,并且它们之间有链接,这显然不利于缓存性能。 在不杀死缓存的情况下使用链表的一种方法,对于不是大列表最有效的方法是创建自己的内存池,即-分配一个大数组。然后不是为每个小链表成员'malloc'ing(或C++中的'new'ing)内存,它可能分配在内存中完全不同的位置,并浪费管理空间,而是从内存池中给它内存,大大增加了逻辑上接近的列表成员一起在缓存中的几率。 当然,但是要获得 std::list 等还有很多工作要做。使用您的自定义内存块。当我还是一个年轻的whippersnapper时,我绝对会走这条路,但是这些天......还有太多其他事情要解决。 一些参考资料:Bjarne Stroustrup says we must avoid linked lists、Why you should never, ever, EVER use linked-list in your code again、Number crunching: Why you should never, ever, EVER use linked-list in your code again【参考方案11】:

缓存按“缓存行”排列,(实际)内存以这种大小的块读取和写入。

因此,包含在单个缓存行中的数据结构效率更高。

同样,访问连续内存块的算法将比以随机顺序跳过内存的算法更有效。

不幸的是,处理器之间的缓存线大小差异很大,因此无法保证在一个处理器上最佳的数据结构在任何其他处理器上都有效。

【讨论】:

不一定。小心虚假分享。有时您必须将数据拆分为不同的缓存行。缓存的有效性始终取决于您如何使用它。【参考方案12】:

要问如何制作代码,缓存有效缓存友好和其他大多数问题,通常是问如何优化程序,因为缓存对性能的影响如此之大,任何优化的程序都是一个这是缓存有效缓存友好的。

我建议阅读有关优化的内容,此站点上有一些很好的答案。 在书籍方面,我推荐Computer Systems: A Programmer's Perspective,它有一些关于正确使用缓存的好文字。

(顺便说一句 - 缓存未命中可能很糟糕,但更糟糕的是 - 如果程序是来自硬盘驱动器的 paging...)

【讨论】:

【参考方案13】:

关于数据结构选择、访问模式等一般建议已经有很多答案了。在这里我想添加另一个代码设计模式,称为软件管道,它利用了主动缓存管理.

这个想法借鉴了其他流水线技术,例如CPU 指令流水线。

这种类型的模式最适用于以下过程

    可以分解为合理的多个子步骤,S[1]、S[2]、S[3]……其执行时间与 RAM 访问时间(~60-70ns)大致相当。 接受一批输入并对其执行上述多个步骤以获得结果。

让我们看一个只有一个子过程的简单案例。 通常代码会这样:

def proc(input):
    return sub-step(input))

为了获得更好的性能,您可能希望将多个输入批量传递给函数,以便分摊函数调用开销并增加代码缓存局部性。

def batch_proc(inputs):
    results = []
    for i in inputs:
        // avoids code cache miss, but still suffer data(inputs) miss
        results.append(sub-step(i))
    return res

但是,如前所述,如果该步骤的执行与 RAM 访问时间大致相同,您可以进一步将代码改进为如下所示:

def batch_pipelined_proc(inputs):
    for i in range(0, len(inputs)-1):
        prefetch(inputs[i+1])
        # work on current item while [i+1] is flying back from RAM
        results.append(sub-step(inputs[i-1]))
        
    results.append(sub-step(inputs[-1]))

执行流程如下:

    prefetch(1) 要求 CPU 将 input[1] 预取到缓存中,其中 prefetch 指令本身需要 P 个周期并返回,而在后台 input[1] 将在 R 个周期后到达缓存。李> works_on(0) Cold Miss on 0 并对其工作,这需要 M prefetch(2) 发出另一个 fetch works_on(1) 如果 P + R works_on(2) ...

可能涉及更多步骤,然后您可以设计一个多级管道,只要步骤的时间和内存访问延迟匹配,您几乎不会遭受代码/数据缓存未命中的情况。然而,这个过程需要通过许多实验来调整,以找出正确的步骤分组和预取时间。由于其所需的努力,它在高性能数据/数据包流处理中得到了更多采用。在 DPDK QoS Enqueue pipeline design 中可以找到一个很好的生产代码示例: http://dpdk.org/doc/guides/prog_guide/qos_framework.html 第 21.2.4.3 章。排队管道。

可以找到更多信息:

https://software.intel.com/en-us/articles/memory-management-for-optimal-performance-on-intel-xeon-phi-coprocessor-alignment-and

http://infolab.stanford.edu/~ullman/dragon/w06/lectures/cs243-lec13-wei.pdf

【讨论】:

【参考方案14】:

除了对齐结构和字段之外,如果您的结构在堆分配时您可能希望使用支持对齐分配的分配器;喜欢_aligned_malloc(sizeof(DATA), SYSTEM_CACHE_LINE_SIZE);,否则你可能会有随机的虚假分享;请记住,在 Windows 中,默认堆有 16 字节对齐。

【讨论】:

【参考方案15】:

编写您的程序以使其最小化。这就是为什么对 GCC 使用 -O3 优化并不总是一个好主意。它占据了更大的尺寸。通常,-Os 与-O2 一样好。这一切都取决于所使用的处理器。 YMMV。

一次处理少量数据。这就是为什么如果数据集很大,效率较低的排序算法可以比快速排序运行得更快。想办法将较大的数据集分解为较小的数据集。其他人建议这样做。

为了帮助您更好地利用指令的时间/空间局部性,您可能需要研究如何将代码转换为汇编。例如:

for(i = 0; i < MAX; ++i)
for(i = MAX; i > 0; --i)

这两个循环产生不同的代码,即使它们只是解析一个数组。无论如何,您的问题是非常特定于架构的。因此,严格控制缓存使用的唯一方法是了解硬件的工作原理并针对它优化代码。

【讨论】:

有趣的地方。前瞻缓存是否根据循环/通过内存的方向进行假设? 有很多方法可以设计推测数据缓存。基于步幅的测量数据访问的“距离”和“方向”。基于内容的追逐指针链。还有其他设计方法。 Often, -Os is just as good as -O2 这并不完全正确。大多数编译器不在-Os 启用矢量化,但许多在-O2 启用。现在最好使用-O3 来启用所有自动矢量化选项。如果处理得当,它会比标量代码快很多倍

以上是关于如何编写最能利用 CPU 缓存来提高性能的代码?的主要内容,如果未能解决你的问题,请参考以下文章

Linux性能学习(1.2):CPU_如何提高CPU缓存命中率

Linux性能学习(1.2):CPU_如何提高CPU缓存命中率

CPU缓存和内存屏障

CPU缓存和内存屏障

CPU缓存和内存屏障

Guava 源码分析(Cache 原理)