关于C++:什么是”缓存友好”代码?

What is a “cache-friendly” code?

"cache unfriendly code"和"cache friendly"代码有什么区别?

我如何确保我写的高速缓存有效的代码?


预赛

在现代计算机上,只有最底层的存储器结构(寄存器)才能在单时钟周期内移动数据。然而,寄存器非常昂贵,大多数计算机核心的寄存器少于几十个(总共几百到1000字节)。在内存频谱的另一端(DRAM),内存是非常便宜的(也就是说,实际上便宜了数百万倍),但在请求接收数据后需要数百个周期。为了在超高速和昂贵以及超低速和廉价之间弥合这一鸿沟,缓存存储器被命名为l1、l2、l3,以降低速度和成本。其思想是,大多数执行代码经常会碰到一组小的变量,而其余的(一组大得多的变量)则很少。如果处理器在一级缓存中找不到数据,则在二级缓存中查找。如果不在那里,那么是三级缓存,如果不在那里,则是主内存。这些"失误"中的每一个在时间上都是昂贵的。好的。

(与之类似的是,高速缓存是对系统内存的,因为系统内存是对硬盘存储的。硬盘存储非常便宜,但速度非常慢)。好的。

缓存是降低延迟影响的主要方法之一。解释药草缝合线(CFR.下面的链接):增加带宽很容易,但我们不能购买我们的方式摆脱延迟。好的。

总是通过内存层次结构检索数据(最小=最快到最慢)。缓存命中/未命中通常指的是CPU中最高级别缓存中的命中/未命中——最高级别是指最大=最慢。缓存命中率对性能至关重要,因为每次缓存未命中都会导致从RAM(或更糟的…)获取数据,这需要花费大量时间(RAM需要数百个周期,HDD需要数千万个周期)。相比之下,从(最高级别)缓存读取数据通常只需要几个周期。好的。

在现代计算机体系结构中,性能瓶颈正离开CPU死区(例如访问RAM或更高版本)。随着时间的推移,情况只会变得更糟。处理器频率的增加目前与提高性能不再相关。问题是内存访问。因此,CPU中的硬件设计工作目前主要集中在优化缓存、预取、管道和并发性上。例如,现代CPU将85%左右的内存花在缓存上,高达99%用于存储/移动数据!好的。

在这个问题上有很多话要说。以下是一些关于缓存、内存层次结构和正确编程的重要参考:好的。

  • Agner Fog的页面。在他的优秀文档中,您可以找到涵盖从汇编到C++的各种语言的详细示例。
  • 如果你喜欢视频,我强烈建议你看一看Herb Sutter在机器架构上的演讲(YouTube)(具体检查12:00及以后!).
  • Christer Ericson(Sony技术总监)制作的关于内存优化的幻灯片
  • lwn.net的文章"每个程序员都应该知道什么是内存"

缓存友好代码的主要概念

对缓存友好的代码的一个非常重要的方面就是局部性原则,其目标是将相关的数据放在内存中,以允许高效的缓存。就CPU缓存而言,了解缓存线以了解其工作原理非常重要:缓存线如何工作?好的。

