【问题标题】:What is a "cache-friendly" code?什么是“缓存友好”代码?
【发布时间】:2013-05-17 22:42:48
【问题描述】:

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

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

【问题讨论】:

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


【解决方案1】:

预赛

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

(比喻缓存内存对应系统内存,系统内存对应硬盘存储。硬盘存储超级便宜但速度很慢)。

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

始终通过内存层次结构检索数据(最小 == 最快到最慢)。 缓存命中/未命中 通常是指 CPU 中最高级别缓存中的命中/未命中——最高级别是指最大的 == 最慢的。缓存命中率对性能至关重要,因为每次缓存未命中都会导致从 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 缓存,缓存行的概念很重要。

使用适当的 容器

缓存友好与缓存不友好的简单示例是std::vectorstd::liststd::vector 的元素存储在连续的内存中,因此访问它们比访问 std::list 中的元素更易于缓存std::list 将其内容存储在所有地方。这是由于空间局部性。

Bjarne Stroustrup 在this youtube clip 中给出了一个很好的说明(感谢@Mohammad Ali Baydoun 的链接!)。

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

只要有可能,尽量调整您的数据结构和计算顺序,以最大限度地利用缓存。这方面的常用技术是cache blocking(Archive.org version),这在高性能计算中极为重要(参见例如ATLAS)。

了解和利用数据的隐式结构

另一个简单的例子,该领域的许多人有时会忘记,列优先(例如,)与行优先排序(例如,)用于存储两个维数组。例如,考虑以下矩阵:

1 2
3 4

在行优先排序中,它以1 2 3 4 的形式存储在内存中;在列优先排序中,这将存储为1 3 2 4。很容易看出,不利用这种排序的实现将很快遇到(很容易避免!)缓存问题。不幸的是,我经常在我的领域(机器学习)中看到类似的东西非常。 @MatteoItalia 在他的回答中更详细地展示了这个例子。

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

为简单起见,假设缓存包含一个可以包含 2 个矩阵元素的缓存行,并且当从内存中获取给定元素时,下一个也是。假设我们想要对上面示例 2x2 矩阵中的所有元素求和(我们称之为 M):

利用排序(例如,首先更改 中的列索引):

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 提供链接):Why is processing a sorted array faster than processing an unsorted array?

避免使用虚函数

的上下文中,virtual 方法代表了一个关于缓存未命中的有争议的问题(普遍的共识是,在性能方面应该尽可能避免它们)。虚函数在查找过程中可能会导致缓存未命中,但这只会发生如果特定函数不经常调用(否则它可能会被缓存),因此这被某些人认为不是问题。有关此问题的参考,请查看:What is the performance cost of having a virtual method in a C++ class?

常见问题

具有多处理器缓存的现代架构中的一个常见问题称为false sharing。当每个单独的处理器尝试使用另一个内存区域中的数据并尝试将其存储在同一 高速缓存行 中时,就会发生这种情况。这会导致缓存线——其中包含另一个处理器可以使用的数据——被一次又一次地覆盖。实际上,在这种情况下,不同的线程通过引发缓存未命中来使彼此等待。 另请参阅(感谢@Matt 提供链接):How and when to align to cache line size?

RAM 内存中缓存不佳的极端症状(这可能不是您在此上下文中的意思)是所谓的thrashing。当进程不断产生需要磁盘访问的页面错误(例如访问不在当前页面中的内存)时,就会发生这种情况。

【讨论】:

  • 也许你可以通过解释,在多线程代码中,数据也可能过于本地化(例如错误共享)来扩展答案
  • 可以有芯片设计者认为有用的缓存级别。通常,他们正在平衡速度与大小。如果你可以让你的 L1 缓存和 L5 一样大,而且速度一样快,那么你只需要 L1。
  • 我意识到 StackOverflow 上不赞成空的协议帖子,但老实说,这是迄今为止我见过的最清晰、最好的答案。出色的工作,马克。
  • @JackAidley 感谢您的称赞!当我看到这个问题受到如此多的关注时,我想很多人可能会对一个有点广泛的解释感兴趣。我很高兴它有用。
  • 您没有提到的是缓存友好的数据结构被设计为适合缓存行并与内存对齐以优化使用缓存行。不过答案很好!太棒了。
【解决方案2】:

继续往下看:缓存不友好代码与缓存友好代码的经典示例是矩阵乘法的“缓存阻塞”。

朴素矩阵乘法如下:

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

循环平铺密切相关:

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

【讨论】:

  • 阅读本文的人可能也对我的article about matrix multiplication 感兴趣,我在其中通过将两个 2000x2000 矩阵相乘来测试“缓存友好”的 ikj 算法和不友好的 ijk 算法。
  • k==; 我希望这是一个错字?
【解决方案3】:

欢迎来到面向数据的设计世界。基本的口头禅是排序、消除分支、批处理、消除virtual 调用——所有步骤都朝着更好的本地化方向发展。

