171

这听起来像是一个主观问题,但我正在寻找的是特定的实例,您可能遇到过与此相关的情况。

  1. 如何使代码,缓存有效/缓存友好(更多的缓存命中,尽可能少的缓存未命中)?从这两个角度来看,数据缓存和程序缓存(指令缓存),即一个人的代码中与数据结构和代码结构相关的东西,应该由一个人来处理以使其缓存有效。

  2. 是否有任何特定的数据结构必须使用/避免,或者是否有一种特定的方式来访问该结构的成员等......以使代码缓存有效。

  3. 是否有任何程序构造(if、for、switch、break、goto、...)、代码流(for 在 if、if 在 for 中等...)在这件事上应该遵循/避免?

我期待听到与制作缓存高效代码相关的个人经验。它可以是任何编程语言(C、C++、Assembly...)、任何硬件目标(ARM、Intel、PowerPC...)、任何操作系统(Windows、Linux、S ymbian...)等。 .

多样性将有助于更好地深入理解它。

4

15 回答 15

127

缓存的作用是减少 CPU 等待内存请求完成时停止的次数(避免内存延迟),并且作为第二个效果,可能会减少需要传输的数据总量(保留内存带宽)。

避免遭受内存获取延迟的技术通常是首先要考虑的事情,而且有时会大有帮助。有限的内存带宽也是一个限制因素,特别是对于许多线程想要使用内存总线的多核和多线程应用程序。一组不同的技术有助于解决后一个问题。

提高空间局部性意味着您可以确保每个缓存行在映射到缓存后被完全使用。当我们查看各种标准基准时,我们发现其中很大一部分未能在缓存行被驱逐之前使用 100% 的已获取缓存行。

提高缓存行利用率在三个方面有帮助:

  • 它倾向于在缓存中容纳更多有用的数据,从本质上增加有效缓存大小。
  • 它倾向于在同一高速缓存行中容纳更多有用的数据,从而增加在高速缓存中可以找到所请求数据的可能性。
  • 它降低了内存带宽要求,因为会减少取数。

常用技术有:

  • 使用较小的数据类型
  • 组织数据以避免对齐漏洞(通过减小大小对结构成员进行排序是一种方法)
  • 当心标准的动态内存分配器,它可能会在内存预热时引入漏洞并在内存中传播数据。
  • 确保在热循环中实际使用了所有相邻数据。否则,考虑将数据结构分解为热组件和冷组件,以便热循环使用热数据。
  • 避免表现出不规则访问模式的算法和数据结构,并支持线性数据结构。

我们还应该注意,除了使用缓存之外,还有其他方法可以隐藏内存延迟。

现代 CPU:s 通常有一个或多个硬件预取器。他们训练缓存中的未命中并尝试发现规律。例如,在对后续缓存行进行几次未命中后,硬件预取器将开始将缓存行提取到缓存中,以预测应用程序的需求。如果你有一个常规的访问模式,硬件预取器通常会做得很好。如果您的程序不显示常规访问模式,您可以通过自己添加预取指令来改进。

以这样一种方式重新组合指令,使那些总是在缓存中丢失的指令彼此靠近,CPU 有时可以重叠这些提取,以便应用程序只承受一个延迟命中(内存级并行性)。

为了减少整体内存总线压力,您必须开始解决所谓的时间局部性问题。这意味着您必须在数据尚未从缓存中逐出时重用数据。

合并涉及相同数据的循环(循环融合),并采用称为平铺阻塞的重写技术,都努力避免那些额外的内存提取。

虽然这个重写练习有一些经验法则,但您通常必须仔细考虑循环携带的数据依赖性,以确保您不会影响程序的语义。

这些东西在多核世界中真正得到了回报,在添加第二个线程后,您通常不会看到太多的吞吐量改进。

于 2009-06-19T16:20:47.187 回答
59

我不敢相信没有更多的答案。无论如何,一个经典的例子是“从里到外”迭代一个多维数组:

pseudocode
for (i = 0 to size)
  for (j = 0 to size)
    do something with ary[j][i]

