设计代码以适应CPU缓存?

问题描述 投票:14回答:7

在编写模拟时,我的伙伴说他喜欢尝试编写足够小的程序以适应缓存。这有什么实际意义吗?据我所知,缓存比RAM和主内存快。是否可以指定您希望程序从缓存运行或至少将变量加载到缓存中?我们正在编写模拟,因此任何性能/优化收益都是巨大的好处。

如果您知道任何解释CPU缓存的好链接,那么请指出我的方向。

c performance caching cpu-architecture cpu-cache
7个回答
30
投票

至少对于典型的桌面CPU,您无法直接指定很多缓存使用情况。您仍然可以尝试编写缓存友好的代码。在代码方面,这通常意味着展开循环(仅一个明显的例子)很少有用 - 它扩展了代码,而现代CPU通常可以最小化循环的开销。您通常可以在数据方面做更多事情,以改善引用的位置,防止错误共享(例如,两个经常使用的数据片段将尝试使用相同的缓存部分,而其他部分仍未使用)。

编辑(使某些点更明确):

典型的CPU具有许多不同的缓存。现代桌面处理器通常具有至少2级且通常3级的高速缓存。通过(至少几乎)通用协议,“级别1”是与处理元件“最接近”的高速缓存,并且数字从那里上升(级别2是下一级,之后是级别3,等等)