由于您使用 C++ 标记了该问题,因此这里是强制性的typical C++ Bullshit。 Tony Albrecht 的Pitfalls of Object Oriented Programming 也是对该主题的一个很好的介绍。

【讨论】:

  • 批量是什么意思,大家可能不明白。
  • 批处理:不是在单个对象上执行工作单元,而是在一批对象上执行。
  • AKA 阻塞、阻塞寄存器、阻塞缓存。
  • 阻塞/非阻塞通常是指对象在并发环境中的行为方式。
  • 批处理 == vectorization
【解决方案4】:

除了@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 度,然后再进行各种分析,将不友好缓存的代码限制在旋转范围内。

【讨论】:

  • 呃,应该是 x
  • 现代图像编辑器使用磁贴作为内部存储,例如64x64 像素的块。这对于本地操作(放置 dab、运行模糊滤镜)更加缓存友好,因为大多数情况下,相邻像素在内存中的两个方向都很接近。
  • 我尝试在我的机器上计时一个类似的例子,我发现时间是一样的。有其他人尝试过计时吗?
  • 等一下...image[0][1]image[0][2] 相邻。因此,内部循环应该循环通过 second 索引,对吗? for each y in rows: (fetch a whole row, hopefully) for each x in cols: ...image[y][x]...。这使您的 first 代码 sn-p 成为好代码,而不是第二代码。我错过了什么吗?
  • @Gauthier:是的,第一个 sn-p 是好的;我认为,当我写这篇文章时,我的想法是“[破坏工作应用程序的性能]所需要的只是从……到……”
【解决方案5】:

正如@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. */

类似地,我们可以通过创建 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 上存在行存储和列存储。

现在我们转到缓存友好代码部分。假设我们表上的工作负载使得我们对价格属性进行聚合查询。比如

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) 元组的价格值,因为我们的 struct 大小为 64 字节,它填满了我们的整个缓存行,所以对于每个元组在行布局的情况下会发生缓存未命中。

在读取缓存行时的列布局情况下,读取 16(cacheline_size/price_int_size = 64/4 = 16) 个元组的价格值,因为存储在内存中的 16 个连续价格值被带入缓存,因此每第 16 个元组在列布局的情况下会发生缓存未命中。

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

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

【讨论】:

    【解决方案6】:

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

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

    【讨论】:

      【解决方案7】:

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

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

      代码不应到处调用函数,因为它们通常需要一个或多个自己的缓存行,从而导致数据的缓存行更少。

      函数应该从缓存行对齐友好的地址开始。尽管有(gcc)编译器开关用于此,但请注意,如果函数非常短,则每个函数占用整个高速缓存行可能是浪费的。例如,如果三个最常用的函数可以放在一个 64 字节的高速缓存行中,那么这比如果每个函数都有自己的高速缓存行并导致两条高速缓存行可用于其他用途的情况更少浪费。典型的对齐值可能是 32 或 16。

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

      【讨论】:

        【解决方案8】:

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

        参考地点

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

        • 空间

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

        其次,我们希望将一起处理的信息也放在一起。典型的缓存以“行”工作,这意味着当您访问某些信息时,附近地址的其他信息将与我们接触的部分一起加载到缓存中。例如,当我触摸一个字节时,缓存可能会在该字节附近加载 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] 一次,进行计算,写入结果,递增@ 987654333@ 并重复直到我们完成。2

        线路共享

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

        为了防止这种情况,大多数缓存都是所谓的“集合关联”。例如,在 4 路组关联缓存中,主内存中的任何项目都可以存储在缓存中的 4 个不同位置中的任何一个。因此,当缓存要加载一个项目时,它会在这四个项目中查找最近最少使用的3 个项目,将其刷新到主内存,然后在其位置加载新项目。

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

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

        • 虚假分享

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


        1. 熟悉 C++ 的人可能想知道这是否可以通过表达式模板之类的东西进行优化。我很确定答案是肯定的,这是可以做到的,如果是的话,这可能是一个相当大的胜利。但是,我不知道有人这样做过,而且鉴于 valarray 很少被使用,看到有人这样做,我至少会有点惊讶。

        2. 如果有人想知道valarray(专为提高性能而设计)为什么会出现如此严重的错误,可以归结为一件事:它确实是为像老式 Crays 这样的机器设计的,它们使用快速主内存且没有缓存。对他们来说,这确实是一个近乎理想的设计。

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

        【讨论】:

        • 我喜欢您回答中的额外信息,尤其是 valarray 示例。
        • +1 最后:对集合关联性的简单描述!进一步编辑:这是关于 SO 的信息最丰富的答案之一。谢谢。
        【解决方案9】:

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

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

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

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

        例如,假设您想复制一个巨大的二维表。它在内存中以连续到达行组织,并且紧随其后的一行。

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

        【讨论】:

          猜你喜欢
          • 2016-08-31
          • 2014-01-17
          • 2014-10-24
          • 1970-01-01
          • 1970-01-01
          • 2012-10-18
          • 2015-11-25
          • 2013-10-08
          相关资源
          最近更新 更多