在编写模拟时,我的朋友说他喜欢尝试编写足够小的程序以放入缓存中。这有什么实际意义吗?我知道缓存比 RAM 和主内存快。是否可以指定您希望程序从缓存中运行或至少将变量加载到缓存中?我们正在编写模拟,因此任何性能/优化增益都是巨大的好处。
如果您知道任何解释 CPU 缓存的好链接,请向我指出那个方向。
在编写模拟时,我的朋友说他喜欢尝试编写足够小的程序以放入缓存中。这有什么实际意义吗?我知道缓存比 RAM 和主内存快。是否可以指定您希望程序从缓存中运行或至少将变量加载到缓存中?我们正在编写模拟,因此任何性能/优化增益都是巨大的好处。
如果您知道任何解释 CPU 缓存的好链接,请向我指出那个方向。
至少对于典型的桌面 CPU,您不能真正直接指定缓存使用情况。不过,您仍然可以尝试编写缓存友好的代码。在代码方面,这通常意味着展开循环(仅举一个明显的例子)很少有用——它扩展了代码,而现代 CPU 通常会最大限度地减少循环的开销。您通常可以在数据方面做更多的事情,以提高引用的局部性,防止错误共享(例如,两个经常使用的数据将尝试使用缓存的同一部分,而其他部分保持未使用)。
编辑(使某些观点更加明确):
一个典型的 CPU 有许多不同的缓存。现代台式机处理器通常至少有 2 级缓存,通常有 3 级缓存。根据(至少几乎)普遍的协议,“1 级”是“最接近”处理元素的高速缓存,并且数字从那里上升(2 级是下一个,3 级之后,等等)
在大多数情况下,(至少)一级缓存分为两半:指令缓存和数据缓存(英特尔 486 几乎是我所知道的唯一例外,指令和数据都有一个缓存——但它已经彻底过时了,可能不值得多想)。
在大多数情况下,缓存被组织为一组“行”。高速缓存的内容通常一次读取、写入和跟踪一行。换句话说,如果 CPU 要使用高速缓存行任何部分的数据,则从下一个较低级别的存储读取整个高速缓存行。更靠近 CPU 的缓存通常更小,缓存行也更小。
这种基本架构导致了缓存的大部分特性,这些特性在编写代码时很重要。尽可能地,您希望将某些内容读入缓存一次,然后用它做所有您想做的事情,然后继续做其他事情。
这意味着当您处理数据时,通常最好读取相对少量的数据(足够少以适合缓存),尽可能多地处理该数据,然后继续处理下一个数据块数据。诸如快速排序之类的算法可以将大量输入快速分解为逐渐变小的部分,或多或少地自动执行此操作,因此它们往往对缓存非常友好,几乎不管缓存的精确细节如何。
这也对您编写代码的方式有影响。如果你有一个像这样的循环:
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 到内存的带宽控制,那么让更多内核竞争该带宽不太可能有任何好处(并且可能会造成重大损害)。在这种情况下,使用多核来提高速度通常归结为做更多的事情来更紧密地打包数据,并利用更多的处理能力来解包数据,因此真正的速度增益来自减少消耗的带宽,并且额外的内核只是避免浪费时间从更密集的格式中解压缩数据。
并行编码中可能出现的另一个基于缓存的问题是变量的共享(和错误共享)。如果两个(或更多)内核需要写入内存中的同一位置,则保存该数据的缓存线最终可能会在内核之间来回穿梭,以使每个内核都可以访问共享数据。结果通常是并行运行的代码比串行运行慢(即,在单个内核上)。有一种称为“错误共享”的变体,其中不同内核上的代码正在写入单独的数据,但不同内核的数据最终位于同一缓存行中。由于缓存仅根据整行数据来控制数据,因此无论如何数据都会在内核之间来回打乱,从而导致完全相同的问题。
这是Christer Ericsson(以战神 I/II/III 闻名)的一篇关于缓存/内存优化的非常好的论文的链接。它已经有几年的历史了,但它仍然非常相关。
更新:2014年 1 月 13 日 根据这位高级芯片设计师的说法,缓存未命中现在是代码性能的绝对主导因素,因此就相对性能而言,我们基本上一直回到 80 年代中期和快速 286 芯片加载、存储、整数运算和缓存未命中的瓶颈。
Cliff Click @ Azul 的现代硬件速成课程 。. . . .
--- 我们现在让您回到您的定期计划 ---
有时,一个例子比如何做某事的描述更好。本着这种精神,这里有一个特别成功的例子,说明我如何更改一些代码以更好地使用芯片缓存。这是不久前在 486 CPU 上完成的,后来迁移到了第一代 Pentium CPU。对性能的影响是相似的。
示例:下标映射
这是我用来将数据放入具有通用实用程序的芯片缓存中的技术示例。
我有一个长度为 1,250 个元素的双浮点向量,这是一条带有很长尾巴的流行病学曲线。曲线的“有趣”部分只有大约 200 个唯一值,但我不希望 2 面 if() 测试弄乱 CPU 的管道(因此长尾,可以用作最极端的下标值蒙特卡洛代码会吐出),我需要分支预测逻辑用于代码中“热点”内的十几个其他条件测试。
我确定了一个方案,我使用 8 位整数向量作为双精度向量的下标,我将其缩短为 256 个元素。小整数在 128 之前的 0 和 128 之后的 0 之前都具有相同的值,因此除了中间的 256 个值之外,它们都指向双精度向量中的第一个或最后一个值。
这将双精度的存储要求缩小到 2k,8 位下标的存储要求缩小到 1,250 字节。这将 10,000 字节缩减至 3,298。由于程序在这个内循环中花费了 90% 或更多的时间,因此这 2 个向量从未被推出 8k 数据缓存。该程序的性能立即翻了一番。在计算 1+ 百万抵押贷款的 OAS 值的过程中,这段代码被击中了约 1000 亿次。
由于曲线的尾部很少被触及,因此很可能只有微型 int 向量的中间 200-300 个元素实际上保存在缓存中,而中间的 160-240 个双精度值代表 1/8 的兴趣百分比。在一个我花了一年多时间优化的程序上,在一个下午完成了性能的显着提升。
我同意 Jerry 的观点,这也是我的经验,将代码向指令缓存倾斜并不像优化数据缓存那样成功。这是我认为 AMD 的通用缓存不如 Intel 的独立数据和指令缓存有用的原因之一。IE:您不希望指令占用缓存,因为它不是很有帮助。这部分是因为 CISC 指令集最初是为了弥补 CPU 和内存速度之间的巨大差异而创建的,除了 80 年代后期的异常,这几乎总是正确的。
我用来支持数据缓存和破坏指令缓存的另一种最喜欢的技术是在结构定义中使用大量位整数,并且通常使用尽可能小的数据大小。要屏蔽 4 位 int 来保存一年中的月份,或 9 位来保存一年中的某一天,等等,需要 CPU 使用掩码来屏蔽这些位正在使用的主机整数,这会缩小数据,有效地增加缓存和总线大小,但需要更多指令。虽然这种技术生成的代码在综合基准测试中表现不佳,但在用户和进程竞争资源的繁忙系统上,它的效果非常好。
大多数情况下,这将作为一个占位符,直到我有时间公正地讨论这个话题,但我想分享我认为真正具有开创性的里程碑——在新的英特尔 Hazwell 微处理器中引入专用位操作指令。
当我在 StackOverflow 上写了一些代码来反转 4096 位数组中的位时,这变得非常明显,在 PC 推出 30 多年后,微处理器并没有对位投入太多关注或资源,我希望这会改变。特别是,对于初学者,我很乐意看到 bool 类型成为 C/C++ 中的实际位数据类型,而不是目前荒谬的浪费字节。
更新:2013 年 12 月 29 日
我最近有机会优化了一个环形缓冲区,它以毫秒粒度跟踪 512 个不同资源用户对系统的需求。有一个计时器每毫秒触发一次,它添加了最新切片的资源请求的总和并减去了第 1,000 个时间切片的请求,包括现在 1,000 毫秒前的资源请求。
Head,Tail 向量在内存中彼此相邻,除非首先是 Head,然后是 Tail 包裹并从数组的开头开始。然而,(滚动)摘要切片位于一个固定的、静态分配的数组中,该数组与其中任何一个都不是特别接近,甚至没有从堆中分配。
考虑到这一点,并研究了代码,一些细节引起了我的注意。
进来的需求同时被添加到 Head 和 Summary 切片中,在相邻的代码行中彼此相邻。
当计时器触发时,Tail 被从 Summary 切片中减去,结果保留在 Summary 切片中,如您所料
计时器触发时调用的第二个函数使所有服务于环的指针前进。特别是.... Head 覆盖了 Tail,从而占用了相同的内存位置 新的 Tail 占用了接下来的 512 个内存位置,或者被包裹
用户希望管理的需求数量更灵活,从 512 到 4098,甚至更多。我觉得最健壮、最安全的方法是将 1,000 个时间片和摘要片一起分配为一个连续的内存块,这样摘要片就不可能最终成为不同的长度比其他 1,000 个时间片。
鉴于上述情况,我开始怀疑如果不是让 Summary 切片保留在一个位置,而是让它在 Head 和 Tail 之间“漫游”,那么我是否可以获得更高的性能,所以它总是紧挨着 Head添加新的需求,当计时器触发并且必须从摘要中减去尾巴的值时,就在尾巴旁边。
我正是这样做的,但随后在此过程中发现了一些额外的优化。我更改了计算滚动摘要的代码,以便将结果留在尾部,而不是摘要切片中。为什么?因为下一个函数正在执行 memcpy() 以将 Summary 切片移动到 Tail 刚刚占用的内存中。(奇怪但真实,尾巴在缠绕时引导头部直到环的末端)。通过将求和的结果留在 Tail 中,我不必执行 memcpy(),只需将 pTail 分配给 pSummary。
以类似的方式,新的 Head 占据了现在陈旧的 Summary slice 的旧内存位置,所以我再次将 pSummary 分配给 pHead,并使用 memset 将其所有值归零。
通往环末端的路(实际上是一个鼓,512 条轨道宽)是 Tail,但我只需将其指针与常量 pEndOfRing 指针进行比较即可检测到这种情况。所有其他指针都可以分配到它前面的向量的指针值。IE:我只需要对 1:3 的指针进行条件测试即可正确包装它们。
最初的设计使用字节整数来最大化缓存使用率,但是,我能够放松这个约束——满足用户每毫秒处理更高资源计数的用户请求——使用无符号短裤和仍然双倍性能,因为即使有 3 个相邻的512 个无符号短路向量,L1 缓存的 32K 数据缓存可以轻松保存所需的 3,720 字节,其中 2/3 位于刚刚使用的位置。仅当 Tail、Summary 或 Head 包装是 3 个中的 1 个被 8MB L3cache 中的任何重要“步骤”分隔时。
此代码的总运行时内存占用低于 2MB,因此它完全耗尽了片上缓存,即使在具有 4 核的 i7 芯片上,也可以运行此进程的 4 个实例而不会降低性能,总吞吐量随着 5 个进程的运行而略有上升。这是关于缓存使用的 Opus Magnum。
大多数 C/C++ 编译器更喜欢优化大小而不是“速度”。也就是说,由于缓存效应,较小的代码通常比展开的代码执行得更快。
如果我是你,我会确保我知道代码的哪些部分是热点,我将其定义为
如果您有这样的热点,那么它应该适合缓存。我不确定你是如何告诉它这样做的,但我怀疑它是自动的。