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

时间:2013-05-22 18:37:02

标签: c++ performance caching memory cpu-cache

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

如何确保编写缓存效率高的代码?

9 个答案:

答案 0 :(得分:879)

预赛

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

(类比是缓存是系统内存,因为系统内存是硬盘存储。硬盘存储超级便宜,但速度很慢。)

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

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

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

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

缓存友好代码的主要概念

缓存友好代码的一个非常重要的方面是关于 the principle of locality ,其目标是将相关数据放在内存中以允许有效的缓存。就CPU缓存而言,了解缓存行以了解其工作原理非常重要:How do cache lines work?

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

  1. 时间位置:访问给定的内存位置时,很可能在不久的将来再次访问相同的位置。理想情况下,此信息仍将在此时缓存。
  2. 空间位置:这是指将相关数据放在彼此靠近的位置。缓存发生在许多层面,而不仅仅是在CPU中。例如,当您从RAM读取时,通常会获取比特别要求的更大的内存块,因为程序通常很快就会需要这些数据。 HDD缓存遵循相同的思路。特别是对于CPU缓存,缓存行的概念很重要。
  3. 使用适当的容器

    缓存友好与缓存不友好的一个简单示例是 std::vectorstd::liststd::vector的元素存储在连续的内存中,因此访问它们 比访问std::list中的元素更加缓存友好,1 2 3 4 存储其内容遍及整个地点。这是由于空间局部性。

    Bjarne Stroustrup在this youtube clip给出了一个非常好的例证(感谢@Mohammad Ali Baydoun的链接!)。

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

    尽可能尝试以允许最大限度地使用缓存的方式调整数据结构和计算顺序。这方面的一个常见技术是cache blocking (Archive.org version),这在高性能计算中非常重要(例如,ATLAS)。

    了解并利用隐含的数据结构

    该领域的许多人有时会忘记的另一个简单示例是列专业(例如)与行主要排序(例如,{ {3}})用于存储二维数组。例如,请考虑以下矩阵:

    1 2 3 4

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

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

    为简单起见,假设缓存包含一个缓存行,该缓存行可以包含2个矩阵元素,并且当从内存中提取给定元素时,下一个也是如此。假设我们想要对上面的示例2x2矩阵中的所有元素求和(让我们称之为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
    

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

    virtual

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

    避免不可预测的分支

    现代架构功能管道和编译器在重新排序代码方面变得非常擅长,以最大限度地减少因内存访问而导致的延迟。当您的关键代码包含(不可预测的)分支时,很难或不可能预取数据。这将间接导致更多的缓存未命中。

    这里解释非常(感谢@ 0x90的链接):

    避免虚拟功能

    Why is it faster to process a sorted array than an unsorted array?的上下文中,{{1}}方法代表了有关缓存未命中的一个有争议的问题(存在一种普遍共识,即在性能方面应尽可能避免它们)。虚拟函数可以在查找期间引发缓存未命中,但这只发生如果没有经常调用特定函数(否则它可能会被缓存),所以这被一些人视为非问题。有关此问题的参考,请查看:

    常见问题

    具有多处理器缓存的现代体系结构中的常见问题称为What is the performance cost of having a virtual method in a C++ class?。当每个处理器尝试使用另一个内存区域中的数据并尝试将其存储在同一缓存行中时,会发生这种情况。这会导致缓存行 - 包含另一个处理器可以使用的数据 - 一次又一次地被覆盖。实际上,在这种情况下,不同的线程会通过引发缓存未命中而使彼此等待。 另请参阅(感谢@Matt的链接):false sharing

    RAM内存中缓存不佳的极端症状(可能不是您在此上下文中的意思)是所谓的How and when to align to cache line size?。当进程连续生成需要磁盘访问的页面错误(例如,访问不在当前页面中的内存)时,会发生这种情况。

答案 1 :(得分:132)

除了@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度的图像,然后再执行各种分析,将缓存不友好的代码限制为旋转。

答案 2 :(得分:82)

优化缓存使用主要归结为两个因素。

参考地点

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

  • 空间

空间维度也归结为两件事:首先,我们希望密集地收集信息,因此更多信息将适合有限的内存。这意味着(例如)您需要在计算复杂性方面进行重大改进,以证明基于指针连接的小节点的数据结构。

其次,我们希望将一起处理的信息也放在一起。典型的缓存在&#34; lines&#34;中工作,这意味着当您访问某些信息时,附近地址的其他信息将被我们触摸的部分加载到缓存中。例如,当我触摸一个字节时,缓存可能会在该字节附近加载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

线路共享

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

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

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

最终,这个因素在便携式代码中更难以控制。您对数据放置位置的控制通常相当有限。更糟糕的是,从地址到高速缓存的确切映射在其他类似处理器之间变化。但是,在某些情况下,可能值得做一些事情,比如分配一个大缓冲区,然后只使用你分配的部分内容来确保数据共享相同的缓存行(即使你可能需要检测确切的处理器并据此采取行动)。

  • 虚假分享

另一个相关项目称为&#34; false sharing&#34;。这出现在多处理器或多核系统中,其中两个(或更多个)处理器/核心具有分离的数据,但是落在同一高速缓存行中。这会强制两个处理器/内核协调对数据的访问,即使每个处理器/内核都有自己独立的数据项。特别是如果两者交替修改数据,这可能导致大幅减速,因为数据必须在处理器之间不断地穿梭。通过将缓存组织成更多&#34;方式,可以很容易地解决这个问题。或类似的东西。防止它的主要方法是确保两个线程很少(最好是从不)修改可能位于同一缓存行中的数据(对于控制分配数据的地址的难度有相同的警告)。


  1. 那些熟悉C ++的人可能会怀疑这是否可以通过类似表达模板的方式进行优化。我非常确定答案是肯定的,它可以完成,如果是的话,它可能是一个相当可观的胜利。但是,我并不知道有人这样做了,并且考虑到valarray的使用率很低,看到有人这样做,我至少有点惊讶。

    < / LI>
  2. 如果有人想知道valarray(专门针对性能而设计)是怎么会出现这种严重错误的,那么归结为一件事:它真的是为像旧款Crays这样的机器设计的内存和没有缓存。对他们来说,这确实是一个近乎理想的设计。

  3. 是的,我简化了:大多数缓存都没有准确地测量最近最少使用的项目,但他们使用了一些旨在接近它的启发式而不必为每次访问保留一个完整的时间戳。

答案 3 :(得分:30)

欢迎来到面向数据的设计世界。基本的口头禅是排序,消除分支,批量,消除virtual电话 - 所有步骤都朝着更好的地方发展。

由于您使用C ++标记了问题,因此必须使用typical C++ Bullshit。托尼·阿尔布雷希特的Pitfalls of Object Oriented Programming也是这个主题的一个很好的介绍。

答案 4 :(得分:21)

刚刚堆积:缓存不友好与缓存友好代码的典型示例是矩阵乘法的“缓存阻塞”。

朴素矩阵乘法看起来像

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==;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转换在2D图块上运行,针对多个缓存(L1,L2,TLB)进行优化,等等。

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

http://stumptown.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=IFWgwGMMrh0

循环平铺非常密切相关:

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

答案 5 :(得分:13)

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

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

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

缓存友好代码尝试将访问保持在内存中,以便最大限度地减少缓存未命中。

所以一个例子就是想象你想复制一个巨大的二维表。它在内存中以连续行的形式组织,并且紧跟在下一行之后的一行。

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

答案 6 :(得分:4)

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

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

代码不应该遍布整个地方的函数,因为它们通常需要一个或多个自己的缓存行,从而减少数据的缓存行。

函数应该从缓存行对齐友好的地址开始。虽然有(gcc)编译器开关,但是要注意,如果函数非常短,则每个函数占用整个高速缓存行可能是浪费。例如,如果最常用的三个函数适合一个64字节高速缓存行,那么与每个高速缓存行具有自己的行并且导致两个高速缓存行不太可用于其他用途相比,这样做更少浪费。典型的对齐值可以是32或16。

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

答案 7 :(得分:2)

正如@Marc Claesen所提到的,编写缓存友好代码的方法之一是利用存储数据的结构。除了编写缓存友好代码的另一种方法是:改变我们的数据存储方式;然后编写新代码来访问存储在这个新结构中的数据。

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

我们可以通过创建大小为N的Product结构数组来模拟主内存中非常基本的行存储查询执行,其中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. */

类似地,我们可以通过创建一个大小为N的3个数组,为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基准测试中的数据自行尝试此类实验,并比较两种布局的运行时间。关于面向列的数据库系统的wikipedia文章也很好。

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

答案 8 :(得分:0)

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

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