什么是“缓存友好”代码?


738

缓存不友好的代码 ”和“ 缓存友好的 ”代码之间有什么区别?

如何确定我编写的高效缓存代码?


28
这可能会给您一个提示:stackoverflow.com/questions/9936132/…–
罗伯特·马丁

4
还应注意缓存行的大小。在现代处理器上,通常为64个字节。
John Dibling

3
这是另一篇很好的文章。该原则适用于任何OS(Linux,MaxOS或Windows)上的C / C ++程序:lwn.net/Articles/255364
paulsm4 2013年


Answers:


965

初赛

在现代计算机上,只有最低级别的内存结构(寄存器)才能在单个时钟周期内移动数据。但是,寄存器非常昂贵,并且大多数计算机内核都只有不到几十个寄存器(总计几百到千个字节)。在内存频谱(DRAM)的另一端,内存非常便宜(即从字面上看)便宜了数百万倍),但是在请求接收数据后需要花费数百个周期。为了弥合超快与昂贵以及超慢与廉价之间的差距,缓存是,以降低速度和成本来命名为L1,L2,L3。这个想法是,大多数执行代码经常会碰到一小组变量,而其余部分(一大组变量)则很少。如果处理器在L1缓存中找不到数据,那么它将在L2缓存中查找。如果不存在,则为L3缓存,如果不存在,则为主内存。这些“缺失”中的每一个在时间上都是昂贵的。

(类比是高速缓存是系统内存,因为系统内存太硬盘存储。硬盘存储非常便宜,但是非常慢)。

缓存是减少延迟影响的主要方法之一。解释一下Herb Sutter(下面的链接):增加带宽很容易,但是我们不能摆脱延迟

始终通过内存层次结构检索数据(最小==最快到最慢)。一个高速缓存命中/缺失通常是指在CPU缓存中的最高等级的命中/缺失-由最高级别我的意思是最大的==最慢的。高速缓存命中率对于性能至关重要,因为每次高速缓存未命中都会导致从RAM中获取数据(或更糟的是...),这会花费很多时间时间(RAM需要数百个周期,HDD需要数千万个周期)。相比之下,从(最高级别)高速缓存读取数据通常只需要几个周期。

在现代计算机体系结构中,性能瓶颈正在使CPU失效(例如访问RAM或更高版本)。随着时间的推移,这只会变得更糟。当前,处理器频率的增加不再与提高性能有关。问题是内存访问。因此,CPU中的硬件设计工作目前主要集中在优化缓存,预取,管道和并发性上。例如,现代的CPU将大约85%的芯片消耗在高速缓存上,并将高达99%的芯片用于存储/移动数据!

关于这个话题有很多要说的。这是有关缓存,内存层次结构和正确编程的一些出色参考:

缓存友好代码的主要概念

缓存友好型代码的一个非常重要的方面是关于局部性的原则,其目的是将相关数据紧密放置在内存中以实现高效的缓存。在CPU缓存方面,了解缓存行以了解其工作方式非常重要:缓存行如何工作?

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

  1. 时间位置:当访问给定的存储位置时,很可能在不久的将来再次访问同一位置。理想情况下,此信息仍将在此时进行缓存。
  2. 空间局部性:这是指将相关数据彼此靠近放置。缓存发生在许多级别,而不仅仅是在CPU中。例如,当您从RAM读取数据时,通常会提取比专门要求的更大的内存块,因为程序经常会很快需要该数据。HDD缓存遵循相同的思路。特别是对于CPU高速缓存,高速缓存行的概念很重要。

适当使用 货柜

缓存友好与缓存不友好的简单示例是 std::vector对比std::list。的元素std::vector被存储在连续的存储器,因此访问它们是一个以上高速缓存友好比在访问元素std::list,其存储其内容所有的地方。这是由于空间局部性。

Bjarne Stroustrup在这个youtube片段中给出了一个很好的例子(感谢@Mohammad Ali Baydoun提供的链接!)。

在数据结构和算法设计中不要忽略缓存

只要有可能,请尝试以最大程度利用缓存的方式调整数据结构和计算顺序。在这方面,一种常见的技术是缓存阻止 (Archive.org版本),这在高性能计算(例如ATLAS)中至关重要。

知道并利用数据的隐式结构

这个领域的许多人有时会忘记的另一个简单示例是专栏专业版(例如 )与行优先排序(例如 )用于存储二维数组。例如,考虑以下矩阵:

1 2
3 4