以下特定方面对于优化缓存非常重要:好的。

  • 时间位置:当一个给定的内存位置被访问时,很可能在不久的将来再次访问相同的位置。理想情况下,此时仍将缓存此信息。
  • 空间位置:是指将相关数据相互靠近放置。缓存发生在许多级别上,而不仅仅是在CPU中。例如,当您从RAM中读取数据时,通常会获取比专门要求的内存更大的内存块,因为程序很快就会需要这些数据。硬盘缓存遵循相同的思路。特别是对于CPU缓存,缓存线的概念非常重要。
  • 使用适当的C++容器好的。

    一个简单的缓存友好与缓存不友好的例子是C++的EDCOX1×0对EDCOX1(1)。std::vector的元素存储在连续内存中,因此访问它们比访问std::list中的元素更容易缓存,后者将其内容存储在各处。这是由于空间位置。好的。

    bjarne stroustrup在这段YouTube视频中给出了一个非常好的例子(感谢@mohammad ali baydoun提供链接!).好的。

    在数据结构和算法设计中不要忽视缓存好的。

    尽可能调整数据结构和计算顺序,以最大限度地利用缓存。在这方面,一种常见的技术是缓存阻塞(archive.org版本),它在高性能计算(CFR)中极为重要。例如Atlas)。好的。

    了解并利用数据的隐式结构好的。

    另一个简单的例子,许多领域的人有时忘记的是列主要(Ex.fortran,Matlab)与行主要排序(例如,C,C++)用于存储二维数组。例如,考虑以下矩阵:好的。

    1
    2
    1 2
    3 4

    在行主排序中,它以1 2 3 4的形式存储在内存中;在列主排序中,它以1 3 2 4的形式存储。很容易看出,不利用此顺序的实现将很快遇到(很容易避免!)缓存问题。不幸的是,在我的领域(机器学习)中,我经常看到这样的东西。@Matteoitalia在他的答案中更详细地展示了这个例子。好的。

    当从内存中提取矩阵的某个元素时,它附近的元素也将被提取并存储在缓存行中。如果利用排序,将导致更少的内存访问(因为后续计算所需的下几个值已经在缓存行中)。好的。

    为了简单起见,假设缓存包含一条缓存线,其中可以包含2个矩阵元素,并且当从内存中提取给定元素时,下一条也是。假设我们要取上面示例2x2矩阵中所有元素的和(我们称之为M):好的。

    利用排序(例如在C++中首先改变列索引):好的。

    1
    2
    3
    M[0][0] (memory) + M[0][1] (cached) + M[1][0] (memory) + M[1][1] (cached)
    = 1 + 2 + 3 + 4
    --> 2 cache hits, 2 memory accesses

    不使用排序(例如在C++中改变行索引):好的。

    1
    2
    3
    M[0][0] (memory) + M[1][0] (memory) + M[0][1] (memory) + M[1][1] (memory)
    = 1 + 3 + 2 + 4
    --> 0 cache hits, 4 memory accesses

    在这个简单的例子中,利用排序大约使执行速度加倍(因为内存访问需要比计算总和更多的周期)。在实践中,性能差异可能会大得多。好的。

    避免不可预测的分支好的。

    现代体系结构的特点是管道和编译器正在变得非常擅长重新排序代码,以尽量减少由于内存访问而导致的延迟。当关键代码包含(不可预测)分支时,很难或不可能预取数据。这将间接导致更多的缓存未命中。好的。

    这里很好地解释了这一点(感谢@0x90的链接):为什么处理排序数组比处理未排序数组更快?好的。

    避免虚拟功能好的。

    在C++的上下文中,EDCOX1的7个方法代表了关于高速缓存未命中的有争议的问题(一般一致地认为,在性能方面,在可能时避免它们)。在查找过程中,虚拟函数可能会导致缓存未命中,但只有在不经常调用特定函数时才会发生这种情况(否则可能会对其进行缓存),因此有些人认为这是不存在的问题。有关这个问题的参考,请注意:在C++类中有一个虚拟方法的性能代价是多少?好的。常见问题

    在具有多处理器缓存的现代体系结构中,一个常见的问题被称为错误共享。当每个处理器试图在另一个内存区域中使用数据,并试图将其存储在同一缓存线中时,就会发生这种情况。这会导致缓存线(包含另一个处理器可以使用的数据)被一次又一次地覆盖。实际上,在这种情况下,不同的线程通过引发缓存未命中而使彼此等待。另请参见(感谢@matt提供链接):如何以及何时与缓存线大小对齐?好的。

    RAM内存中缓存不足的一个极端症状(在本文中这可能不是您的意思)是所谓的"抖动"。当进程连续生成需要磁盘访问的页错误(例如访问当前页以外的内存)时,就会发生这种情况。好的。好啊。


    除了@marc claesen的答案外,我认为缓存不友好代码的一个有指导意义的经典例子是扫描C二维数组(例如位图图像)的代码,而不是按行扫描。

    行中相邻的元素在内存中也相邻,因此按顺序访问它们意味着按内存升序访问它们;这是缓存友好的,因为缓存倾向于预取连续的内存块。

    相反,访问这样的元素列是不友好的缓存,因为同一列中的元素在内存中彼此相距较远(特别是,它们的距离等于行的大小),所以当使用此访问模式时,您在内存中来回跳跃,可能会浪费缓存检索靠近b的元素的工作。在记忆中。

    破坏演出的唯一方法就是

    1
    2
    3
    4
    5
    6
    7
    8
    // Cache-friendly version - processes pixels which are adjacent in memory
    for(unsigned int y=0; y<height; ++y)
    {
        for(unsigned int x=0; x<width; ++x)
        {
            ... image[y][x] ...
        }
    }

    1
    2
    3
    4
    5
    6
    7
    8
    // Cache-unfriendly version - jumps around in memory for no good reason
    for(unsigned int x=0; x<width; ++x)
    {
        for(unsigned int y=0; y<height; ++y)
        {
            ... image[y][x] ...
        }
    }

    在具有小缓存和/或使用大阵列(例如当前计算机上的10+百万像素24 bpp图像)的系统中,这种效果可能非常显著(速度的几个数量级);因此,如果必须进行许多垂直扫描,通常最好先将图像旋转90度,然后再执行各种分析,限制将缓存中不友好的代码转到旋转。


    优化缓存使用在很大程度上归结为两个因素。好的。参考位置

    第一个因素(其他人已经提到过)是参考位置。然而,参考位置实际上有两个维度:空间和时间。好的。

    • 空间的

    空间维度也可以归结为两个方面:第一,我们希望将信息密集地打包,这样更多的信息将适合于有限的内存。这意味着(例如)需要在计算复杂性方面进行重大改进,以证明基于指针连接的小节点的数据结构是正确的。好的。

    第二,我们希望将一起处理的信息也位于一起。典型的缓存以"行"的形式工作,这意味着当您访问某些信息时,附近地址的其他信息将与我们接触的部分一起加载到缓存中。例如,当我触摸一个字节时,缓存可能在该字节附近加载128或256个字节。为了利用这一点,您通常希望对数据进行排列,以最大程度地提高同时加载其他数据的可能性。好的。

    对于一个非常简单的例子,这意味着线性搜索与二进制搜索的竞争可能比您预期的要激烈得多。一旦从缓存行加载了一个项目,使用该缓存行中的其余数据几乎是免费的。只有当数据足够大,二进制搜索减少了您访问的缓存线数量时,二进制搜索才会明显加快。好的。

    • 时间

    时间维度意味着,当您对某些数据执行某些操作时,您希望(尽可能)同时对该数据执行所有操作。好的。

    既然您已经将此标记为C++,我将指向一个相对高速缓存不友好设计的经典示例:EDCOX1(0)。valarray重载大多数算术运算符,因此我可以(例如)说a = b + c + d;(其中abcd都是valarray)来对这些数组进行元素添加。好的。

    问题在于,它遍历一对输入、将结果放入临时输入、遍历另一对输入等等。对于大量的数据,一个计算的结果可能会在下一个计算中使用之前从缓存中消失,所以在得到最终结果之前,我们会反复读取(和写入)数据。如果最终结果的每一个元素都类似于(a[n] + b[n]) * (c[n] + d[n]);,我们通常更愿意读取每个a[n]b[n]c[n]d[n]一次,进行计算、写入结果、增加n并重复直到完成。2好的。线路共享

    第二个主要因素是避免线路共享。要理解这一点,我们可能需要备份并查看缓存是如何组织的。最简单的缓存形式是直接映射。这意味着主内存中的一个地址只能存储在缓存中的一个特定位置。如果我们使用两个映射到缓存中同一位置的数据项,那么它的工作将很糟糕——每次我们使用一个数据项时,另一个数据项都必须从缓存中刷新,以便为另一个数据项腾出空间。缓存的其余部分可能为空,但这些项不会使用缓存的其他部分。好的。

    为了防止这种情况发生,大多数缓存都被称为"set associative"。例如,在4路集合关联缓存中,主内存中的任何项都可以存储在缓存中的4个不同位置中的任何一个。因此,当缓存要加载一个项目时,它会在这四个项目中查找最近使用过的3个项目,将其刷新到主内存中,并在其位置加载新项目。好的。

    问题可能相当明显:对于直接映射的缓存,映射到同一缓存位置的两个操作数可能会导致不良行为。n路集关联缓存将数字从2增加到n+1。将缓存组织成更多的"方式"需要额外的电路,通常运行速度较慢,因此(例如)8192路集关联缓存也很少是一个好的解决方案。好的。

    最终,这一因素在可移植代码中更难控制。您对数据放置位置的控制通常相当有限。更糟糕的是,从地址到缓存的精确映射在其他类似的处理器之间有所不同。然而,在某些情况下,可能值得做一些事情,比如分配一个大的缓冲区,然后只使用您分配的部分来确保数据共享相同的缓存线(即使您可能需要检测确切的处理器并相应地执行此操作)。好的。

    • 虚假分享

    还有一个相关的项目叫做"虚假分享"。这发生在多处理器或多核系统中,其中两个(或更多)处理器/核心具有单独的数据,但属于同一缓存线。这迫使两个处理器/核心协调对数据的访问,即使每个处理器/核心都有自己的独立数据项。尤其是如果两个处理器交替修改数据,这可能会导致大量的速度下降,因为数据必须在处理器之间不断地来回切换。通过将缓存组织成更多的"方式"或类似的方式,这是不容易解决的。防止这种情况发生的主要方法是确保两个线程很少(最好永远不会)修改可能位于同一缓存行中的数据(对于难以控制分配数据的地址也有同样的警告)。好的。

  • 那些知道C++的人可能会怀疑这是否可以通过表达式模板之类的方式进行优化。我很确定答案是肯定的,这是可以做到的,如果是的话,这可能是一个相当大的胜利。不过,我不知道有人这样做,而且考虑到valarray的使用量很小,我也会有点惊讶看到有人这样做。好的。

  • 如果有人想知道valarray(专门为性能而设计)是如何出现这种严重错误的,那么可以归结为一件事:它实际上是为像老式Crays这样的机器设计的,这种机器使用了快速的主内存,没有缓存。对他们来说,这真的是一个近乎完美的设计。好的。

  • 是的,我正在简化:大多数缓存并不能精确地测量最近使用过的项目,但是它们使用了一些启发式的方法,这是为了接近最近使用过的项目,而不必为每个访问保留一个完整的时间戳。好的。

  • 好啊。


    欢迎来到面向数据设计的世界。基本的准则是:分类、消除分支、批量、消除virtual调用——所有步骤都要朝着更好的地方发展。

    既然你用C++标记了这个问题,这里就是强制性的典型C++废话。TonyAlbrecht的面向对象编程陷阱也是对这个主题的一个很好的介绍。


    刚刚开始:缓存不友好与缓存友好代码的典型例子是矩阵乘法的"缓存阻塞"。

    简单的矩阵乘法看起来像

    1
    2
    3
    4
    5
    6
    7
    8
    for(i=0;i<N;i++) {
       for(j=0;j<N;j++) {
          dest[i][j] = 0;
          for( k==;k<N;i++) {
             dest[i][j] += src1[i][k] * src2[k][j];
          }
       }
    }

    如果N很大,例如,如果N * sizeof(elemType)大于缓存大小,那么对src2[k][j]的每个访问都将是缓存未命中。

    有许多不同的方法可以为缓存优化这个特性。下面是一个非常简单的示例:不要在内部循环中每个缓存行读取一个项目,而是使用所有项目:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    int itemsPerCacheLine = CacheLineSize / sizeof(elemType);

    for(i=0;i<N;i++) {
       for(j=0;j<N;j += itemsPerCacheLine ) {
          for(jj=0;jj<itemsPerCacheLine; jj+) {
             dest[i][j+jj] = 0;
          }
          for( k==;k<N;i++) {
             for(jj=0;jj<itemsPerCacheLine; jj+) {
                dest[i][j+jj] += src1[i][k] * src2[k][j+jj];
             }
          }
       }
    }

    如果缓存行大小为64字节,并且我们是在32位(4字节)浮点上操作的,那么每个缓存行有16个项目。通过这个简单的转换,缓存未命中的数量减少了大约16倍。

    Fancier转换在二维图块上操作,为多个缓存(l1、l2、tlb)进行优化,等等。

    谷歌搜索"缓存阻塞"的一些结果:

    http://stmptown.cc.gt.atl.ga.us/cse6230-hpcta-fa11/slides/11a-matmul-goto.pdf

    http://software.intel.com/en-us/articles/cache-blocking-techniques

    一个很好的视频动画优化缓存阻塞算法。

    http://www.youtube.com/watch?V= IFWGWGMRMRH0

    循环平铺与以下密切相关:

    http://en.wikipedia.org/wiki/loop瓷砖


    现在的处理器可以处理许多级别的级联内存区域。所以CPU将有一堆内存,它们在CPU芯片上。它可以很快地访问这个内存。有不同级别的缓存,每个缓存的访问速度都比下一个慢(并且更大),直到到达不在CPU上且访问速度相对慢得多的系统内存为止。

    从逻辑上讲,对于CPU的指令集,您只需要引用一个巨大的虚拟地址空间中的内存地址。当你访问一个内存地址时,CPU会去取它。在过去,它只取那个地址。但今天,CPU将围绕您所要求的位获取大量内存,并将其复制到缓存中。它假设,如果你要求一个特别的地址,很可能你会要求附近的地址很快。例如,如果您正在复制一个缓冲区,您将从连续的地址读写—一个接一个。

    所以今天当你获取一个地址时,它会检查第一级缓存,看看它是否已经将这个地址读到缓存中,如果它没有找到它,那么这就是一个缓存未命中,它必须到下一级缓存中才能找到它,直到它最终进入主内存。

    对缓存友好的代码试图在内存中保持访问紧密,以便最小化缓存未命中。

    一个例子是想象你想要复制一个巨大的二维表格。它在内存中以连续的到达行进行组织,一行紧接着下一行。

    如果您一次从左到右复制一行元素,这将是缓存友好的。如果您决定一次只复制表中的一列,那么您将复制完全相同的内存量——但它将不友好地进行缓存。


    需要澄清的是,不仅数据应该是缓存友好的,它对代码同样重要。这是除了分支预测,指令重新排序,避免实际的划分和其他技术。

    通常,代码越密集,存储它所需的缓存线就越少。这会导致更多的缓存线可用于数据。

    代码不应该在各处调用函数,因为它们通常需要自己的一个或多个缓存线,从而减少数据的缓存线。

    函数应该从缓存行对齐友好地址开始。尽管有(gcc)编译器开关可用于此目的,但请注意,如果函数非常短,则每个函数占用整个缓存线可能会浪费资源。例如,如果三个最常用的函数放在一个64字节的缓存行中,这比每个函数都有自己的行并导致两个缓存行对其他用途的可用性更低的情况下浪费更少。典型的对齐值可以是32或16。

    因此,要花一些额外的时间使代码密集。测试不同的构造,编译并检查生成的代码大小和配置文件。


    正如@marc claesen提到的,编写缓存友好代码的方法之一就是利用存储数据的结构。除此之外,编写缓存友好代码的另一种方法是:更改数据的存储方式;然后编写新代码以访问存储在这个新结构中的数据。

    在数据库系统如何线性化表的元组并存储它们的情况下,这是有意义的。存储表的元组有两种基本方法,即行存储和列存储。行存储中,顾名思义,元组是按行存储的。假设一个存储的名为Product的表有3个属性,即int32_t key, char name[56]int32_t price,那么一个元组的总大小是64字节。

    我们可以通过创建一个大小为n的Product结构数组来模拟主内存中非常基本的行存储查询执行,其中n是表中的行数。这种内存布局也称为结构数组。因此,产品的结构可以是:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct Product
    {
       int32_t key;
       char name[56];
       int32_t price'
    }

    /* create an array of structs */
    Product* table = new Product[N];
    /* now load this array of structs, from a file etc. */

    同样,我们可以通过创建3个大小为n的数组来模拟主内存中非常基本的列存储查询执行,每个数组对应于Product表的每个属性。这种内存布局也称为数组结构。因此,产品每个属性的3个数组可以是:

    1
    2
    3
    4
    5
    /* create separate arrays for each attribute */
    int32_t* key = new int32_t[N];
    char* name = new char[56*N];
    int32_t* price = new int32_t[N];
    /* now load these arrays, from a file etc. */

    现在,在加载了结构数组(row layout)和3个独立数组(column layout)之后,在内存中的表Product上有行存储和列存储。

    现在我们继续讨论缓存友好代码部分。假设我们表上的工作负载使我们对price属性进行了聚合查询。如

    1
    2
    SELECT SUM(price)
    FROM PRODUCT

    对于行存储,我们可以将上述SQL查询转换为

    1
    2
    3
    int sum = 0;
    for (int i=0; i<N; i++)
       sum = sum + table[i].price;

    对于列存储,我们可以将上述SQL查询转换为

    1
    2
    3
    int sum = 0;
    for (int i=0; i<N; i++)
       sum = sum + price[i];

    列存储区的代码将比此查询中的行布局代码更快,因为它只需要属性的子集,而在列布局中,我们只需要访问价格列。

    假设缓存线大小为64字节。

    在行布局的情况下,当读取缓存行时,只读取1(cacheline_size/product_struct_size = 64/64 = 1个元组)的价格值,因为我们的结构大小为64字节,它填充了整个缓存行,所以对于每个元组,行布局时都会发生缓存未命中。

    在列布局的情况下,当读取缓存行时,将读取16个(cacheline_size/price_int_size = 64/4 = 16个)元组的价格值,因为存储在内存中的16个连续价格值被带到缓存中,因此对于每16个元组,在列布局的情况下,缓存会丢失焦点。

    因此,对于给定的查询,列布局将更快,而对于表的一个子集的此类聚合查询,列布局将更快。您可以使用来自TPC-H基准测试的数据自己尝试这样的实验,并比较两种布局的运行时间。维基百科关于面向列的数据库系统的文章也不错。

    因此,在数据库系统中,如果事先知道查询工作负载,那么我们可以将数据存储在适合工作负载中查询的布局中,并从这些布局中访问数据。在上面的例子中,我们创建了一个列布局,并修改了代码来计算和,从而使它变得对缓存友好。


    请注意,缓存不只是缓存连续内存。它们有多行(至少4行),因此不连续和重叠的内存通常可以同样高效地存储。

    上述所有示例中缺少的是测量基准。关于表演有很多神话。除非你测量它,否则你不知道。不要使代码复杂化,除非您有一个有度量的改进。