缓存效率低下的原因是,当您访问单个内存地址时,现代 CPU 会从主内存加载具有“近”内存地址的缓存行。我们在内部循环中遍历数组中的“j”(外部)行,因此对于内部循环的每次行程,缓存行将导致刷新并加载靠近 [ j][i] 条目。如果将其更改为等效项:

for (i = 0 to size)
  for (j = 0 to size)
    do something with ary[i][j]

它会运行得更快。

于 2009-04-18T10:51:26.923 回答
48

基本规则实际上相当简单。棘手的地方在于它们如何应用于您的代码。

缓存的工作原理有两个:时间局部性和空间局部性。前者的想法是,如果您最近使用了某个数据块,您可能很快就会再次需要它。后者意味着如果您最近使用了地址 X 的数据,您可能很快就会需要地址 X+1。

缓存试图通过记住最近使用的数据块来适应这一点。它使用缓存线运行,通常大小为 128 字节左右,因此即使您只需要一个字节,包含它的整个缓存线也会被拉入缓存。因此,如果您之后需要以下字节,它已经在缓存中。

这意味着您总是希望自己的代码尽可能地利用这两种形式的局部性。不要跳过所有的内存。在一个小区域做尽可能多的工作,然后转移到下一个区域,在那里做尽可能多的工作。

一个简单的例子是 1800 的答案显示的 2D 数组遍历。如果你一次遍历它一行,你就是按顺序读取内存。如果按列执行,您将读取一个条目,然后跳转到完全不同的位置(下一行的开头),读取一个条目,然后再次跳转。当你最终回到第一行时,它将不再在缓存中。

这同样适用于代码。跳转或分支意味着缓存使用效率较低(因为您不是按顺序读取指令,而是跳转到不同的地址)。当然,小的 if 语句可能不会改变任何东西(你只是跳过了几个字节,所以你仍然会在缓存区域内结束),但函数调用通常意味着你正在跳转到一个完全不同的可能不会被缓存的地址。除非它最近被调用。

不过,指令缓存的使用通常不是问题。您通常需要担心的是数据缓存。

在结构或类中,所有成员都是连续布局的,这很好。在一个数组中,所有条目也是连续布局的。在链表中,每个节点都分配在完全不同的位置,这很糟糕。指针通常倾向于指向不相关的地址,如果取消引用它可能会导致缓存未命中。

如果你想利用多个内核,它会变得非常有趣,因为通常情况下,一次只有一个 CPU 在其 L1 缓存中可能有任何给定的地址。因此,如果两个内核不断地访问同一个地址,则会导致不断的缓存未命中,因为它们正在争夺地址。

于 2009-04-18T13:22:56.840 回答
45

如果您对内存和软件的交互方式感兴趣,我建议您阅读 Ulrich Drepper 撰写的由 9 部分组成的文章每个程序员都应该了解的内存。它还以 104 页的 PDF 格式提供。

与这个问题特别相关的部分可能是第 2 部分(CPU 缓存)和第 5 部分(程序员可以做什么 - 缓存优化)。

于 2009-04-18T12:56:17.263 回答
15

除了数据访问模式,缓存友好代码的一个主要因素是数据大小。更少的数据意味着更多的数据适合缓存。

这主要是内存对齐数据结构的一个因素。“传统”智慧说数据结构必须在字边界对齐,因为 CPU 只能访问整个字,如果一个字包含多个值,则必须做额外的工作(读取-修改-写入而不是简单的写入) . 但是缓存可以完全使这个论点无效。

类似地,Java 布尔数组为每个值使用一个完整字节,以便允许直接对单个值进行操作。如果使用实际位,您可以将数据大小减少 8 倍,但是访问单个值会变得更加复杂,需要位移和掩码操作(BitSet该类会为您执行此操作)。但是,由于缓存效应,当数组很大时,这仍然比使用 boolean[] 快得多。IIRC I 曾经以这种方式实现了 2 或 3 倍的加速。

于 2009-04-18T11:13:55.760 回答
9

缓存最有效的数据结构是数组。如果您的数据结构按顺序排列,因为 CPU 从主内存一次读取整个缓存行(通常为 32 字节或更多),则缓存效果最好。

