在现代计算机上,只有最低级别的内存结构(寄存器)才能在单个时钟周期内移动数据。但是,寄存器非常昂贵,并且大多数计算机内核只有不到几十个寄存器。在内存频谱( DRAM )的另一端,内存非常便宜(即便宜了数百万倍),但是在请求接收数据后需要花费数百个周期。为了弥合超快和昂贵以及超慢和便宜之间的差距,缓存存储器以降低的速度和成本命名为 L1,L2,L3。这个想法是,大多数执行代码经常会命中一小组变量,而其余部分(一组更大的变量)则很少。如果处理器无法在 L1 高速缓存中找到数据,那么它将在 L2 高速缓存中查找。如果不存在,则为 L3 高速缓存;如果不存在,则为主内存。这些 “缺失” 中的每一个在时间上都是昂贵的。
(类比是高速缓存是系统内存,因为系统内存太硬盘存储。硬盘存储非常便宜,但速度很慢)。
缓存是减少延迟影响的主要方法之一。解释一下 Herb Sutter(下面的链接):增加带宽很容易,但是我们不能摆脱延迟。
始终通过内存层次结构检索数据(最小 == 最快到最慢)。高速缓存命中 / 未命中通常是指 CPU 最高级别的高速缓存中的命中 / 未命中 - 最高水平是指最大 == 最慢。高速缓存命中率对于性能至关重要,因为每次高速缓存未命中都会导致从 RAM 中获取数据(或更糟的是...),这需要花费大量时间(RAM 需要数百个周期,HDD 需要数千万个周期)。相比之下,从(最高级别)高速缓存读取数据通常只需要几个周期。
在现代计算机体系结构中,性能瓶颈正在使 CPU 消亡(例如,访问 RAM 或更高版本)。随着时间的流逝,这只会变得更糟。当前,处理器频率的增加与提高性能不再相关。问题是内存访问。因此,CPU 中的硬件设计工作目前主要集中在优化缓存,预取,管道和并发性上。例如,现代的 CPU 将大约 85%的芯片消耗在高速缓存上,而将高达 99%的芯片用于存储 / 移动数据!
关于这个话题有很多要说的。这是有关缓存,内存层次结构和正确编程的一些出色参考:
缓存友好代码的一个非常重要的方面是关于局部性的原则,其目的是将相关数据紧密放置在内存中以实现高效的缓存。在 CPU 缓存方面,重要的是要了解缓存行,以了解其工作原理:缓存行如何工作?
以下特定方面对于优化缓存非常重要:
使用适当的C ++容器
c ++的std::vector
和std::list
是缓存友好和缓存不友好的简单示例。一个元素std::vector
存储在连续的内存,因此访问它们是更为缓存友好比在访问元素std::list
,其中存储内容所有的地方。这是由于空间局部性。
Bjarne Stroustrup 在这个 youtube 片段中给出了一个很好的例子(感谢 @Mohammad Ali Baydoun 提供的链接!)。
在数据结构和算法设计中不要忽略缓存
只要有可能,请尝试以最大程度利用缓存的方式调整您的数据结构和计算顺序。在这方面,一种常见的技术是缓存阻止(Archive.org 版本) ,这在高性能计算(例如ATLAS )中非常重要。
知道并利用数据的隐式结构
另一个简单的示例(本领域的许多人有时会忘记)是用于存储二维数组的列主排序(例如 fortran和matlab )与行主排序(例如c和c ++)的比较。例如,考虑以下矩阵:
1 2
3 4
在行优先排序中,它以1 2 3 4
形式存储在内存中;在按大列排序时,该值将存储为1 3 2 4
。不难看出,不利用此顺序的实现将很快遇到(很容易避免!)缓存问题。不幸的是,我看到这样的东西经常在我的域(机器学习)。 @MatteoItalia 在他的答案中更详细地显示了此示例。
当从内存中获取矩阵的某个元素时,其附近的元素也将被获取并存储在高速缓存行中。如果利用了排序,这将导致较少的内存访问(因为后续计算所需的下几个值已经在高速缓存行中)。
为简单起见,假设高速缓存包含一个高速缓存行,该行可以包含 2 个矩阵元素,并且当从内存中获取给定元素时,下一个也是。假设我们要对上述示例 2x2 矩阵中的所有元素求和(我们称其为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
不利用顺序(例如,首先在c ++ 中更改行索引):
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 ++的上下文中, virtual
方法在缓存未命中方面是一个有争议的问题(存在一个普遍的共识,即就性能而言应尽可能避免使用它们)。虚拟功能可以查找过程中引起高速缓存未命中,但是这只是发生在不经常被称为特定功能(否则它可能会被缓存),所以这被看作是由一些非的问题。有关此问题的参考,请查看: 在 C ++ 类中使用虚拟方法的性能成本是多少?
在具有多处理器高速缓存的现代体系结构中,一个常见的问题称为虚假共享。当每个单独的处理器试图在另一个内存区域中使用数据并将其存储在同一高速缓存行中时,就会发生这种情况。这会导致一次又一次地覆盖高速缓存行(其中包含另一个处理器可以使用的数据)。实际上,在这种情况下,不同的线程会导致缓存未命中,从而使彼此等待。另请参见(感谢 @Matt 提供链接):如何以及何时对齐缓存行大小?
RAM 内存缓存不足的一种极端症状(在这种情况下可能不是您的意思)就是所谓的抖动。当进程连续生成需要磁盘访问的页面错误(例如,访问当前页面中不在的内存)时,就会发生这种情况。
除了 @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 度,然后再执行各种分析,从而将对缓存不友好的代码仅限制为旋转。
优化缓存的使用很大程度上可以归结为两个因素。
第一个因素(其他人已经提到过)是参考的地点。引用的位置实际上确实具有两个维度:空间和时间。
空间维度还可以归结为两件事:首先,我们要密集地打包信息,因此更多信息将适合该有限的内存。例如,这意味着您需要在计算复杂度方面进行重大改进,以基于由指针连接的小节点来证明数据结构的合理性。
其次,我们希望将一起处理的信息也放置在一起。典型的缓存以 “行” 的形式工作,这意味着当您访问某些信息时,附近地址的其他信息将与我们触摸的部分一起加载到缓存中。例如,当我触摸一个字节时,缓存可能会在该字节附近加载 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 方式集关联缓存也不是一个好的解决方案。
最终,尽管如此,但是在可移植代码中更难以控制这个因素。您对数据放置位置的控制通常相当有限。更糟糕的是,从地址到缓存的确切映射在其他类似处理器之间也有所不同。但是,在某些情况下,值得这样做,例如分配一个大缓冲区,然后仅使用分配的部分内存来确保数据共享相同的缓存行(即使您可能需要检测确切的处理器和内存)。为此采取相应措施)。
还有另一个相关的项目,称为 “虚假共享”。这是在多处理器或多核系统中发生的,其中两个(或更多)处理器 / 核具有独立的数据,但属于同一缓存行。这迫使两个处理器 / 内核协调它们对数据的访问,即使每个处理器 / 内核都有自己的单独数据项。尤其是如果两者交替修改数据,由于必须在处理器之间不断穿梭数据,这可能会导致速度大大降低。通过将缓存组织成更多的 “方式” 或类似方式,很难解决此问题。防止这种情况发生的主要方法是确保两个线程很少(最好永远不要)修改可能位于同一高速缓存行中的数据(关于控制分配数据地址的困难的相同警告)。
那些非常了解 C ++ 的人可能会想知道这是否可以通过表达式模板之类的方法进行优化。我敢肯定,答案是肯定的,可以做到,如果可以,那将是一个相当可观的胜利。但是我不知道有人这样做,并且鉴于使用了很少的valarray
,我至少会惊讶地看到有人这样做。
如果有人想知道valarray
(专门为性能而设计)怎么可能是这个严重错误,则归结为一件事:它实际上是为像较早的 Crays 这样的机器设计的,它使用了快速的主内存而没有缓存。对于他们来说,这确实是一个近乎理想的设计。
是的,我正在简化:大多数缓存并不能真正准确地衡量最近最少使用的项目,但是它们使用了一些启发式方法,旨在接近于此,而不必为每次访问保留完整的时间戳。