gcc 性能大幅下降,可能与内联有关

large performance drop with gcc, maybe related to inline

我目前在使用 gcc(测试版本:4.8.4)时遇到一些奇怪的效果。

我有一个面向性能的代码,运行速度非常快。它的速度在很大程度上取决于内联许多小函数。

由于内联多个 .c 文件很困难(-flto 尚未广泛使用),我保留了很多小函数(通常每个函数 1 到 5 行代码)到一个普通的 C 文件,我正在其中开发编解码器及其相关的解码器。按照我的标准,它 "relatively" 很大(大约 2000 行,虽然其中很多只是注释和空行),但是将它分成更小的部分会带来新的问题,所以我宁愿避免这种情况,如果是的话可能。

编码器和解码器是相关的,因为它们是逆运算。但是从编程的角度来看,它们是完全分开的,除了一些 typedef 和非常低级的函数(例如从未对齐的内存位置读取)之外没有任何共同点。

奇怪的效果是这个:

我最近在编码器端添加了一个新功能fnew。这是一个新的"entry point"。 .c 文件中的任何地方都没有使用或调用它。

它存在的简单事实使得 解码器 函数 fdec 的性能大幅下降,下降超过 20%,这是不容忽视的.

现在请记住,编码和解码操作是完全分开的,几乎没有任何共享,保存一些次要的 typedefu32u16 等)和相关操作(read/write).

将新编码函数fnew定义为static时,解码器fdec的性能恢复正常。由于 fnew 不是从 .c 调用的,我想它和它不存在是一样的(死代码消除)。

如果现在从编码器端调用 static fnewfdec 的性能仍然很强。

但是一旦 fnew 被修改,fdec 性能就会大幅下降。

假设 fnew 修改超过了阈值,我增加了以下 gcc 参数:--param max-inline-insns-auto=60(默认情况下,它的值应该是 40。)它起作用了:性能的 fdec 现已恢复正常。

而且我想这个游戏会随着 fnew 或任何其他类似内容的每一个小修改而永远持续下去,需要进一步调整。

这很奇怪。函数 fnew 中的一些小修改对完全不相关的函数 fdec 产生连锁反应没有合乎逻辑的理由,唯一的关系是在同一个文件中。

到目前为止,我唯一能想出的初步解释是,也许 fnew 的简单存在就足以跨越某种会影响 fdecglobal file thresholdfnew 可以在以下情况下变为 "not present":1. 不存在,2. static 但不能从任何地方调用 3. static 并且足够小以内联。但这只是隐藏了问题。这是否意味着我无法添加任何新功能?

真的,我在网上找不到任何令人满意的解释。

我很想知道是否有人已经经历过一些类似的副作用,并找到了解决方案。

[编辑]

让我们进行更疯狂的测试。 现在我要添加 另一个 完全没用的功能,只是为了玩玩。它的内容完全是fnew的复制粘贴,但是函数的名字明显不同,所以我们就叫它wtf.

wtf存在时,fnew是否静态无关紧要,max-inline-insns-auto的值是多少也不重要:fdec的性能又回来了到正常。 即使 wtf 未被使用或从任何地方调用... :'(

[编辑 2] 没有 inline 指令。所有功能都正常或 static。内联决定完全在编译器的范围内,到目前为止运行良好。

[编辑 3] 正如 Peter Cordes 所建议的那样,这个问题与内联无关,而是与指令对齐有关。在较新的 Intel cpu(Sandy Bridge 及更高版本)上,热循环受益于在 32 字节边界上对齐。 问题是,默认情况下,gcc 在 16 字节边界上对齐它们。根据先前代码的长度,这提供了 50% 的正确对齐机会。因此这是一个难以理解的问题,"looks random".

并非所有循环都是敏感的。它只对关键循环很重要,并且只有当它们的长度使它们在对齐不太理想时再跨过一个 32 字节的指令段时才重要。

根据我的经验,性能下降可能是由禁用内联优化引起的。

'inline' 修饰符并不表示强制内联函数。它给编译器一个内联函数的提示。因此,当编译器的内联优化标准无法通过对代码的简单修改来满足时,使用内联修改的函数通常会编译为静态函数。

还有一个东西让问题变得更复杂,嵌套内联优化。如果您有一个内联函数 fA,它调用一个内联函数 fB,如下所示:

inline void fB(int x, int y) {
    return x * y;
}

inline void fA() {
    for(int i = 0; i < 0x10000000; ++i) {
        fB(i, i+1);
    }
}

void main() {
    fA();
}

在这种情况下,我们希望 fA 和 fB 都是内联的。但如果不满足内联条件,则性能无法预测。也就是说,当禁用 fB 的内联时,性能会大幅下降,但 fA 的性能下降非常小。你知道,编译器的内部决策非常复杂。

导致禁用内联的原因,例如,内联函数的大小、.c 文件的大小、局部变量的数量等。

实际上,在 C# 中,我经历过这种性能下降。在我的例子中,当一个局部变量被添加到一个简单的内联函数时,性能下降了 60%。

编辑:

您可以通过阅读编译后的汇编代码来调查发生了什么。我想对用 'inline'.

