什么是“不利于缓存的代码”和“友好缓存的代码”之间的区别?
我如何确保编写高效的缓存代码?
什么是“不利于缓存的代码”和“友好缓存的代码”之间的区别?
我如何确保编写高效的缓存代码?
在现代计算机上,只有最低级的内存结构(寄存器)可以在单个时钟周期内移动数据。然而,寄存器非常昂贵,大多数计算机核心只有不到几十个寄存器。在内存谱系的另一端(DRAM),内存非常便宜(即文字意义上的便宜了数百万倍),但需要数百个周期才能在请求后接收数据。为了弥合这种超快速和昂贵与超慢速和便宜之间的差距,出现了高速缓存存储器,按照降序分别命名为L1、L2、L3,速度和成本逐渐降低。其思想是,大部分执行代码将经常访问一小组变量,而其余部分(一个更大的变量集)则很少访问。如果处理器在L1高速缓存中找不到数据,则会查找L2高速缓存。如果没有,那么查找L3高速缓存,如果还没有,就查找主内存。每次“未命中”都会消耗大量时间。
(类比:高速缓存存储器对系统内存,就像系统内存对硬盘存储一样。硬盘存储非常便宜但非常慢)。
缓存是减少延迟影响的主要方法之一。为了引用Herb Sutter的话(参见下面的链接):“增加带宽很容易,但我们无法通过购买来摆脱延迟”。数据总是通过内存层次结构检索(最小==最快到最慢)。“缓存命中/失效”通常指CPU中最高级别的缓存命中/失效--我所说的最高级别是最大==最慢的。缓存命中率对性能至关重要,因为每个缓存未命中都会导致从RAM(或更糟的情况...)获取数据,这需要很长时间(RAM需要数百个周期,HDD需要数千万个周期)。相比之下,从(最高级别)缓存中读取数据通常只需要几个周期。这个主题有很多值得讨论的地方。以下是一些关于缓存、内存层次结构和适当编程的优秀参考资料:
使用合适的c++容器
缓存友好和不友好的简单例子是c++的std::vector
和std::list
。 std::vector
的元素存储在连续的内存中,因此访问它们比访问std::list
中的元素更加缓存友好,后者将其内容存储在各个位置。这是由于空间局部性。
Bjarne Stroustrup在this youtube clip中给出了一个非常好的说明(感谢@Mohammad Ali Baydoun提供链接!)。
在数据结构和算法设计中不要忽视缓存
尽可能地,尝试以允许最大缓存利用的方式调整数据结构和计算顺序。在这方面的常见技术是缓存阻塞 (Archive.org版本),这在高性能计算中非常重要(例如ATLAS)。
了解并利用数据的隐含结构
另一个简单的例子,有时候在领域中被许多人忘记的是按列存储(例如 fortran, matlab)和按行存储(例如 c, c++)二维数组。例如,考虑以下矩阵:
1 2
3 4
1 2 3 4
;在列主序中,这将被存储为1 3 2 4
。很容易看出,不利用这种排序的实现将很快遇到(可以轻松避免的!)缓存问题。不幸的是,在我的领域(机器学习)中我经常看到这样的东西。@MatteoItalia在他的答案中更详细地展示了这个例子。M
)中的所有元素进行求和:
利用排序(例如在 c++ 中首先更改列索引):
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
方法在缓存未命中方面引起了争议(普遍的共识是尽可能避免它们以获得更好的性能)。虚函数在查找时可能会导致缓存未命中,但这仅会在特定函数不经常被调用时发生(否则它很可能已被缓存),因此某些人认为这不是问题。有关此问题的参考,请查看:在C++类中使用虚方法的性能成本是多少?
除了@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度,然后执行各种分析,将不友好缓存的代码限制到旋转操作中。
image[0][1]
与image[0][2]
相邻。因此内部循环应该遍历第二个索引,对吧?for each y in rows: (fetch a whole row, hopefully) for each x in cols: ...image[y][x]...
。这使得你的第一个代码片段是正确的,而不是第二个。我有什么遗漏吗? - Gauthier优化缓存使用主要涉及两个因素。
第一个因素(其他人已经提到过)是引用局部性。 引用局部性实际上有两个维度:空间和时间。
空间维度也归结为两件事:首先,我们希望将信息密集地打包在一起,这样更多的信息就可以适应有限的内存。 这意味着(例如)需要显着改善计算复杂度才能证明基于指针连接的小节点的数据结构比较好。
其次,我们希望处理在一起的信息也放在一起。 典型的缓存工作方式是“行”,这意味着当您访问某些信息时,附近地址的其他信息将与我们触摸的部分一起加载到缓存中。例如,当我触摸一个字节时,缓存可能会加载该字节附近的128或256个字节。为了充分利用它,通常希望最大化数据排列,以增加同时访问其他数据的可能性。
对于一个非常微不足道的例子,这意味着线性搜索与二分搜索相比可以更加具有竞争力。一旦您从缓存行中加载了一个项目,在该缓存行中使用其余数据几乎是免费的。仅当数据足够大,以至于二分搜索减少所访问的缓存行数时,二分搜索才会明显变快。
时间维度意味着在对某些数据执行某些操作时,尽可能一次性执行所有操作。
由于您将其标记为C ++,因此我将指向一个相对较不友好缓存设计的经典示例:std::valarray
。 valarray
重载了大多数算术运算符,因此我可以(例如)说a = b + c + d;
(其中a
,b
,c
和d
都是valarray),以对这些数组进行逐元素加法运算。
这种方法的问题在于,它会处理一对输入,将结果放入临时存储器中,然后处理另一对输入,如此反复。如果数据量很大,那么在下一次计算之前,上一次计算得到的结果可能已经从缓存中消失了,因此我们最终需要多次读取(和写入)数据才能得到最终结果。如果最终结果的每个元素都像(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路组相联缓存也很少是一个好的解决方案。如果有人想知道为什么valarray
(专门设计用于提高性能)会出现如此严重的问题,那就归结于一件事:它确实是为像旧Cray这样使用快速主存和没有缓存的机器而设计的。对于它们来说,这真的是一个几乎理想的设计。
是的,我在简化:大多数缓存并不精确地测量最近未使用的项目,但它们使用一些启发式方法,旨在接近这个值,而无需为每个访问保留完整的时间戳。
valarray
的例子。 - Marc Claesen欢迎来到数据导向设计世界。基本口号是:排序、消除分支、批处理、消除virtual
函数调用,所有这些步骤都是为了更好的局部性。
既然您用C++标记了问题,这里有一份必备的典型的C++废话。Tony Albrecht的《面向对象编程的陷阱》也是该主题的很好的入门材料。
仅仅叠加:缓存不友好代码与缓存友好代码的经典例子是对矩阵乘法进行“缓存分块”。
朴素的矩阵乘法如下:
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倍。
更高级的转换操作在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
循环分块与此密切相关:
现今的处理器与许多级联记忆区域一起工作。因此,CPU将拥有一些位于CPU芯片本身上的内存。它可以非常快速地访问这些内存。有不同级别的缓存,每个级别的访问速度都比下一个级别慢(但容量更大),直到您到达系统内存,在CPU上没有这种内存,并且相对较慢。
从逻辑上讲,对于CPU的指令集,您只需在一个巨大的虚拟地址空间中引用内存地址。当您访问单个内存地址时,CPU会去获取它。在旧日子里,它将只获取该单个地址。但是今天,CPU将获取您请求的一堆围绕该位的内存,并将其复制到缓存中。它认为,如果您请求了特定地址,则很可能您很快会请求附近的地址。例如,如果要复制缓冲区,则会从连续地址读取和写入-一个接一个。
因此,今天当您获取地址时,它会检查第一级缓存以查看是否已将该地址读入缓存中,如果找不到它,则这是缓存未命中,必须转到下一级缓存来查找,直到最终必须转到主内存。
缓存友好的代码试图将内存访问保持在相邻位置,以最小化缓存未命中。
因此,一个例子是想象您要复制一个巨大的二维表。它按连续的行组织在内存中,每一行紧随其后。
如果您从左到右逐行复制元素,则这将是缓存友好的。如果您决定逐列复制表格,则复制的内存量完全相同-但这不太友好。
Product
的表被存储,具有3个属性,即int32_t key, char name[56]
和int32_t price
,因此一个元组的总大小为64
字节。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. */
Product
表的一个属性。这种内存布局也称为结构体数组。因此,每个Product属性的三个数组可能如下所示:/* 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. */
Product
上有了行存储和列存储。SELECT SUM(price)
FROM PRODUCT
int sum = 0;
for (int i=0; i<N; i++)
sum = sum + table[i].price;
int sum = 0;
for (int i=0; i<N; i++)
sum = sum + price[i];
cacheline_size/product_struct_size = 64/64 = 1
),因为我们的结构体大小为64字节,它填满了整个缓存行,所以对于每个元组,在行布局中会发生一次缓存未命中。cacheline_size/price_int_size = 64/4 = 16
),因为连续存储在内存中的16个价格值被带入缓存,所以对于每16个元组,在列布局中会发生一次缓存未命中。