任何以随机顺序访问内存的算法都会破坏缓存,因为它总是需要新的缓存行来容纳随机访问的内存。另一方面,通过数组顺序运行的算法是最好的,因为:

  1. 它使 CPU 有机会预读,例如推测性地将更多内存放入缓存中,稍后将访问这些内存。这种预读提供了巨大的性能提升。

  2. 在大型数组上运行紧密循环还允许 CPU 缓存循环中执行的代码,并且在大多数情况下,您可以完全从缓存内存中执行算法,而不必阻塞外部内存访问。

于 2009-04-18T10:54:41.643 回答
9

我在游戏引擎中看到的一个示例是将数据从对象中移出并移入它们自己的数组中。受物理影响的游戏对象可能还附加了许多其他数据。但是在物理更新循环期间,所有引擎关心的都是关于位置、速度、质量、边界框等的数据。所以所有这些都被放入它自己的数组中,并尽可能针对 SSE 进行优化。

因此,在物理循环期间,物理数据使用矢量数学以数组顺序进行处理。游戏对象使用它们的对象 ID 作为各种数组的索引。它不是指针,因为如果必须重新定位数组,指针可能会失效。

在许多方面,这违反了面向对象的设计模式,但通过将需要在同一个循环中操作的数据放在一起,使代码变得更快。

这个例子可能已经过时了,因为我预计大多数现代游戏都使用像 Havok 这样的预构建物理引擎。

于 2013-05-15T22:58:54.690 回答
7

只有一篇文章涉及到它,但是在进程之间共享数据时出现了一个大问题。您希望避免多个进程同时尝试修改同一个高速缓存行。这里需要注意的是“错误”共享,其中两个相邻的数据结构共享一个高速缓存行,并且对其中一个的修改会使另一个的高速缓存行无效。这可能会导致高速缓存行在共享多处理器系统上的数据的处理器高速缓存之间不必要地来回移动。避免它的一种方法是对齐和填充数据结构以将它们放在不同的行上。

于 2009-06-01T17:24:30.037 回答
7

用户1800 INFORMATION对“经典示例”的评论(评论太长)

我想检查两个迭代顺序(“外部”和“内部”)的时间差异,所以我用一个大型二维数组做了一个简单的实验:

measure::start();
for ( int y = 0; y < N; ++y )
for ( int x = 0; x < N; ++x )
    sum += A[ x + y*N ];
measure::stop();

for交换循环的第二种情况。

较慢的版本(“x first”)为 0.88 秒,较快的版本为 0.06 秒。这就是缓存的力量:)

我使用过gcc -O2,但循环仍未优化。Ricardo的评论“大多数现代编译器可以自己解决这个问题”并不成立

于 2012-03-19T11:07:13.720 回答
5

我可以回答 (2),说在 C++ 世界中,链表很容易杀死 CPU 缓存。在可能的情况下,数组是更好的解决方案。没有关于这是否适用于其他语言的经验,但很容易想象会出现同样的问题。

于 2009-04-18T10:37:13.183 回答
4

高速缓存排列在“高速缓存行”中,并且(实际)内存以这种大小的块读取和写入。

因此,包含在单个高速缓存行中的数据结构更有效。

类似地,访问连续内存块的算法将比以随机顺序跳过内存的算法更有效。

不幸的是,高速缓存行大小在处理器之间变化很大,因此无法保证在一个处理器上最佳的数据结构在任何其他处理器上都是有效的。

于 2009-04-18T10:50:13.323 回答
4

要问如何制作代码,缓存有效缓存友好和其他大多数问题,通常是问如何优化程序,因为缓存对性能的影响非常大,任何优化的程序都是缓存的有效缓存友好。

我建议阅读优化,这个网站上有一些很好的答案。在书籍方面,我推荐计算机系统:程序员的观点,其中有一些关于正确使用缓存的好文本。

(顺便说一句 - 与缓存未命中一样糟糕,更糟糕的是 - 如果程序正在从硬盘分页......)

