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

How does one write code that best utilizes the CPU cache to improve performance?

这听起来像是一个主观问题,但我要找的是一些具体的例子,你可能会遇到与此相关的例子。

  • 如何使代码、缓存有效/缓存友好(更多缓存命中,尽可能少的缓存未命中)?从这两个角度来看,数据缓存和程序缓存(指令缓存)也就是说,代码中与数据结构和代码构造相关的内容,应该注意使其缓存有效。

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

  • 在这个问题上,是否有任何程序构造(if,for,switch,break,goto,…)、代码流(if内部,if内部,for等…)应该遵循/避免?

  • 我期待听到个人的经验,使缓存效率代码一般。它可以是任何编程语言(C,C++,汇编,…),任何硬件目标(ARM,英特尔,PowerPC,…),任何OS(Windows,Linux,S YMIB,……)等等。

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


    缓存的存在是为了减少CPU在等待内存请求完成时暂停的次数(避免内存延迟),作为第二个影响,可能是为了减少需要传输的总数据量(保留内存带宽)。

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

    改进空间位置意味着一旦缓存线映射到缓存,就可以确保完全使用它。当我们研究各种标准基准时,我们已经看到,令人惊讶的是,其中很大一部分未能在收回缓存线之前100%地使用提取的缓存线。

    提高缓存线利用率有三个方面的帮助:

    • 它倾向于在缓存中容纳更有用的数据,实质上增加了有效的缓存大小。
    • 它倾向于将更有用的数据放在同一个缓存线中,从而增加了在缓存中找到请求数据的可能性。
    • 它减少了内存带宽需求,因为将有更少的回迁。

    常用技术有:

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

    我们还应该注意到,还有其他方法可以隐藏内存延迟,而不是使用缓存。

    现代CPU:S通常有一个或多个硬件预取器。他们在一个保险箱里对失踪者进行训练,试图找出规律。例如,在对后续缓存线进行几次未命中之后,HW预取器将开始将缓存线提取到缓存中,以预测应用程序的需求。如果您有一个常规的访问模式,那么硬件预取器通常会做得很好。如果您的程序不显示常规的访问模式,您可以通过自己添加预取指令来改进。

    以这样一种方式重新分组指令,即总是在缓存中丢失的指令彼此发生在一起,CPU有时可以重叠这些提取,以便应用程序只维持一次延迟命中(内存级并行)。

    为了减少总的内存总线压力,您必须开始处理所谓的时间位置。这意味着当数据还没有从缓存中移出时,必须重新使用它。

    合并接触相同数据的循环(循环融合),以及使用称为平铺或阻塞的重写技术,都会努力避免这些额外的内存获取。

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

    在多核世界中,这些都是真正值得的,在添加第二个线程之后,通常不会看到吞吐量的提高。


    我真不敢相信这个问题没有更多的答案。无论如何,一个经典的例子是迭代多维数组"由内向外":

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

    这是缓存效率低下的原因,因为当您访问单个内存地址时,现代CPU将从主内存加载带有"近"内存地址的缓存线。我们正在迭代内部循环中数组中的"j"(外部)行,因此对于内部循环的每次行程,缓存行都将导致刷新并加载一行靠近[j][i]项的地址。如果更改为等效值:

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

    它会跑得更快。


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

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

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

    这意味着您总是希望自己的代码尽可能地利用这两种形式的局部性。别把记忆全翻了。在一个小区域内尽可能多地工作,然后继续下一个区域,在那里尽可能多地工作。

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

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

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

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

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


    如果您对内存和软件的交互方式感兴趣,我建议您阅读这篇由9部分组成的文章,每个程序员都应该了解ulrich drepper关于内存的知识。它也有104页的PDF格式。

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


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

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

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


    缓存最有效的数据结构是数组。如果数据结构是按顺序排列的,当CPU从主内存中同时读取整个缓存线(通常为32字节或更多),那么缓存工作得最好。

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

  • 它给CPU一个提前读取的机会,例如推测性地将更多内存放入缓存中,稍后将访问缓存。这种预读会大大提高性能。

  • 在一个大数组上运行一个紧密的循环也允许CPU缓存在循环中执行的代码,在大多数情况下,允许您完全从缓存内存执行一个算法,而不必为外部内存访问而阻塞。


  • 我在游戏引擎中看到的一个例子是将数据从对象中移出并移动到它们自己的数组中。一个受物理影响的游戏对象可能也有很多其他的数据。但是在物理更新循环中,所有引擎关心的都是关于位置、速度、质量、边界框等的数据,因此所有这些数据都被放置到自己的数组中,并尽可能为SSE优化。

    因此,在物理循环过程中,物理数据是使用向量数学按数组顺序处理的。游戏对象使用其对象ID作为不同数组的索引。它不是指针,因为如果必须重新定位数组,指针可能会失效。

    在许多方面,这违反了面向对象的设计模式,但它通过将需要在同一循环中操作的数据紧密地放在一起,使代码更快。

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


    用户1800信息对"经典示例"的注释(注释太长)

    我想检查两个迭代顺序("outter"和"inner")的时间差,所以我用一个大的二维数组做了一个简单的实验:

    1
    2
    3
    4
    5
    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,但是循环没有优化。里卡多的评论"大多数现代编译器都能通过自己的精灵来解决这个问题",但这一观点并不成立。


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


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

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

    这种模式最适用于

  • 可以分解为合理的多个子步骤,S[1]、S[2]、S[3]、…其执行时间与RAM访问时间大致相当(~60-70ns)。
  • 采取一批输入并对其执行上述多个步骤以获得结果。
  • 我们来看一个只有一个子过程的简单例子。通常,代码需要:

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

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

    1
    2
    3
    4
    5
    6
    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访问时间大致相同,则可以进一步将代码改进为如下所示:

    1
    2
    3
    4
    5
    6
    7
    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]))

    执行流程如下:

  • 预取(1)要求CPU将输入[1]预取到缓存中,其中预取指令本身接受P周期并返回,在后台输入[1]将在R周期后到达缓存。
  • 在(0)上工作,在0上工作,在0上工作,这需要m
  • 预取(2)发出另一个提取
  • 工作于(1)如果p+r<=m,那么输入[1]应该在此步骤之前就已经在缓存中,从而避免数据缓存未命中。
  • Worson on(2)…
  • 可能会涉及更多的步骤,然后您可以设计一个多阶段管道,只要这些步骤的时间和内存访问延迟匹配,就不会有代码/数据缓存丢失。然而,这个过程需要通过许多实验来调整,以找出正确的步骤分组和预取时间。由于其所需的努力,它在高性能数据/包流处理中看到了更多的采用。在DPDK QoS排队管道设计中可以找到一个很好的生产代码示例: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-co处理器-alliance-and

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


    要问如何使代码、缓存有效缓存友好,以及其他大多数问题,通常是要问如何优化程序,这是因为缓存对性能有如此大的影响,所以任何优化的程序都是缓存有效缓存友好的程序。

    我建议阅读关于优化的文章,这里有一些很好的答案。在书籍方面,我推荐计算机系统:程序员的观点,其中有一些关于缓存的正确使用的好文本。

    (B.T.W-如果程序正在从硬盘分页,那么可能会有缓存丢失,但情况更糟…)


    缓存被安排在"缓存线"中,并且(真实的)内存是从这个大小的块中读取和写入的。

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

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

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


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


    除了对齐结构和字段之外,如果您的结构为"如果已分配堆",则可能需要使用支持对齐分配的分配器;例如"对齐的"malloc(sizeof(data),system"缓存"line"大小);否则,您可能会有随机的错误共享;请记住,在Windows中,默认堆有16个字节的对齐。


    编写程序,使其最小化。这就是为什么对GCC使用-O3优化并不总是一个好主意。它需要更大的尺寸。通常,-os和-o2一样好。但这完全取决于所用的处理器。YMMV。

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

    为了帮助您更好地利用指令的时间/空间位置,您可能需要研究如何将代码转换为程序集。例如:

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

    这两个循环产生不同的代码,即使它们只是通过一个数组进行解析。在任何情况下,您的问题都是特定于体系结构的。因此,严格控制缓存使用的唯一方法是了解硬件的工作方式,并为其优化代码。