c - 什么是"缓存友好" 代码?

  显示原文与译文双语对照的内容

有人可以给出"缓存不友好的代码"的一个例子和该代码的"缓存友好"版本?

如何确保我编写了cache-efficient代码?

时间:

预置

现代计算机体系结构具有复杂的内存层次结构: 寄存器,通常在CPU芯片( L1,L2,L3,指令缓存,。。),RAM,hdd ( 拥有他们自己的缓存) 等的几个级别缓存。 基本的口号是:的快速内存是昂贵的 。 这是我们今天看到的高级缓存的核心原因。 缓存是减少延迟 ( cfr ) 影响的主要方法之一。 我在开始时链接的Herb Sutter talk 。 paraphrase 。Sutter ( cfr ) 。 转到下面的链接)的延迟: 增加带宽是容易的,但是我们不能买了我们的出路。

数据总是通过内存层次结构( 最小的==最快到最慢) 检索的。 一个缓存击中击通常指的是击中击不中的最高级别的缓存中CPU由最高水平我是说最大的== --慢的方法。 缓存命中率对性能是至关重要的,因为会导致从内存获取数据每次缓存缺页( 或者更糟的是。。) 它接受 大量的时间( 内存有数百个周期,有几万个周期用于硬盘) 。 相比之下,从( 最高级别) 缓存读取数据通常只需要几个周期。

