【问题标题】:Design code to fit in CPU Cache?设计代码以适应 CPU 缓存?
【发布时间】:2010-12-21 19:01:52
【问题描述】:

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

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

【问题讨论】:

  • “足够小”很重要,但“足够接近”和“在时间上足够接近”也很重要。缓存只能容纳这么多,所以让它成为一个很好的紧凑包装,你需要的所有东西在同一时间,在同一时间点物理上相邻。

标签: c performance caching cpu-architecture cpu-cache


【解决方案1】:

至少对于典型的桌面 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 到内存的带宽控制,那么让更多内核竞争该带宽不太可能有任何好处(并且可能会造成重大损害)。在这种情况下,使用多核来提高速度通常归结为做更多的事情来更紧密地打包数据,并利用更多的处理能力来解包数据,因此真正的速度增益来自减少消耗的带宽,并且额外的内核只是避免浪费时间从更密集的格式中解压缩数据。

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

【讨论】:

  • “现代 CPU 通常会最小化循环的开销”。好吧,在一个简单的基准测试中,展开循环通常会带来惊人的提升。我当然已经看到在具有编译器优化的现代 CPU 上以 2 或 4 倍的代码速度展开,只要它不会阻止编译器执行任何矢量化操作。这是因为基准代码总是适合缓存。然后在实际应用程序中,所有展开的循环都加起来,缓存未命中也是如此。基本上,做X然后Y所用的时间不等于做X所用的时间加上做Y所用的时间......
  • 循环展开是一种优化,分支预测可以在一定程度上缓解这种优化,并强调指令缓存,因为展开的代码更大,因此占用更多的缓存空间。它对数据缓存没有任何影响。通常,专注于尽可能减小数据大小,以便它们适合数据缓存以发挥最大性能。
  • @RocketRoy:我有点不知所措,你怎么能声称这不能区分 I$ 和 D$。它专门讨论了“在代码方面......”和“在数据方面......”。一些指令缓存确实需要处理修改(例如,x86,在其上支持自修改代码,但会受到相当严重的惩罚)。
  • @RocketRoy:嗯,我有一些额外的时间,所以我(相当)扩展了答案。
  • 杰瑞干得漂亮!!!不仅值得我投票,而且还有更多的投票。很自豪能促使你写出这篇优秀的作品。也许我应该用我自己的答案来补充这一点——如果这该死的头痛消退的话。 RE:并行编码,我的观察是英特尔内核总是比它们的总线快,所以我对每件事都使用位整数和位结构。它增加了 CPU 的负载以屏蔽主机整数,但有效地将缓存和总线大小增加了 3-64 倍。这样的代码在综合基准测试中很慢,但在 CPU 负载过重时会飞起来。
【解决方案2】:

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

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

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