修改的函数进行了意外的实际调用

将我的评论变成一个答案,因为它正在变成一个漫长的讨论。讨论表明性能问题对对齐很敏感。

linkhttps://whosebug.com/tags/x86/info 上有一些性能调整信息,包括 Intel 的优化指南和 Agner Fog 的非常棒的东西。 Agner Fog 的一些装配优化建议并不完全适用于 Sandybridge 和更高版本的 CPUs。不过,如果您想要了解特定 CPU 的底层详细信息,微架构指南非常好。

如果没有至少一个外部 link 代码我可以自己尝试,我只能手摇。如果你没有 post 任何地方的代码,你将需要使用分析/ CPU 性能计数器工具,如 Linux perf 或 Intel VTune 来追踪它在合理的时间内。


在聊天中,OP 发现 someone else having this issue, but with code posted这可能与 OP 遇到的问题相同,并且是代码对齐对 Sandybridge 式 uop 缓存很重要的主要方式之一。

慢速版本的循环中间有一个32B的边界。在边界解码为 5 微指令之前开始的指令。所以在第一个周期,uop 缓存服务 mov/add/movzbl/mov。在第二个周期中,当前缓存行中只剩下一个 mov uop。然后第 3 个周期循环发出循环的最后 2 微指令:addcmp+ja.

有问题的 mov0x..ff 开始。我猜跨越 32B 边界的指令进入 uop 缓存行(其中一个)作为它们的起始地址。

在快速版本中,一次迭代只需要 2 个周期即可发出:第一个周期相同,然后 mov / add / cmp+ja 第二个。

如果前 4 条指令中的一条多了一个字节(例如用无用的前缀或 REX 前缀填充),就不会有问题。在第一个缓存行的末尾不会有奇怪的人出局,因为 mov 将在 32B 边界之后开始并成为下一个 uop 缓存行的一部分。

AFAIK,assemble & 检查反汇编输出是使用相同指令的更长版本(参见 Agner Fog 的优化程序集)以 4 微指令的倍数获得 32B 边界的唯一方法。我不知道在您编辑时显示 assembled 代码对齐的 GUI。 (显然,这样做只适用于手写的 asm,而且很脆弱。更改代码会破坏手对齐。)

这就是英特尔优化指南建议将关键循环对齐到 32B 的原因。

如果 assembler 有办法请求前面的指令 assembled 使用更长的编码填充到一定长度,那就太好了。也许是 .startencodealign / .endencodealign 32 对指令,用于在指令之间对代码应用填充以使其在 32B 边界处结束。但是,如果使用不当,这可能会产生糟糕的代码。


对内联参数的更改将更改函数的大小,并使其他代码超过 16B 的倍数。这与更改函数内容的效果类似:它变大并更改其他函数的对齐方式。

I was expecting the compiler to always make sure a function starts at ideal aligned position, using noop to fill gaps.

需要权衡。将每个函数与 64B(缓存行的开始)对齐会损害性能。代码密度会下降,需要更多的缓存行来保存指令。 16B 很好,因为它是最近 CPU 上的指令 fetch/decode 块大小。

Agner Fog 有每个微架构的底层细节。不过,他还没有为 Broadwell 更新它,但是 uop 缓存可能自 Sandybridge 以来就没有改变过。我假设有一个相当小的循环支配运行时。我不确定首先要寻找什么。也许 "slow" 版本在 32B 代码块的末尾附近有一些分支目标(因此在 uop 缓存行的末尾附近),导致从前端出来的每个时钟明显少于 4 uops。

查看 "slow" 和 "fast" 版本(例如 perf stat ./cmd)的性能计数器,看看是否有任何不同。例如更多的缓存未命中可能表明线程之间错误地共享了缓存行。另外,profile看看"slow"版本有没有新的热点。 (例如 perf record ./cmd && perf report 在 Linux 上)。

"fast"版本得到多少uops/clock?如果它高于 3,则对对齐敏感的前端瓶颈(可能在 uop 缓存中)可能是问题所在。如果不同的对齐方式意味着您的代码需要比可用更多的缓存行,那么或者 L1 / uop-cache 未命中。

无论如何,这值得重复:使用分析器/性能计数器找到 "slow" 版本具有的新瓶颈,但 "fast" 版本没有。然后您可以花时间查看该代码块的反汇编。 (不要查看 gcc 的 asm 输出。您需要查看最终二进制文件的反汇编中的对齐情况。)查看 16B 和 32B 边界,因为它们可能位于两个版本之间的不同位置,我们认为这就是问题的原因。

如果 compare/jcc 恰好分割 16B 边界,对齐也会使宏融合失败。尽管这在您的情况下不太可能,因为您的函数始终与 16B 的某个倍数对齐。

回复:用于对齐的自动化工具:不,我不知道有什么可以查看二进制文件并告诉您有关对齐的任何有用信息。我希望有一个编辑器可以在您的代码旁边显示 4 微指令和 32B 边界的组,并在您编辑时更新。

Intel's IACA 有时对分析循环很有用,但 IIRC 它不知道采用的分支,而且我认为没有前端的复杂模型,这显然是问题所在错位会影响性能。