如何编写一个最能充分利用CPU缓存来提高性能的代码?


159

这听起来像是一个主观的问题,但是我正在寻找的是与之相关的特定实例。

  1. 如何使代码有效,缓存有效/缓存友好(缓存命中率越高,缓存未命中越少)?从两种角度来看,数据高速缓存和程序高速缓存(指令高速缓存),即代码中与数据结构和代码结构有关的东西,都应该使高速缓存有效。

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

  3. 是否有任何程序构造(如果,用于,切换,中断,转到,...),代码流(用于if,如果位于for等内部……)在此问题上应该遵循/避免使用?

我期待听到与一般而言使高速缓存高效代码有关的个人经验。它可以是任何编程语言(C,C ++,Assembly等),任何硬件目标(ARM,Intel,PowerPC等),任何OS(Windows,Linux,Symbian等)。 。

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


1
作为介绍,本演讲很好地概述了youtu.be/BP6NxVxDQIs
schoetbi

上面缩短的URL似乎不再起作用,这是演讲的完整URL:youtube.com/watch?
v=BP6NxVxDQIs

Answers:


119

那里的高速缓存可以减少CPU等待内存请求被满足之前暂停的次数(避免内存等待时间),并且第二个效果是,可以减少需要传输的数据总量(保留记忆体频宽)。

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

改善空间局部性意味着您确保每条高速缓存行在映射到高速缓存后都被充分使用。当我们查看各种标准基准时,我们发现,在驱逐缓存行之前,其中很大一部分都无法使用100%的提取缓存行。

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

  • 它倾向于将更多有用的数据放入缓存中,从而实质上增加了有效缓存的大小。
  • 它倾向于将更多有用的数据放入同一高速缓存行中,从而增加了可以在高速缓存中找到请求的数据的可能性。
  • 它减少了对内存带宽的需求,因为提取的次数将减少。

常用技术有:

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

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

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

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

为了降低总体内存总线压力,您必须开始解决所谓的时间局部性。这意味着您必须重用尚未从缓存中清除的数据。

合并该触摸相同的数据(循环回路融合),以及采用称为重写技术平铺阻断所有努力避免这些额外的存储器取操作。

尽管此重写练习有一些经验法则,但通常您必须仔细考虑循环承载的数据依赖关系,以确保不影响程序的语义。

这些都是在多核世界中真正获得回报的东西,在多核世界中,添加第二个线程后通常不会看到很多吞吐量提高。


5
当我们查看各种标准基准时,我们发现,在驱逐缓存行之前,其中很大一部分都无法使用100%的提取缓存行。请问什么样的性能分析工具可以为您提供这类信息,以及如何?
龙能量

“组织数据以避免对齐孔(通过减小大小对结构成员进行排序是一种方法)”-为什么编译器本身不会对其进行优化?为什么编译器不能总是“通过减小大小对成员排序”?保持成员不排序的优势是什么?
javapowered

我不知道起源,但首先,成员顺序对于网络通信至关重要,在网络通信中,您可能希望通过Web逐字节发送整个结构。
科布拉

1
@javapowered取决于语言,编译器可能能够做到这一点,尽管我不确定它们是否能做到。在C语言中无法执行此操作的原因是,通过基地址+偏移量而不是名称来寻址成员是完全有效的,这意味着对成员重新排序将完全破坏程序。
丹·贝查德

56

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

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]

它将运行得更快。


9
回到大学时,我们做了一个矩阵乘法的作业。事实证明,出于这个确切的原因,首先对“列”矩阵进行转置,然后将行乘行而不是行乘cols更快。
ykaganovich,2009年

11
实际上,大多数现代编译器都可以通过其自身(启用优化功能)来解决这一问题
Ricardo Nolde 2010年

1
@ykaganovich这也是Ulrich Dreppers文章中的示例:lwn.net/Articles/255364
Simon Stender Boisen

我不确定这是否总是正确的-如果整个数组都适合L1缓存(通常为32k!),则两个订单的缓存命中和未命中次数相同。我猜也许预取内存可能会产生一些影响。很高兴得到纠正。
马特·帕金斯

如果顺序无关紧要,谁会选择此代码的第一个版本?
silver_rocket '18

45

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

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

高速缓存尝试通过记住最近使用的数据块来适应这种情况。它与高速缓存行一起使用,通常大小为128字节左右,因此,即使您只需要一个字节,包含它的整个高速缓存行也会被拉入高速缓存中。因此,如果以后需要以下字节,则该字节已在缓存中。

这意味着您将始终希望自己的代码尽可能多地利用这两种形式的局部性。不要跳过所有内存。在一个较小的区域上尽您最大的努力,然后移到下一个区域,并在那里尽可能多地工作。

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