【讨论】:

    【解决方案3】:

    这里有一个链接,指向一个非常好的 paper,由 Christer Ericsson(以战神 I/II/III 闻名)的缓存/内存优化。它已经有几年的历史了,但它仍然非常重要。

    【讨论】:

    • 安德烈亚斯的一个很好的参考。它达到了我要提出的大部分观点。我目前正在处理的项目已经从每秒 200k 到每秒 15M 的范围,主要是由于对 L1 和 L3 缓存的出色使用,以及一些巧妙的方法将平面向量内存弯曲成环形缓冲区。我认为这是一种真正让代码飞起来的黑色艺术,其中很大一部分是消息灵通的设计与大量的基准测试相结合。再次感谢您的链接。
    • 那个链接已经失效了。 Here 是 Wayback Machine 的备份。
    【解决方案4】:

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

    【讨论】:

    • GCC 有优化标志,会尝试编写快速代码,但可能会导致程序变大。
    • 十年前,我是微软 IIS Web 服务器的性能主管。我多次从 Windows 性能团队和 VC 团队得到的建议正是我上面所说的。在 Visual C++ 术语中,更喜欢 /Os 选项而不是 cl.exe 而不是 /Ot。展开的代码较大,更有可能超过指令缓存的大小,从而导致缓存未命中。
    • @GeorgeV.Reilly,重新审视一下,你得到了很好的建议,因为 IIS 可能是很多没有大热点的代码。我的代码是具有 1 个 H-U-G-E 热点的蒙特卡洛模拟。 SqlServer 可能看起来像 IIS,但这并不是因为所有 DB 中的用户模式都作为元数据存储,从而迫使 DB 服务器在为任何用户的 DB 活动提供服务时访问数兆字节的数据。 IE:每个数据库内部都有另一个数据库,IE 是一个元数据库。当数据库处理查询时,运行的核心代码非常少,因此需要大量数据缓存。
    【解决方案5】:

    Ulrich Drepper 的What Every Programmer Should Know About Memory 是一篇有用的论文,它将告诉您比您想知道的更多的信息。 Hennessey 非常彻底地涵盖了它。 Christer 和 Mike Acton 也为此写了很多好东西。

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

    【讨论】:

      【解决方案6】:

      在我有时间公正地讨论这个话题之前,这主要是一个占位符,但我想分享我认为真正具有开创性的里程碑——在新的英特尔 Hazwell 微处理器中引入专用位操作指令。

      当我在 StackOverflow 上写了一些代码来反转 4096 位数组中的位时,这变得非常明显,在 PC 推出 30 多年后,微处理器并没有对位投入太多关注或资源,而且我希望会改变。特别是,对于初学者,我很乐意看到 bool 类型成为 C/C++ 中的实际位数据类型,而不是目前荒谬的浪费字节。

      更新:2013 年 12 月 29 日

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

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

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

      1. 传入的需求同时添加到 Head 和 Summary 切片中,在相邻的代码行中彼此相邻。

      2. 当计时器触发时,Tail 被从 Summary 切片中减去,结果保留在 Summary 切片中,正如您所期望的那样

      3. 定时器触发时调用的第二个函数将所有服务于环的指针推进。特别是.... 头部覆盖了尾部,从而占据了相同的内存位置 新的 Tail 占用了接下来的 512 个内存位置,或者被包裹

      4. 用户希望管理的需求数量更灵活,从 512 到 4098,甚至更多。我觉得最强大、最简单的方法是将 1,000 个时间片和摘要片一起分配为一个连续的内存块,这样摘要片就不可能最终成为不同的长度比其他 1000 个时间片。

      5. 鉴于上述情况,我开始怀疑如果不是让摘要切片保留在一个位置,而是让它在头部和尾部之间“漫游”,我是否可以获得更高的性能,所以它总是紧挨着 Head 用于添加新的需求,紧挨着 Tail 当计时器触发并且必须从 Summary 中减去 Tail 的值时。

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

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

      Tail 引领着环的尽头(实际上是一个鼓,512 条轨道宽),但我只需将它的指针与一个常量 pEndOfRing 指针进行比较就可以检测到这种情况。所有其他指针都可以分配到它前面的向量的指针值。 IE:我只需要对 1:3 的指针进行条件测试即可正确包装它们。

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

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

      【讨论】:

        【解决方案7】:

        更新:2014 年 1 月 13 日 根据这位资深芯片设计师的说法,缓存未命中现在是代码性能的压倒性主导因素,因此就加载、存储、整数的相对性能瓶颈而言,我们基本上一直回到 80 年代中期和快速 286 芯片算术和缓存未命中。

        A Crash Course In Modern Hardware by 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 使用掩码来屏蔽这些位正在使用的主机整数,这会缩小数据,有效地增加缓存和总线大小,但需要更多指令。虽然这种技术生成的代码在综合基准测试中表现不佳,但在用户和进程竞争资源的繁忙系统上,它的效果非常好。

        【讨论】:

          猜你喜欢
          • 2012-04-28
          • 2015-05-26
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2010-11-12
          • 2020-10-12
          • 2012-08-01
          • 1970-01-01
          相关资源
          最近更新 更多