在行优先排序中,这将存储为1 2 3 4;在按大栏顺序排序时,它将存储为1 3 2 4。不难看出,不利用此顺序的实现将很快遇到(很容易避免!)缓存问题。不幸的是,我看到这样的东西常在我的域(机器学习)。@MatteoItalia在他的答案中更详细地显示了此示例。

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

为简单起见,假设高速缓存包含一个高速缓存行,该行可以包含2个矩阵元素,并且从内存中获取给定元素时,下一个也是。假设我们要对上述示例2x2矩阵中的所有元素求和(称为M):

利用顺序(例如,首先更改列索引) ):

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

不利用顺序(例如,首先更改行索引) ):

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的链接):为什么处理有序数组不是处理一个排序的数组快吗?

避免虚函数

在上下文中 virtual在缓存未命中方面,方法代表了一个有争议的问题(存在一个普遍共识,即就性能而言应尽可能避免使用)。虚拟功能可以查找过程中引起高速缓存未命中,但是这只是发生,如果不经常被称为特定功能(否则它可能会被缓存),所以这被看作是由一些非的问题。有关此问题的参考,请查看:在C ++类中拥有虚拟方法的性能成本是多少?

常见问题

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

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


27
也许你可以扩展答案一点点也解释说,在-multithreaded代码-数据,也可以过本地(如假共享)
TemplateRex

2
芯片设计者认为有用的缓存级别可以多达许多。通常,它们是在速度与大小之间取得平衡。如果您可以使L1高速缓存与L5一样大并且一样快,则只需要L1。
拉斐尔·巴普蒂斯塔

24
我意识到在StackOverflow上不赞成空的协议,但这是我到目前为止所看到的最清晰,最好的答案。马克,工作出色。
杰克·艾德利

2
@JackAidley感谢您的好评!当我看到这个问题引起人们的广泛关注时,我发现许多人可能会对某种程度的广泛解释感兴趣。我很高兴它很有用。
马克·克莱森

1
您没有提到的是,缓存友好的数据结构设计为适合缓存行并与内存对齐,以最佳利用缓存行。很好的答案!太棒了
马特

140

除了@Marc Claesen的答案外,我认为缓存不友好的代码的一个经典示例是可以按列而不是按行扫描C二维数组(例如位图图像)的代码。

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

相反,按列访问此类元素是缓存不友好的,因为同一列上的元素在内存中彼此相距很远(特别是它们的距离等于行的大小),因此,当您使用此访问模式时,在内存中跳来跳去,可能会浪费缓存来检索内存中附近的元素。

而破坏性能所需要的仅仅是

// 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] ...
    }
}

// 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度,然后再执行各种分析,从而将对缓存不友好的代码仅限制为旋转。


r,应该是x <width吗?
mowwwalker

13
现代图像编辑器使用图块作为内部存储,例如64x64像素的块。这对于本地操作(放置DAB,运行模糊滤镜)而言,对缓存更友好,因为在大多数情况下,相邻像素在两个方向上都在内存中接近。
maxy

我尝试在计算机上计时一个类似的示例,但发现时间是相同的。有没有其他人尝试计时?
gsingh2011 2013年

@ I3arnon:是的,第一个是缓存友好的,因为通常在C数组中以优先的顺序存储(当然,如果由于某种原因图像以列优先的顺序存储,则相反)。
Matteo Italia 2014年

1
@Gauthier:是的,第一个片段是好的;我认为当我写这篇文章时,我的思路是:“ [破坏正在运行的应用程序的性能]所要做的只是从...到...“
Matteo Italia,2016年

88

优化缓存的使用很大程度上可以归结为两个因素。

参考地点

第一个因素(其他人已经提到过)是参考的地点。引用的位置实际上确实具有两个维度:空间和时间。

  • 空间空间

空间维度还可以归结为两件事:首先,我们要密集地打包信息,因此更多信息将适合该有限的内存。例如,这意味着您需要在计算复杂度上进行重大改进,以基于基于指针连接的小节点的数据结构。

其次,我们希望将一起处理的信息也放置在一起。典型的缓存以“行”形式工作,这意味着当您访问某些信息时,附近地址的其他信息将与我们触摸的部分一起加载到缓存中。例如,当我触摸一个字节时,缓存可能会在该字节附近加载128或256个字节。为了充分利用这一点,通常需要安排数据以最大程度地提高您也将使用同时加载的其他数据的可能性。