这同样适用于代码。跳转或跳转意味着缓存使用效率较低(因为您没有按顺序读取指令,而是跳转到另一个地址)。当然,小的if语句可能不会改变任何内容(您只跳过了几个字节,因此仍然会停留在缓存区域内),但是函数调用通常意味着您将跳到完全不同的位置可能未缓存的地址。除非最近被调用。

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

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

而且,如果您想利用多个内核,它会变得非常有趣,通常,一次只有一个CPU的L1高速缓存中可以有任何给定的地址。因此,如果两个内核不断访问同一地址,则在争夺该地址时,将导致不断发生的高速缓存未命中。


4
+1,好的实用建议。一个补充:时间局部性和空间局部性相结合,例如,对于矩阵运算,建议将它们分成较小的矩阵,使其完全适合高速缓存行,或者将其行/列适合高速缓存行。我记得这样做是为了可视化多维效果。数据。它在裤子上打了一些脚。最好记住,缓存确实包含多个“行”;)
AndreasT,2009年

1
您说一次一次L1高速缓存中只能有一个CPU具有给定的地址-我假设您的意思是高速缓存行而不是地址。我也听说过,至少有一个CPU进行写操作时会出现错误的共享问题,但如果两个CPU都仅读操作时就不会出现。那么,“访问”实际上是指写操作吗?
Joseph Garvin

2
@JosephGarvin:是的,我的意思是写东西。没错,多个内核可以同时在其L1缓存中具有相同的缓存行,但是当一个内核写入这些地址时,它会在所有其他L1缓存中失效,然后它们必须重新加载它才能执行此操作任何东西。抱歉,措词不正确。:)
jalf

44

如果您对内存和软件的交互方式感兴趣,我建议阅读由9部分组成的文章,Ulrich Drepper会让每个程序员了解内存。也可以以104页的PDF格式获得

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


16
您应该添加本文要点的摘要。
Azmisov 2014年

读得很好,但这里还必须提到的另一本书是轩尼诗,帕特森,《计算机体系结构,一种定量方法》,该书现已在第五版发行。
Haymo Kutschbach 2016年

15

除了数据访问模式之外,高速缓存友好代码的主要因素是数据大小。较少的数据意味着更多的数据可以放入缓存。

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

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


9

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

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

  1. 它使CPU有机会进行预读,例如推测性地将更多内存放入缓存中,稍后将对其进行访问。这种预读功能极大地提高了性能。

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


@Grover:关于您的观点2。所以可以说,如果在一个紧密循环中,每个循环计数都调用一个函数,那么它将完全获取新代码并导致缓存未命中,如果您可以将该函数放在for循环中的代码本身,无需函数调用,由于较少的缓存未命中会更快吗?
goldenmean

1
是的,没有。新功能将被加载到缓存中。如果有足够的缓存空间,则在第二次迭代时,它已经在缓存中具有该功能,因此没有理由再次重新加载它。因此,这是第一个电话的热门。在C / C ++中,您可以要求编译器使用适当的段将函数彼此紧邻放置。
grover

还有一点要注意:如果您循环调用而没有足够的缓存空间,则无论如何,新函数都将被加载到缓存中。甚至可能发生原始循环将被抛出高速缓存的情况。在这种情况下,该调用每次迭代最多会受到3种惩罚:一种加载调用目标,另一种重新加载循环。第三,如果循环头与调用返回地址不在同一高速缓存行中。在这种情况下,跳转到循环头也需要新的内存访问。
grover

8

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

因此,在物理循环过程中,使用矢量数学以阵列顺序处理了物理数据。游戏对象使用其对象ID作为各种数组的索引。它不是指针,因为如果必须重定位数组,则指针可能会失效。

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

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


2
+1一点也不过时。这是为游戏引擎组织数据的最佳方法-使数据块连续,并执行所有给定类型的操作(例如AI),然后再进行下一个操作(例如物理操作),以利用缓存的接近度/局部性参考。
工程师

我在几周前的某个视频中看到了这个确切的示例,但是此后失去了链接,无法记住如何找到它。记住这里的例子吗?

@will:不,我不记得确切在哪里。
Zan Lynx 2014年

这就是实体组件系统的想法(ECS:en.wikipedia.org/wiki/Entity_component_system)。将数据存储为数组结构,而不是OOP实践鼓励的更传统的结构数组。
BuschnicK

7

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


7

用户1800信息对“经典示例” 的评论(太长,无法发表评论)

