内联和指令缓存命中率和抖动

Inlining and Instruction Cache Hit Rates and Thrashing

这篇文章https://www.geeksforgeeks.org/inline-functions-cpp/指出内联的缺点是:

3) Too much inlining can also reduce your instruction cache hit rate, thus reducing the speed of instruction fetch from that of cache memory to that of primary memory.

内联如何影响指令缓存命中率?

6) Inline functions might cause thrashing because inlining might increase size of the binary executable file. Thrashing in memory causes performance of computer to degrade.

内联如何增加二进制可执行文件的大小?仅仅是增加了代码库长度吗?此外,我不清楚为什么拥有更大的二进制可执行文件会导致抖动,因为这两个文件似乎没有关联。

假设您有一个长度为 100 条指令的函数,无论何时调用它都需要 10 条指令。

这意味着对于 10 次调用,它在二进制文件中使用了 100 + 10 * 10 = 200 条指令。

现在让我们说它在所有使用的地方都是内联的。这会在您的二进制文件中使用 100*10 = 1000 条指令。

所以对于第 3 点,这意味着它会在指令缓存中占用更多 space(内联函数的不同调用在 i-cache 中不是 'shared')

对于第 6 点,您的总二进制大小现在更大,更大的二进制大小可能导致抖动

一般来说,内联往往会增加发出的代码大小,因为调用站点被替换为更大的代码段。因此,可能需要更多内存 space 来保存代码,这可能会导致抖动。我会更详细地讨论这个问题。

How does inlining affect the instruction cache hit rate?

内联对性​​能的影响很难在没有实际 运行 代码和衡量其性能的情况下进行一般静态描述。

是的,内联可能会影响代码大小,并且通常会使发出的本机代码更大。让我们考虑以下情况:

  • 在两种情况下(有或没有内联),在特定时间段内执行的代码都适合内存层次结构的特定级别(比如 L1I)。因此,关于该特定级别的性能不会改变。
  • 在没有内联的情况下,在特定时间段内执行的代码适合内存层次结构的特定级别,但不适合内联。这对性能的影响取决于执行的位置。本质上,如果最热的代码片段首先出现在该级别的内存中,那么该级别的未命中率可能会略有增加。现代处理器的功能,如推测执行、out-of-order 执行、预取可以隐藏或减少额外未命中的惩罚。重要的是要注意内联确实改善了代码的局部性,尽管代码大小增加了,但这仍会对性能产生净积极影响。当频繁执行在调用站点内联的代码时尤其如此。已经开发出部分内联技术来仅内联函数中被认为是热的部分。
  • 在这两种情况下,在特定时间段内执行的代码都不适合内存层次结构的特定级别。因此,关于该特定级别的性能不会改变。

Moreover, it is not clear to me why having a larger binary executable file would cause thrashing as the two dont seem linked.

考虑 resource-constrained 系统上的主内存级别。即使仅仅增加 5% 的代码大小也会导致主内存抖动,从而导致性能显着下降。在其他 resource-rich 系统(台式机、工作站、服务器)上,当热指令的总大小太大无法容纳在一个或多个缓存中时,抖动通常只发生在缓存中。

如果编译器尽可能内联所有内容,那么大多数函数都会非常庞大​​。 (虽然你可能只有一个调用库函数的巨大 main 函数,但在最极端的情况下,你程序中的所有函数都将被内联到 main 中)。

想象一下,如果一切都是宏而不是函数,那么它会在您使用它的任何地方完全扩展。这是内联的 source-level 版本。


大多数函数有多个 call-sites。调用函数的 code-size 与 args 的数量成比例,但与中型到大型函数相比通常很小。因此,在其所有调用站点内联大型函数会增加总代码大小,从而降低 I-cache 命中率。

但现在编写大量 小型 包装器/辅助函数是常见的做法,尤其是在 C++ 中。 stand-alone 版本的小函数的代码通常不会比调用它所需的代码大多少,尤其是当您包含函数调用的 side-effects 时(如破坏寄存器)。内联小函数通常可以节省代码大小,尤其是当内联后可以进一步优化时。 (例如,函数计算函数外部代码也计算的一些相同内容,因此 CSE 是可能的)。

所以对于编译器来说,是否内联到任何特定调用点的决定应该基于被调用函数的大小,也许它是否在循环中调用。 (如果调用站点 运行 更频繁,优化掉 call/ret 开销更有价值。)Profile-guided 优化可以帮助编译器做出更好的决策,通过 "spending" 更多 code-size 用于热函数,并在冷函数中节省 code-size(例如,许多函数在程序的整个生命周期中仅 运行 一次,而一些热函数占用了大部分时间)。

如果 编译器对于何时内联没有很好的启发式方法,或者您将它们覆盖方式 过于激进,那么是的, I-cache 失误就是结果。

但是现代编译器确实有很好的内联启发式,通常这会使程序明显更快但只是更大一点。您阅读的文章正在谈论为什么需要限制。


上面的 code-size 推理应该清楚地表明可执行文件的大小增加了,因为它不会缩小数据。许多函数在可执行文件中仍然有一个 stand-alone 副本,以及在各种调用站点的内联(和优化)副本。