仅举一个非常琐碎的例子,这可能意味着线性搜索比二进制搜索更具竞争优势。从缓存行中加载一项后,几乎免费使用该缓存行中的其余数据。仅当数据足够大以至于二进制搜索减少了您访问的缓存行数时,二进制搜索才会变得明显更快。

  • 时间

时间维度意味着在对某些数据执行某些操作时,您希望(尽可能多地)一次对该数据进行所有操作。

既然您已将其标记为C ++,我将指向一个相对缓存不友好的设计的经典示例:std::valarrayvalarray超载最算术运算符,这样我就可以(例如)说a = b + c + d;(其中abcd都是valarrays)做的那些阵列元素方面的加法。

问题在于它遍历一对输入,将结果放入临时目录,遍历另一对输入,依此类推。对于大量数据,一次计算的结果可能会在缓存中消失,然后再用于下一次计算,因此我们最终在获得最终结果之前反复读取(写入)数据。如果最终结果的每个元素将是这样(a[n] + b[n]) * (c[n] + d[n]);,我们通常不喜欢阅读各a[n]b[n]c[n]d[n]一次,做了计算,结果写,增量n和重复,直到我们就大功告成了。2

线路共享

第二个主要因素是避免线路共享。为了理解这一点,我们可能需要备份并稍微了解一下缓存的组织方式。缓存的最简单形式是直接映射。这意味着主存储器中的一个地址只能存储在缓存中的一个特定位置。如果我们使用的两个数据项映射到缓存中的同一位置,则它的效果很差-每次我们使用一个数据项时,都必须从缓存中清除另一个数据项,以便为另一个空间腾出空间。缓存的其余部分可能为空,但是这些项目将不会使用缓存的其他部分。

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

这个问题可能很明显:对于直接映射的缓存,碰巧映射到同一缓存位置的两个操作数可能导致不良行为。N向集关联缓存将数字从2增加到N + 1。将缓存组织成更多的“方式”会占用额外的电路,并且运行速度通常会变慢,因此(例如)8192方式集关联缓存也不是一个好的解决方案。

最终,尽管如此,但是在可移植代码中更难以控制这个因素。您对数据放置位置的控制通常相当有限。更糟糕的是,从地址到缓存的确切映射在其他类似处理器之间有所不同。但是,在某些情况下,值得这样做,例如分配一个大缓冲区,然后仅使用分配的部分内存来确保数据共享相同的缓存行(即使您可能需要检测确切的处理器和内存)。为此采取相应措施)。

  • 虚假分享

还有另一个相关的项目,称为“虚假共享”。这是在多处理器或多核系统中产生的,其中两个(或更多)处理器/核具有独立的数据,但属于同一缓存行。这迫使两个处理器/内核协调它们对数据的访问,即使每个处理器/内核都有自己的单独数据项。尤其是如果两者交替修改数据,由于必须在处理器之间不断穿梭数据,这可能会导致速度大大降低。通过将缓存组织成更多的“方式”或类似方式,很难解决此问题。防止它发生的主要方法是确保两个线程很少(最好永远不要)修改可能位于同一高速缓存行中的数据(关于控制分配数据地址的困难的相同警告)。


  1. 那些非常了解C ++的人可能会想知道这是否可以通过表达式模板之类的方法进行优化。我敢肯定,答案是肯定的,可以做到,如果可以,那将是一个相当大的胜利。但是,我不知道有人这样做,并且鉴于使用的很少valarray,我至少会惊讶地看到有人这样做。

  2. 如果有人想知道如何 valarray(为性能而专门设计的)怎么可能是这个严重错误,可以归结为一件事:它实际上是为较老的Crays等机器设计的,它使用了快速的主内存并且没有缓存。对于他们来说,这确实是一个近乎理想的设计。

  3. 是的,我正在简化:大多数缓存实际上并不能精确地衡量最近最少使用的项目,但是它们使用了一些启发式方法,旨在接近于此,而不必为每次访问保留完整的时间戳。


1
我喜欢您的答案中的其他信息,尤其是valarray示例。
马克·克莱森

1
+1最后:对集合关联性的简单描述!进一步编辑:这是关于SO的最有用的答案之一。谢谢。
工程师

32

欢迎来到面向数据的设计世界。基本口号是排序,消除分支,批处理,消除virtual呼叫-朝着更好的本地化的所有步骤。

由于您使用C ++标记了问题,因此这是强制性的典型C ++废话。托尼·阿尔布雷希特(Tony Albrecht)的“面向对象编程陷阱”也是对该主题的出色介绍。


1
您批处理的意思是什么,您可能不理解。
0x90