我想检查两个迭代顺序(“外部”和“内部”)的时差,因此我对大型2D数组进行了简单的实验:

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首先”)为0.88秒,而较快的版本为0.06秒。那就是缓存的力量:)

我用过gcc -O2,但循环仍未优化。里卡多(Ricardo)的评论“大多数现代编译器都可以自己解决”


不知道我明白了。在两个示例中,您仍在访问for循环中的每个变量。为什么一种方法比另一种更快?
ed-

最终直观地让我理解了它是如何影响的:)
Laie

@EdwardCorlew这是因为它们的访问顺序。y优先顺序更快,因为它顺序地访问数据。当请求第一个条目时,L1高速缓存将加载整个高速缓存行,其中包括请求的int加上下一个15(假定为64字节的高速缓存行),因此没有CPU停顿等待下一个15。 -一阶比较慢,因为被访问的元素不是顺序的,并且大概N足够大,以至于被访问的内存总是在L1高速缓存之外,因此每个操作都停滞了。
马特·帕金斯

4

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


@安德鲁:关于结构。它们缓存有效吗?它们是否具有任何大小限制以提高缓存效率?
goldenmean

结构是一个内存块,因此只要不超过缓存的大小,您就不会看到影响。只有当您拥有一个结构(或类)集合时,您才会看到缓存命中,并且取决于组织该集合的方式。数组将对象彼此对接(良好),但是一个链表可以在您的地址空间中拥有对象,并且它们之间具有链接,这显然不利于缓存性能。
安德鲁

使用链接列表而不杀死高速缓存的一种方法(对于不大的列表最有效)是创建自己的内存池,即-分配一个大数组。然后,您可以为每个小链接列表成员“分配”(或在C ++中“新建”)内存,而不必在内存中完全不同的位置分配内存,这会浪费管理空间,而是从内存池中为其分配内存,逻辑上接近列表成员的几率将大大增加,这将一起出现在缓存中。
Liran Orevi 09年

当然,但是要获取std :: list <>等还有很多工作。使用您的自定义内存块。当我还是一个年轻的小鲷鱼时,我绝对会走这条路,但是这些日子……还有太多其他事情需要解决。
安德鲁


4

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

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

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

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


不必要。小心虚假共享。有时您必须将数据拆分为不同的缓存行。缓存的有效性始终取决于您如何使用它。
DAG

4

要问如何编写代码,如何缓存有效的缓存,以及其他大多数问题,通常是问如何优化程序,这是因为缓存对性能的影响很大,以至于任何优化程序都是缓存。有效缓存友好。

我建议阅读有关优化的文章,此网站上有一些不错的答案。在书籍方面,我推荐《计算机系统:程序员的观点》,其中详细介绍了缓存的正确用法。

(顺便说一句-就像缓存丢失一样严重,更糟糕的是-如果程序正在从硬盘驱动器分页 ...)


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将输入[1]预取到高速缓存中,其中预取指令本身需要P个周期并返回,而在后台,输入[1]将在R个周期后到达高速缓存中。
  2. works_on(0) 在0上冷落并对其进行处理,这需要M
  3. prefetch(2) 发出另一个读取
  4. works_on(1) 如果P + R <= M,则在此步骤之前,input [1]应该已经在高速缓存中,因此避免了数据高速缓存未命中
  5. works_on(2) ...

可能涉及更多的步骤,然后您可以设计一个多阶段管道,只要这些步骤的时间和内存访问延迟匹配,就不会有太多的代码/数据缓存丢失。但是,此过程需要通过许多实验进行调整,以找出正确的步骤分组和预取时间。由于需要付出的努力,它在高性能数据/数据包流处理中得到了更多的采用。在DPDK QoS Enqueue管道设计中可以找到一个很好的生产代码示例:http : //dpdk.org/doc/guides/prog_guide/qos_framework.html第21.2.4.3。章。排队管道。

可以找到更多信息:

https://software.intel.com/zh-CN/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


1

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

一次处理少量数据。这就是为什么如果数据集很大,效率较低的排序算法可以比快速排序运行得更快的原因。寻找将较大的数据集分解为较小的数据集的方法。其他人建议这样做。

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

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

即使两个循环仅通过数组进行解析,它们也会产生不同的代码。无论如何,您的问题都是特定于体系结构的。因此,严格控制高速缓存使用的唯一方法是了解硬件的工作方式并为此优化代码。


有趣的一点。超前缓存是否基于循环/通过内存的方向做出假设?
安德鲁

1
设计推测性数据缓存的方法有很多。基于跨步的方法确实可以测量数据访问的“距离”和“方向”。基于内容的追逐指针链。还有其他设计方法。
sybreon

1

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.