有几个因素可以缓解 I-cache 命中率问题。更好的局部性(因为没有跳来跳去)让代码预取做得更好。许多程序将大部分时间花在总代码的一小部分上,经过一些内联后通常仍然适合 I-cache。

但是较大的程序(如 Firefox 或 GCC)有很多代码,并且在大型 "hot" 循环中从许多调用站点调用相同的函数。过多的内联会使每个热循环的总代码大小膨胀,这会损害它们的 I-cache 命中率。

Thrashing in memory causes performance of computer to degrade.

https://en.wikipedia.org/wiki/Thrashing_(computer_science)

在具有多个 GiB RAM 的现代计算机上,虚拟内存(分页)的抖动是不合理的,除非系统上的每个程序都使用极其激进的内联编译。如今,大部分内存都被数据占用,而不是代码(尤其是计算机中的像素图 运行 GUI),因此 代码必须爆炸几个数量级才能开始制作整体内存压力的真正差异。

击败 I-cache 与多次 I-cache 失误几乎是一回事。但有可能超越这一点,冲击缓存代码 + 数据的更大的统一缓存(L2 和 L3)。

关于为什么内联会损害 i-cache 命中率或导致抖动的混淆可能在于静态指令计数和动态指令计数之间的差异。内联(几乎总是)减少后者但通常增加前者。

让我们简要地研究一下这些概念。

静态指令计数

某些执行跟踪的静态指令计数是二进制映像中出现的唯一指令0 的数量。基本上,您只需计算汇编转储中的指令行。以下 x86 代码片段的静态指令数为 5(.top: 行是一个标签,不会转换为二进制文件中的任何内容):

  mov eci, 10
  mov eax, 0
.top:
  add eax, eci
  dec eci
  jnz .top

静态指令数对于二进制大小和缓存注意事项最为重要。

静态指令计数也可以简称为 "code size",我有时会在下面使用该术语。

动态指令计数

另一方面,动态指令计数取决于实际的运行时行为,是执行的指令数。由于循环和其他分支,同一条静态指令可能会被多次计数,静态计数中包含的一些指令可能根本不会执行,因此在动态情况下不会计数。上面的代码片段的动态指令数为 2 + 30 = 32:前两条指令执行一次,然后循环执行 10 次,每次迭代执行 3 条指令。

作为一个非常粗略的近似值,动态指令计数对于运行时性能非常重要。

权衡

循环展开、函数克隆、矢量化等许多优化增加了代码大小(静态指令数)以提高运行时性能(通常与动态指令数密切相关)。

内联也是一种优化,尽管对于某些调用站点,内联 减少了 动态 静态指令计数。

How does inlining affect the instruction cache hit rate?

文章提到太多内联,这里的基本思想是大量内联通过增加工作集的静态指令数来增加代码占用空间 同时通常减少其 动态指令数 。由于典型的指令缓存1缓存静态指令,较大的静态足迹意味着缓存压力增加,通常会导致更差的缓存命中率。

增加的静态指令数是因为内联本质上是在每个调用点复制函数体。因此,不是函数体的一个副本和一些调用函数 N 次的指令,而是最终得到函数体的 N 个副本。

现在这是一个相当天真的内联如何工作的模型,因为内联之后,可能会出现进一步优化的情况在特定 call-site 的上下文中完成,这可能会显着减少内联代码的大小。在非常小的内联函数或大量后续优化的情况下,内联后的结果代码甚至可能更小,因为剩余代码(如果有的话)可能比开销更小参与调用函数2.

不过,基本思想仍然存在:过多的内联会使二进制映像中的代码膨胀。

i-cache 的工作方式取决于某些执行的静态指令计数,或者更具体地说,是二进制映像中触及的指令缓存行数,这很大程度上是静态指令计数的一个相当直接的函数。也就是说,i-cache 缓存二进制图像的区域,因此区域越多,区域越大,缓存占用空间就越大,即使动态指令数恰好较低。

How does inlining increase the size of the binary executable file?

这与上述i-cache案例的原理完全相同:更大的静态足迹意味着需要调入更多不同的页面,从而可能对VM 系统造成更大的压力。现在我们通常以兆字节为单位衡量代码大小,而服务器、台式机等上的内存通常以千兆字节为单位,因此过度内联不太可能对此类系统的抖动产生有意义的影响。这可能是更小或嵌入式系统的一个问题(尽管后者通常根本没有 MMU)。


0这里的unique指的是指令的IP,而不是编码指令的实际值。您可能会在二进制文件的多个位置找到 inc eax,但从这个意义上说,每个位置都是独一无二的,因为它们出现在不同的位置。

1也有例外,比如某些类型的trace缓存。

2 在 x86 上,必要的 overhad 几乎就是 call 指令。根据调用站点的不同,可能还会有其他开销,例如将值改组到正确的寄存器中以遵守 ABI,以及溢出 caller-saved 寄存器。更一般地说,函数调用可能会有很大的成本,仅仅是因为编译器必须在函数调用中重置其许多假设,例如内存状态。