在现代计算机架构中,性能瓶颈正在离开CPU芯片( 例如。 访问内存或者更高版本。这只会随着时间的推移而恶化。 处理器频率的增加目前不再与提高性能有关。 解决问题的问题是内存访问。 关于优化缓存,预取。管道和 concurrency, 硬件设计工作在当前焦点 heavily. CPU 因此 于上存储/模具运动现代 CPU data,例如花的85%周围相关缓存和高达 99% !

这个主题有很多要讲的内容。 以下是关于缓存,内存层次结构和适当编程的一些很好的参考:

cache-friendly代码的主要概念

在内存中,以允许高效caching,中重要的一个方面cache-friendly代码都是关于 局部性的原理,结果放置在相关数据的目标自动关闭 在CPU方面,缓存时,重要的是要理解它的工作原理需要注意的缓存线: :缓存行的工作方式

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

  1. 时间局部性︰给定的内存位置被访问时,很可能在不久的将来再次访问时相同的位置。 理想情况下,这里信息仍将在此时缓存。
  2. 空间局部性 这是指放置相关数据接近化学计量彼此堆积。 缓存在许多级别上发生,而不仅仅在CPU中。 例如当你从磁盘读取内存,通常是一个更大的内存块比指定要和是什么,因为很多时候的程序将需要该数据时,才能提取很快。 硬盘缓存遵循相同的思路。 特别是对于CPU缓存,缓存线的概念是非常重要的。

使用适当的 容器

cache-friendly与cache-unfriendly的一个简单示例是 std::vectorstd::list的。 一个 std::vector的元素存储在连续内存中,因此访问这些属性都是多少更cache-friendly除了访问元素在一个地方都在 std::list,存储了它的内容。 这是因为空间位置。

关于照片剪辑给出了一个非常好的展示了这个由 Bjarne

在数据结构与算法设计, 也不要忽略缓存

只要有可能,尽量让你的数据适应结构与顺序进行的计算的一种方法,它允许最大使用缓存的情况。 在这方面的一种常见技术是缓存阻塞,它在高性能计算中非常重要( cfr 。 例如地图集 ) 。

知道和利用这个数据的隐含结构

另一个简单的例子,这个字段中很多人有时忘记的是 column-major ( 例如。 ) vs row-major排序( 例。 例如考虑以下矩阵:

 
1 2
3 4

 

在row-major排序中,这是存储在内存中 1 2 3 4 ;在column-major中,这将被存储为 1 3 2 4 很容易看到不利用这里排序的实现会很快陷入( 容易避免的) 缓存问题。 在我的域不幸的是,我看到的东西比如这非常 often. @MatteoItalia 在他的回答中更详细地展示了这个例子。

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

为简单起见,假设缓存于内存,下一个是too,包括一个缓存哪个可能包含 2矩阵元素和,当给定元素 fetched. 在示例中 2 x2矩阵上说我们想把这各项之 elements:

使用排序( e.g 。在 中先更改列索引):


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的链接): 为什么处理排序数组比未排序数组快

避免虚函数

对高速缓存未命中次数与regard上下文中的virtual 定方法代表一个有争议的事 虚函数也可以诱导过程中缓存未命中数查找,但这只发生在特定的函数不会调用通常如果 ( 否则它可能被缓存),因此这被认为是一个由一些 non-issue 。 有关这里问题的参考,请签出: C++ 类中有虚方法的性能成本是多少

常见问题

多处理器缓存中的一个常见问题叫做假共享 。 发生这种情况是当每个单独的处理器正在尝试使用另一个内存区域中的数据并试图将它的存储在相同的缓存线。 这将导致该缓存行--它包含数据另一个处理器可以使用--来被重写一次又一次。 实际上,不同的线程会在这种情况下等待缓存未命中。 另请参阅( 感谢 @Matt的链接): 如何和什么时候对齐缓存行大小

RAM内存( 这可能不是你在这个上下文中的意思) 中缓存的一个极端症状是抖动 。 当进程连续生成页面错误时发生( 例如。 访问不在当前页面中的内存,该内存需要磁盘访问。

除了 claesen @Marc 回答中,我认为经典的cache-unfriendly例子代码是嵌入式系统硬件设计代码,它扫描一个C 二维数组( 如,态 位图图像) column-wise而不是 row-wise 。

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

相反,访问这样的元素column-wise是cache-unfriendly的,因为同一个列的元素在内存中是遥远的,所以当你使用这个访问模式时,你就会在内存中跳过内存中的元素的缓存。

而它所需要的就是从


for(unsigned int y=0; y<height; ++y)
{
 for(unsigned int x=0; x<width; ++x)
 {
. . . image[y][x].. .
 }
}


for(unsigned int x=0; x<width; ++y)
{
 for(unsigned int y=0; y<height; ++x)
 {
. . . image[y][x].. .
 }
}

这种效果在具有小缓存和/或者处理大数组的系统中可能非常引人注目( 例如。 10 + 像素 24 bpp图像在当前机器上,由于这个原因,如果你需要做很多垂直扫描,通常最好先旋转 90度图像,然后再进行各种分析,限制cache-unfriendly代码。

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

引用的位置

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

  • 空间空间

空间维度也归结为两个方面: 首先,我们想把我们的信息打包,这样就能在有限的内存中容纳更多的信息。 这意味着( 例如) 需要在计算复杂性方面有一个重大改进,以根据指针连接的小节点来调整数据结构。

其次,我们想要同时处理的信息也在一起。 一个典型的缓存工作在"线条"中,这意味着当你访问一些信息时,附近地址的其他信息将被加载到缓存中,这部分是我们所碰到的。 例如当我触摸一个字节时,缓存可能在一个字节附近加载 128或者 256字节。 为了利用这个优势,你通常希望安排的数据最大限度地使用同时加载的其他数据。

对于一个很小的例子来说,这意味着线性搜索比你期望的二进制搜索更具竞争力。 一旦从缓存行加载了一个项目,使用该缓存行中的其余数据几乎是免费的。 只有当数据足够大,二进制搜索减少你访问的缓存行数时,二进制搜索才会变得明显。

  • 时间

时间维度意味着当你对某些数据执行某些操作时,你需要( 尽可能多地) 同时对该数据执行所有操作。

由于你将它的标记为 C++,我将指向一个相对cache-unfriendly设计的典型示例: std::valarrayvalarray 重载大多数算术运算符,所以我可以说 a = b + c + d; ( abcd 都是 valarrays ) 来执行这些数组的element-wise添加。

这个问题是它通过了一对输入,将结果放入一个临时,遍历另一个输入,等等。 有了大量的数据,一个计算结果可能会在下次计算时从缓存中消失,因此我们最终会在得到最终结果之前反复读取数据。 如果最终结果的每个元素都是类似 (a[n] + b[n]) * (c[n] + d[n]); 我们通常更喜欢读取每个 a[n]b[n]c[n]d[n] 一次,执行计算,写入结果,递增 n 和重复,直到我们完成为止。 2

线路共享

第二个主要因素是避免行共享。 为了理解这点,我们可能需要备份并查看缓存的组织。 最简单的缓存形式是直接映射。 这意味着主内存中的一个地址只能存储在缓存中的一个特定位置。 在缓存为了腾出other,如果我们使用的是缓存中的两个数据项映射到同一个现场,它工作严重--每次我们使用一个数据项,另必须要 flushed. 缓存的其余部分可能是空的,但是这些项目将不使用缓存的其他部分。

为了防止这种情况,大多数缓存都被称为"设置关联"。 例如在 4 -way set-associative缓存中,主内存中的任何项目都可以存储在缓存中 4个不同位置的任意位置。 因此,当缓存加载一个项目时,它会寻找最近使用过 3 在这四个项目中,将它的刷新到主内存,并在它的位置加载新项目。

问题可能很明显: 对于direct-mapped缓存,映射到同一个缓存位置的两个操作数可能导致错误的行为。 N-way set-associative缓存将数字从 2增加到 N+1. 将缓存组织到更多的"路径"占用额外的电路,通常运行较慢,所以( 例如) 8192 -way集的关联缓存不是一个好的解决方案。

最终,在可移植代码中,这个因素更难控制。 你对数据放置位置的控制通常是相当有限的。 更糟糕的是,从地址到缓存的精确映射在不同的处理器之间不同。 在某些情况下,但是,它都是值得做的事情,如分配一个大型缓冲区,然后备份,且仅使用你所分配到的防止数据共享同一个缓存行( 即使你可能需要检测精确的处理器并采取相应措施) 。

  • 伪共享

还有另一个相关的项目叫做"伪共享"。 这出现在多处理器或者多核系统中,其中两个( 或者更多) 处理器/核心具有独立的数据,但在同一个缓存行。 这迫使两个处理器/核心协调他们对数据的访问,即使每个处理器都有自己的独立的数据项。 特别是如果这两个修改交替中的数据,这可能会导致一个巨大的减速为与处理器之间的数据就必须进行不断 shuttled 。 这不能通过将缓存组织成更多"路径"或者任何类似的方式来修复。 在同一高速缓存行最主要方式,以防止它是确保两个线程很少( 最好从不) 修改数据那可能可能 be.


  1. 知道 C++的人可能会想,这是否可以通过类似表达式模板的东西来优化。 我非常确信答案是是的,如果是的话,它可能会是一个相当大的胜利。 对任何人都这样训练之后,但是,我不清楚是否与给定但小 valarray的使用,我将在至少有点惊奇地看到任何人都这样做,放弃我

  2. 如果有人想知道 valarray ( 专为性能设计) 怎么可能是这样的严重错误,那么它归结为: 它是专门为老的Crays设计的,它使用了快速的主内存,没有缓存。 对于他们来说,这简直是一个理想的设计。

  3. 于每个access,相关。是,我是简化,很多缓存都不会真的进行最近最少使用的项的精密测量,可是会使用了一些启发,旨在成为接近于它,而不必保留一个完整

欢迎使用面向数据设计的世界。 基本的口号是排序,消除分支,批处理,消除 virtual 调用- 所有步骤都朝着更好的位置。

因为你用 C++ 标记了问题,这里是强制性的典型的C++ 垃圾邮件。 面向对象的程序设计的托尼的Albrecht 陷阱也是一本介绍需要编写任何代码。

简单地说:cache-unfriendly与cache-friendly代码的经典示例是矩阵相乘的"缓存阻塞"。

天真矩阵相乘看起来像


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 -fold 。

Fancier的转换操作在 2块瓦片上操作,优化多个缓存( 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

当今的处理器使用了许多级别的级联内存区域。 所以CPU会有一个在CPU芯片本身上的内存。 它可以快速访问这个内存。 缓存的级别不同,每次访问( 以及更大的)的速度比下一步慢,直到系统内存不在CPU上,并且访问速度相对较慢。

从逻辑上讲,对于cpu集的指令,你只需要在一个巨大的虚拟地址空间中引用内存地址。 当你访问一个内存地址时,CPU将获取它。 在过去,它只获取单一地址。 但是今天,CPU会在你要求的位周围获取一系列内存,并将它的复制到缓存中。 它假设如果你要求一个特别的地址,很可能你会在附近要求一个地址。 例如如果你正在复制一个缓冲区,你将从连续的地址中读取并写入一个。

所以今天,当你获取一个地址时,它会检查一级缓存,看看它是否已经读取了那个地址,如果它没有找到的话,那么它就会被缓存到缓存中,直到它最终进入主存。

缓存友好代码试图在内存中保持接近的访问,这样你就可以最小化缓存丢失。

所以一个例子就是你想复制一个巨大的2维表格。 它被组织在内存中的连续行中,在后面的一行后面紧跟着。

如果你一次一行一行地复制元素,这将是缓存友好的。 如果你决定一次只复制一个列,你将复制完全相同的内存量- 但这将是不友好的。

需要澄清的是,不仅数据应该是 cache-friendly,而且对代码同样重要。 除了分支 predicition,指令重新排序,避免实际的除法和其他技术。

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

代码不应该在整个位置调用函数,因为它们通常需要一个或者多个自己的缓存行,从而减少数据的缓存行。

函数应该从缓存line-alignment-friendly地址开始。 虽然还有( gcc ) 编译器开关对于这里一定要知道如果它的功能也非常短的这里设置可能会浪费的每一个都可以占据整个缓存线。 在两个缓存行不太适用于其他usage,例如如果三个最常用的功能可以塞进一个 64字节的缓存线,减少了这种浪费比如果每个都有自己的线和 results. 一个典型的对齐方式

所以花一些时间让代码密集。 测试不同的构造,编译并检查生成的代码大小和概要。

...