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

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


当前回答

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

这在数据库系统如何线性化表的元组并存储它们的情况下是有意义的。存储表的元组有两种基本方法,即行存储和列存储。行存储,顾名思义,元组是按行存储的。假设存储的名为Product的表具有3个属性,即int32_t key、char name[56]和int32_tprice,因此元组的总大小为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. */

类似地,我们可以通过创建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个连续价格值被带到缓存中,因此在列布局情况下,每十六个元组都会出现缓存未命中。

因此,在给定查询的情况下,列布局将更快,而在表的列子集上的此类聚合查询中,列布局会更快。您可以使用TPC-H基准测试中的数据自己进行这样的实验,并比较两种布局的运行时间。维基百科关于面向列的数据库系统的文章也很好。

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

其他回答

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

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

优化缓存使用率主要取决于两个因素。

参考地点

第一个因素(其他人已经提到)是参考的地方性。然而,引用的地点实际上有两个维度:空间和时间。

空间的

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

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

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

时间

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

既然您已经将其标记为C++,我将指出一个相对不友好的缓存设计的经典示例:std::valarray。valarray重载了大多数算术运算符,所以我可以(例如)说a=b+c+d;(其中a、b、c和d都是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方式集关联缓存也很少是好的解决方案。

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

虚假共享

还有一个相关的项目叫做“虚假分享”。这出现在多处理器或多核系统中,其中两个(或多个)处理器/核具有独立的数据,但位于同一缓存线中。这迫使两个处理器/内核协调对数据的访问,即使每个处理器/内核都有自己的独立数据项。特别是如果两个处理器交替修改数据,这可能会导致数据在处理器之间不断穿梭,从而导致速度大幅放缓。通过将缓存组织成更多的“方式”或类似的方式,这是不容易解决的。防止这种情况的主要方法是确保两个线程很少(最好永远不会)修改可能位于同一缓存行中的数据(同时也要注意控制数据分配地址的难度)。


熟悉C++的人可能会想,这是否可以通过表达式模板等方式进行优化。我很肯定答案是肯定的,这是可能的,如果是的话,这可能是一场相当可观的胜利。然而,我不知道有人这样做,而且考虑到valarray的使用量很少,看到有人这么做,我至少会有点惊讶。如果有人想知道valarray(专门为性能而设计的)是如何严重错误的,那就归结为一件事:它确实是为像旧版Crays这样的机器设计的,使用了快速的主内存,没有缓存。对他们来说,这真的是一个近乎理想的设计。是的,我在简化:大多数缓存并没有精确地测量最近最少使用的项目,但它们使用了一些启发式方法,目的是为了接近这一点,而不必为每次访问保留完整的时间戳。

简单来说:缓存不友好代码与缓存友好代码的典型例子是矩阵乘法的“缓存阻塞”。

朴素矩阵乘法看起来像:

for(i=0;i<N;i++) {
   for(j=0;j<N;j++) {
      dest[i][j] = 0;
      for( k=0;k<N;k++) {
         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/en-us/articles/cache-blocking-techniques

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

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

循环平铺关系非常密切:

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

欢迎来到面向数据设计的世界。基本的口头禅是“排序”、“消除分支”、“批处理”和“消除虚拟呼叫”,所有这些步骤都是为了更好地定位。

既然你用C++标记了这个问题,这里是强制性的典型C++废话。托尼·阿尔布雷希特(Tony Albrecht)的《面向对象编程的陷阱》也是对这一主题的一个很好的介绍。

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

这在数据库系统如何线性化表的元组并存储它们的情况下是有意义的。存储表的元组有两种基本方法,即行存储和列存储。行存储,顾名思义,元组是按行存储的。假设存储的名为Product的表具有3个属性,即int32_t key、char name[56]和int32_tprice,因此元组的总大小为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. */

类似地,我们可以通过创建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个连续价格值被带到缓存中,因此在列布局情况下,每十六个元组都会出现缓存未命中。

因此,在给定查询的情况下,列布局将更快,而在表的列子集上的此类聚合查询中,列布局会更快。您可以使用TPC-H基准测试中的数据自己进行这样的实验,并比较两种布局的运行时间。维基百科关于面向列的数据库系统的文章也很好。

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