5
批处理:不是在单个对象上执行工作单元,而是在一批对象上执行工作。
2013年

AKA阻止,阻止寄存器,阻止缓存。
0x90

1
阻塞/非阻塞通常是指对象在并发环境中的行为。
2013年

2
批处理== 向量化
Amro

23

只是说明一下:缓存不友好与缓存友好代码的经典示例是矩阵乘法的“缓存阻止”。

天真矩阵乘法看起来像:

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]将是缓存未命中。

有多种优化缓存的方法。这是一个非常简单的示例:不要使用内部循环中的每个缓存行读取一个项目,而要使用所有项目:

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=0;k<N;k++) {
         for(jj=0;jj<itemsPerCacheLine; jj+) {
            dest[i][j+jj] += src1[i][k] * src2[k][j+jj];
         }
      }
   }
}

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

Fancier转换在2D切片上运行,针对多个缓存(L1,L2,TLB)进行优化,依此类推。

谷歌搜索“缓存阻止”的一些结果:

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

http://software.intel.com/zh-CN/articles/cache-blocking-techniques

一个优化的缓存阻止算法的漂亮视频动画。

http://www.youtube.com/watch?v=IFWgwGMMrh0

循环平铺密切相关:

http://en.wikipedia.org/wiki/Loop_tiling


7
读过这篇文章的人可能还会对我有关矩阵乘法的文章感兴趣,在该文章中,我通过将两个2000x2000 矩阵相乘来测试“缓存友好的” ikj算法和不友好的ijk算法。
马丁·托马

3
k==;我希望这是一个错字?
TrebledJ

13

如今,处理器可以处理许多级联的存储区域。因此,CPU将在CPU芯片本身上拥有一堆内存。它可以非常快速地访问该内存。缓存有不同的级别,每个级别的访问速度比下一个级别的访问速度慢(并且更大),直到获得不位于CPU上并且访问速度相对慢得多的系统内存。

从逻辑上讲,对于CPU的指令集,您仅引用巨大虚拟地址空间中的内存地址。当您访问单个内存地址时,CPU将获取它。在过去,它只会获取那个地址。但是今天,CPU会在您请求的位附近获取一堆内存,并将其复制到缓存中。它假定如果您要求的特定地址很可能很快就会在附近要求一个地址。例如,如果您要复制缓冲区,则可以从连续的地址读取和写入-一个接一个。

因此,今天,当您获取地址时,它会检查缓存的第一级,以查看是否已将该地址读取到缓存中;如果找不到该地址,则表明这是缓存未命中,因此必须进入下一级别。缓存以找到它,直到最终将其放入主内存中为止。

缓存友好的代码试图使访问在内存中保持紧密联系,以便最大程度地减少缓存未命中。

举个例子,假设您想复制一个巨大的二维表。它在内存中连续排列触及率行,然后紧跟着下一行。

如果您一次从左到右复制元素一次,那将是缓存友好的。如果您决定一次将表复制到一列,则将复制完全相同的内存量-但它对缓存不友好。


4

需要澄清的是,不仅数据应该是缓存友好的,而且对于代码同样重要。这是分支谓词,指令重新排序,避免实际除法和其他技术的补充。

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

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

函数应从高速缓存行对齐友好的地址开始。尽管有(gcc)编译器开关用于此操作,但请注意,如果函数非常短,则每个函数占用整个缓存行可能会很浪费。例如,如果最常用的三个函数放在一个64字节的高速缓存行中,则与每个函数都有自己的行相比,这将减少浪费,并减少两个高速缓存行可用于其他用途。典型的对齐值可以是32或16。

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


2

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

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

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

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表的每个属性一个数组),在主内存中模拟一个非常基本的列存储查询执行。这种内存布局也称为数组的结构。因此,Product的每个属性的3个数组可以是:

/* 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. */

现在,在加载结构数组(行布局)和3个单独的数组(列布局)之后,我们Product在内存中的表上有了行存储和列存储。

现在,我们继续进行缓存友好的代码部分。假设表上的工作量是这样,我们对price属性进行汇总查询。如

SELECT SUM(price)
FROM PRODUCT

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

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

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

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的数据自己尝试这种实验基准测试中,并比较两种布局的运行时间。在维基百科上面向列的数据库系统的文章也不错。

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


1

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

以上所有示例均缺少测量基准。关于性能有很多神话。除非您进行测量,否则您将不知道。除非您进行了适当的改进,否则不要使代码复杂化。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.