于 2009-04-18T19:55:42.123 回答
4

关于数据结构选择、访问模式等一般建议已经有很多答案了。在这里,我想添加另一个代码设计模式,称为软件管道,它利用了主动缓存管理。

这个想法借鉴了其他流水线技术,例如 CPU 指令流水线。

这种类型的模式最适用于那些

  1. 可以分解为合理的多个子步骤,S[1], S[2], S[3], ...其执行时间与 RAM 访问时间(~60-70ns)大致相当。
  2. 接受一批输入并对它们执行上述多个步骤以获得结果。

让我们看一个只有一个子过程的简单案例。通常代码会这样:

def proc(input):
    return sub-step(input))

为了获得更好的性能,您可能希望将多个输入批量传递给函数,以便摊销函数调用开销并增加代码缓存局部性。

def batch_proc(inputs):
    results = []
    for i in inputs:
        // avoids code cache miss, but still suffer data(inputs) miss
        results.append(sub-step(i))
    return res

但是,如前所述,如果该步骤的执行与 RAM 访问时间大致相同,您可以进一步改进代码,如下所示:

def batch_pipelined_proc(inputs):
    for i in range(0, len(inputs)-1):
        prefetch(inputs[i+1])
        # work on current item while [i+1] is flying back from RAM
        results.append(sub-step(inputs[i-1]))
        
    results.append(sub-step(inputs[-1]))

执行流程如下所示:

  1. prefetch(1) 要求 CPU 将 input[1] 预取到缓存中,其中 prefetch 指令本身需要 P 个周期并返回,并且在后台 input[1] 将在 R 个周期后到达缓存。
  2. works_on(0) 对 0 的冷错过并对其进行处理,这需要 M
  3. prefetch(2) 发出另一个 fetch
  4. works_on(1) 如果 P + R <= M,则输入[1] 应该在此步骤之前已经在缓存中,从而避免数据缓存未命中
  5. 作品_on(2) ...

可能涉及更多步骤,然后您可以设计一个多级管道,只要步骤的时间和内存访问延迟匹配,您将遭受很少的代码/数据缓存未命中。然而,这个过程需要通过许多实验来调整,以找出正确的步骤分组和预取时间。由于其所需的努力,它在高性能数据/数据包流处理中得到了更多采用。在 DPDK QoS Enqueue 管道设计中可以找到一个很好的生产代码示例:http: //dpdk.org/doc/guides/prog_guide/qos_framework.html第 21.2.4.3 章。排队管道。

可以找到更多信息:

https://software.intel.com/en-us/articles/memory-management-for-optimal-performance-on-intel-xeon-phi-coprocessor-alignment-and

http://infolab.stanford.edu/~ullman/dragon/w06/lectures/cs243-lec13-wei.pdf

于 2016-04-19T01:05:23.167 回答
2

除了对齐你的结构和字段,如果你的结构如果堆分配,你可能想要使用支持对齐分配的分配器;否则你_aligned_malloc(sizeof(DATA), SYSTEM_CACHE_LINE_SIZE);可能会有随机的虚假分享;请记住,在 Windows 中,默认堆有 16 字节对齐。

于 2010-12-06T04:09:57.943 回答
1

编写程序以占用最小的大小。这就是为什么对 GCC 使用 -O3 优化并不总是一个好主意。它占据了更大的尺寸。通常,-Os 与-O2 一样好。这一切都取决于所使用的处理器。YMMV。

一次处理少量数据。这就是为什么如果数据集很大,效率较低的排序算法可以比快速排序运行得更快。想办法将较大的数据集分解为较小的数据集。其他人提出了这个建议。

为了帮助您更好地利用指令的时间/空间局部性,您可能需要研究如何将代码转换为汇编。例如:

for(i = 0; i < MAX; ++i)
for(i = MAX; i > 0; --i)

这两个循环产生不同的代码,即使它们只是解析一个数组。无论如何,您的问题是非常特定于架构的。因此,严格控制缓存使用的唯一方法是了解硬件的工作原理并针对它优化代码。

于 2009-04-18T11:35:57.410 回答