在大多数情况下,(至少)1级缓存被分成两半:指令缓存和数据缓存(英特尔486几乎是我所知道的唯一例外,指令和数据都有一个缓存 - 但它已经彻底过时了,可能不值得考虑。

在大多数情况下,缓存被组织为一组“行”。通常一次读取,写入和跟踪一行高速缓存的内容。换句话说,如果CPU将使用来自高速缓存行的任何部分的数据,则从下一个较低级别的存储读取整个高速缓存行。靠近CPU的高速缓存通常较小并且具有较小的高速缓存行。

这种基本体系结构导致了编写代码时缓存的大多数特性。您希望尽可能多地将某些内容读入缓存中,然后使用它执行所有操作,然后继续使用其他内容。

这意味着,当您处理数据时,通常最好读取相对少量的数据(足够小以适应缓存),尽可能多地处理该数据,然后继续下一个块数据。像Quicksort这样的算法可以快速地将大量输入分解为逐渐变小的部分,这或多或少会自动执行此操作,因此它们往往对缓存非常友好,几乎与缓存的精确细节无关。

这也会影响您编写代码的方式。如果你有一个像这样的循环:

for i = 0 to whatever
   step1(data);
   step2(data);
   step3(data);
end for

你通常最好将多个步骤串在一起,因为你可以达到适合缓存的数量。溢出缓存的那一刻,性能会急剧下降。如果上面步骤3的代码足够大以至于它不适合缓存,那么通常最好将循环分成两部分(如果可能):

for i = 0 to whatever
    step1(data);
    step2(data);
end for

for i = 0 to whatever
    step3(data);
end for

循环展开是一个相当激烈的争议主题。一方面,它可以导致代码更加CPU友好,减少了为循环本身执行的指令的开销。同时,它可以(并且通常会)增加代码大小,因此它相对缓存不友好。我自己的经验是,在合成基准测试中,往往会对大量数据进行少量处理,您可以从循环展开中获得很多收益。在更实际的代码中,您倾向于对单个数据进行更多处理,您可以获得更少的收益 - 并且溢出缓存导致严重的性能损失并不是特别罕见。

数据缓存的大小也有限。这意味着您通常希望尽可能密集地打包数据,以便尽可能多的数据适合缓存。仅举一个明显的例子,与指针链接在一起的数据结构需要在计算复杂性方面获得相当多的收益,以弥补这些指针使用的数据缓存空间量。如果您要使用链接数据结构,通常希望至少确保将相对较大的数据链接在一起。

然而,在很多情况下,我发现我最初学习的技巧是将数据装入微小处理器中的微小内存,这些处理器已经(大部分)已经过时了几十年,在现代处理器上运行良好。现在的意图是在缓存而不是主存储器中容纳更多数据,但效果几乎相同。在很多情况下,您可以认为CPU指令几乎是免费的,并且整体执行速度由缓存(或主存储器)的带宽决定,因此从密集格式解压缩数据的额外处理工作在你的好意当您处理足够的数据以至于它们都不再适合缓存时,尤其如此,因此整体速度由主存储器的带宽决定。在这种情况下,您可以执行大量指令以节省一些内存读取,并且仍然可以提前完成。

并行处理会加剧这个问题。在许多情况下,重写代码以允许并行处理可能导致性能几乎没有增加,有时甚至导致性能损失。如果整体速度受从CPU到内存的带宽控制,那么有更多内核竞争该带宽不太可能有任何好处(并且可能造成实质性损害)。在这种情况下,使用多个内核来提高速度往往归结为更加紧密地打包数据,并利用更多的处理能力来解压缩数据,因此真正的速度增益来自于减少消耗的带宽并且额外的内核不会浪费时间从更密集的格式解压缩数据。

并行编码中可能出现的另一个基于缓存的问题是变量的共享(和错误共享)。如果两个(或更多)内核需要写入内存中的相同位置,则保存该数据的高速缓存行最终可以在内核之间来回切换,以便为每个内核提供对共享数据的访问。结果通常是并行运行的代码比串行运行的代码慢(即,在单个内核上)。这有一种称为“错误共享”的变体,其中不同内核上的代码正在写入单独的数据,但不同内核的数据最终位于同一缓存行中。由于缓存完全根据整行数据控制数据,因此无论如何数据都会在核心之间来回移动,从而导致完全相同的问题。


11
投票

这是一个链接到一个非常好的paper关于缓存/内存优化由Christer Ericsson(战争之神I / II / III成名)。它已经有几年了,但它仍然非常相关。


7
投票

Ulrich Drepper的What Every Programmer Should Know About Memory是一篇有用的论文,它将告诉你比你想知道更多关于缓存的信息。 Hennessey非常透彻。 Christer和Mike Acton也写了很多关于这个的好东西。

我认为你应该更多地担心数据缓存而不是指令缓存 - 根据我的经验,dcache misses更频繁,更痛苦,更有用的修复。


5
投票

更新:2014年1月13日根据这位资深芯片设计师的说法,缓存未命中现在是代码性能的绝对主导因素,所以我们基本上可以追溯到80年代中期和286芯片的相对性能加载,存储,整数运算和缓存未命中的瓶颈。

A Crash Course In Modern Hardware by Cliff Click @ Azul。 。 。 。 。

---我们现在回到您的定期计划---

有时,一个例子比描述如何做某事要好。本着这种精神,这是一个特别成功的例子,说明我如何更改一些代码以更好地利用芯片缓存。这是在不久前在486 CPU上完成的,后者迁移到第一代Pentium CPU。对性能的影响是相似的。

示例:下标映射

以下是我用于将数据拟合到具有通用实用程序的芯片缓存中的技术示例。

我有一个双重浮动载体,长度为1,250个元素,这是一个具有很长尾巴的流行病学曲线。曲线的“有趣”部分只有大约200个唯一值,但我不希望双侧if()测试弄乱CPU的管道(因此长尾,可以用作最极端的下标)将蒙特卡罗代码吐出来的值,我需要在代码中的“热点”内进行十几个其他条件测试的分支预测逻辑。

我确定了一个方案,我使用8位整数的向量作为双向量的下标,我缩短为256个元素。微小的整数在零之前的128之前都具有相同的值,在零之后的128之前具有相同的值,因此除了中间的256个值之外,它们都指向双向量中的第一个或最后一个值。

这将双存储器的存储需求缩小到2k,而8位下标则需要1,250字节。这缩小了10,000字节,降至3,298。由于程序在这个内循环中花费了90%或更多的时间,因此2个向量永远不会被推出8k数据高速缓存。该计划立即使其表现翻了一番。在计算100万抵押贷款的OAS值的过程中,该代码达到了大约1000亿次。

由于曲线的尾部很少被触及,因此很可能只有微小的int矢量的中间200-300个元素实际上保存在缓存中,160-240个中间双精度代表1/8的感兴趣百分比。在一个下午完成的程序中,我花了一年多时间进行优化,这是一个显着的性能提升。

我同意Jerry,因为我的经验也是如此,将代码倾斜到指令高速缓存并不像优化数据高速缓存那么成功。这是我认为AMD的常见缓存不如英特尔独立的数据和指令缓存有用的原因之一。 IE:你不希望指令占用缓存,因为它不是很有帮助。在某种程度上,这是因为CISC指令集最初是为了弥补CPU和内存速度之间的巨大差异而创建的,除了80年代后期的异常外,这几乎总是如此。

我用来支持数据缓存并破坏指令缓存的另一种最受欢迎​​的技术是在结构定义中使用大量的bit-ints,并且通常使用尽可能小的数据大小。要屏蔽4位int以保存一年中的月份,或者用9位来保存一年中的某一天等,需要CPU使用掩码来屏蔽位正在使用的主机整数,这会缩小数据,有效地增加了缓存和总线大小,但需要更多指令。虽然这种技术产生的代码在综合基准测试中表现不佳,但在用户和进程竞争资源的繁忙系统上,它的工作效果非常好。


4
投票

大多数情况下,这将作为一个占位符,直到我有时间做这个主题正义,但我想分享我认为是一个真正开创性的里程碑 - 在新的英特尔Hazwell微处理器中引入专用位操作指令。

当我在StackOverflow上编写一些代码来反转4096位数据中的位时,在引入PC之后30多年,微处理器就没有太多的注意力或资源用于位,这一点变得非常痛苦,我希望将会更改。特别是,我很想看到,对于初学者来说,bool类型在C / C ++中变成了一个实际的位数据类型,而不是它当前的荒谬浪费字节。

更新:12/29/2013

我最近有机会优化一个环形缓冲区,它以毫秒级的粒度跟踪512个不同资源用户对系统的需求。有一个定时器,每毫秒触发一次,它加上最新切片资源请求的总和,并减去第1,000个时间片的请求,包括现在1000毫秒的资源请求。

Head,Tail向量在内存中彼此相邻,除了首先是Head,然后Tail包裹并在数组的开始处开始。然而,(滚动)摘要切片位于一个固定的,静态分配的数组中,该数组并不特别接近其中任何一个,甚至没有从堆中分配。

考虑到这一点,并研究代码,一些细节引起了我的注意。

  1. 正在进入的需求同时被添加到Head和Summary切片中,在相邻的代码行中彼此相邻。
  2. 当计时器触发时,Tail从Summary切片中减去,结果保留在Summary切片中,正如您所期望的那样
  3. 定时器触发时调用的第二个函数使服务环的所有指针都前进。特别是...... Head覆盖了Tail,从而占据了相同的内存位置新的Tail占据了接下来的512个内存位置,或者被包裹
  4. 用户希望管理的需求数量更灵活,从512到4098,或者更多。我觉得这样做最强大,最笨拙的方法是将1000个时间片和摘要片一起分配为一个连续的内存块,这样,摘要片最终会变成不同的长度是不可能的。比其他1000个时间片。
  5. 鉴于上述情况,我开始怀疑是否可以获得更多性能如果,而不是将摘要切片保留在一个位置,我让它在头部和尾部之间“漫游”,所以它总是在Head的旁边添加新的需求,并在定时器触发时直接在Tail旁边,并且必须从摘要中减去Tail的值。

我做到了这一点,但后来在这个过程中发现了一些额外的优化。我更改了计算滚动摘要的代码,以便将结果留在Tail中,而不是Summary切片。为什么?因为下一个函数正在执行memcpy()以将Summary切片移动到刚刚被Tail占用的内存中。 (奇怪但是真的,Tail引领头部直到环绕它结束时)。通过在Tail中保留求和的结果,我不必执行memcpy(),我只需将pTail分配给pSummary。

以类似的方式,新的Head占用了现在陈旧的Summary slice的旧内存位置,所以我再次将pSummary分配给pHead,并将memset的所有值归零。

引导到环的末尾(实际上是鼓,512轨道宽)是Tail,但我只需将其指针与常量pEndOfRing指针进行比较以检测该条件。可以在其前面为所有其他指针分配向量的指针值。 IE:我只需要1:3指针的条件测试来正确包装它们。

初始设计使用字节整数来最大化缓存使用率,但是,我能够放宽这个约束 - 满足用户处理每用户每毫秒更高资源数的请求 - 使用无符号短路和STILL双重性能,因为即使有3个相邻通过512个无符号短路的向量,L1高速缓存的32K数据高速缓存可以轻松容纳所需的3,720个字节,其中2/3个字节位于刚才使用的位置。只有当Tail,Summary或​​Head包装的是3个中的1个时,由8MB L3cache中的任何重要“步骤”分隔。

此代码的总运行时内存占用量小于2MB,因此它完全由片上高速缓存运行,即使在具有4个内核的i7芯片上,也可以运行此过程的4个实例而不会降低性能,5个进程正在运行,总吞吐量略有增加。这是关于缓存使用的Opus Magnum。


2
投票

大多数C / C ++编译器更喜欢优化大小而不是“速度”。也就是说,由于缓存效应,较小的代码通常比展开的代码执行得更快。


0
投票

如果我是你,我会确保我知道代码的哪些部分是热点,我将其定义为

  • 一个不包含任何函数调用的紧密循环,因为如果它调用任何函数,那么PC将花费大部分时间在该函数中,
  • 这占了执行时间的很大一部分(例如> = 10%),您可以从分析器中确定。 (我只是手动对堆栈进行采样。)

如果您有这样的热点,那么它应该适合缓存。我不知道你怎么告诉它这样做,但我怀疑它是自动的。

© www.soinside.com 2019 - 2024. All rights reserved.