【问题标题】:large performance drop with gcc, maybe related to inlinegcc 性能大幅下降,可能与内联有关
【发布时间】:2015-09-02 12:27:52
【问题描述】:

我目前正在体验gcc 的一些奇怪效果(测试版本:4.8.4)。

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

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

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

奇怪的效果是这个:

我最近在编码器端添加了一个新功能fnew。这是一个新的“入口点”。在.c 文件中的任何地方都不会使用或调用它。

它的存在使得 decoder 函数fdec 的性能大幅下降,下降了 20% 以上,这一点不容忽视。

现在,请记住,编码和解码操作是完全分离的,几乎不共享任何内容,保存一些次要的 typedefu32u16 等)和相关操作(读/写)。

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

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

但一旦修改fnewfdec 的性能就会大幅下降。

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

而且我猜这个游戏将永远持续下去,只要对 fnew 或其他任何类似的小改动,都需要进一步调整。

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

到目前为止,我能发明的唯一试探性解释是,fnew 的简单存在可能足以跨越某种global file threshold,这会影响fdecfnew 可以在以下情况下设置为“不存在”: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% 的机会进行正确对齐。因此,这是一个难以理解的问题,“看起来很随机”。

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

【问题讨论】:

  • 会不会是因为大量的内联代码突然开始颠簸缓存只是为了加载指令?并且fnew 函数由 gcc 在导致解码器获得更多缓存未命中的地方生成。对我来说暗示的是,将其定义为静态允许编译器从它将被调用的位置做出更强的假设并将其放置在那里。我从一个大型项目(几 MB 二进制文件)中获得的经验是,我可以通过去内联函数来大幅提升性能,从而减少代码的缓存使用量。
  • 你能做一个最小的独立示例,这样我们也可以玩弄这个吗?这真的很有趣。
  • @FUZxxl:不幸的是没有。虽然修改导致可重复的结果,修改和效果之间的关系似乎完全随机。这使得不可能有意识地产生一个最小的独立示例......
  • @Art:也许吧。请注意,我不是强行内联任何内容,而是让编译器决定,因为在大多数情况下,内联函数非常小(1-5 行),所以这似乎只是常识。我还尝试查看生成的程序集。 fdec 的“慢”和“快”版本看起来惊人地相似。某处肯定有一些细微的差别,但是生成的程序集太大了,我无法分析和找到如此细微的东西。
  • @Cyan 您应该考虑报告编译器错误,或者至少在 gcc 邮件列表中提出这个问题。如果切换到 clang,问题是否仍然存在?

标签: c performance gcc inlining


【解决方案1】:

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

https://stackoverflow.com/tags/x86/info 有一些性能调整信息的链接,包括 Intel 的优化指南和 Agner Fog 的非常优秀的东西。 Agner Fog 的一些汇编优化建议并不完全适用于 Sandybridge 和更高版本的 CPU。不过,如果您想了解特定 CPU 的底层细节,微架构指南非常好。

如果没有至少一个我可以自己尝试的代码的外部链接,我只能用手挥手。如果您不发布代码,您将需要使用 Linux perf 或 Intel VTune 等性能分析/CPU 性能计数器工具在合理的时间内进行跟踪。


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

在慢速版本的循环中间有一个 32B 的边界。在边界解码为 5 uop 之前开始的指令。所以在第一个循环中,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,组装和检查反汇编输出是使用相同指令的较长版本(请参阅 Agner Fog 的优化组装)以 4 微指令的倍数获得 32B 边界的唯一方法。我不知道在您编辑时显示组装代码对齐的 GUI。 (显然,这样做只适用于手写 asm,而且很脆弱。完全更改代码会破坏手动对齐。)

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

如果汇编器有办法请求使用更长的编码来组装前面的指令以填充到一定长度,那将是非常酷的。也许是 .startencodealign / .endencodealign 32 一对指令,将填充应用于指令之间的代码,使其在 32B 边界上结束。但是,如果使用不当,这可能会产生糟糕的代码。


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

我希望编译器始终确保函数从 理想的对齐位置,使用 noop 来填补空白。

有一个权衡。将每个函数对齐到 64B(高速缓存行的开头)会损害性能。代码密度会下降,需要更多的缓存行来保存指令。 16B 很好,因为它是最新 CPU 上的指令获取/解码块大小。

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

查看“慢”和“快”版本(例如 perf stat ./cmd)的性能计数器,看看是否有任何不同。例如更多的缓存未命中可能表示线程之间错误地共享缓存线。此外,配置文件并查看“慢”版本中是否有新热点。 (例如,在 Linux 上使用 perf record ./cmd && perf report)。

“快速”版本可以获得多少微秒/时钟?如果它高于 3,则对对齐敏感的前端瓶颈(可能在 uop 缓存中)可能是问题所在。如果不同的对齐方式意味着您的代码需要的缓存行多于可用缓存行,则该方法或 L1 / uop-cache 未命中。

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

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

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

Intel's IACA 有时可用于分析循环,但 IIRC 它不知道所采用的分支,而且我认为没有复杂的前端模型,如果未对齐会破坏性能,这显然是问题你。

【讨论】:

  • 可以试试-falign-functions=N看看有什么不同吗?
  • @MaximEgorushkin:OP 已经尝试过了,请参阅 cmets 的问题。 -falign-functions=64 显然足以将性能从“快”转变为“慢”。
  • 我可以通过使用 -falign-loops=32 获得一些好的结果:然后生成的二进制文件似乎总是很快。所以我最近的怀疑是关键对齐条件必须应用于主解码循环。作为参考,-falign-loops=16 倾向于生产“慢”版本。困难在于代码中其他地方的任何更改都可能随机更改结果,因此怀疑存在对齐问题。但是,我很惊讶二进制大小在 16 和 32 对齐时完全相同,这与直觉相反:我希望 32 版本更大。
  • @Cyan:二进制文件大小相同,这很有趣。也许其他东西仍然与 32 对齐,-falign-loops=32 只是将某些东西推了 16,导致之后的填充更少。如果你幸运的话,-falign-loops=32 将成为强大性能的诀窍,即使面对其他变化也是如此。 32B 代码边界始终是 uop 高速缓存行边界。但是,就像我一直说的那样,在您发布代码链接或在循环中找到“慢”版本停止但“快速”版本没有的实际位置之前,我们只能挥手致意't。
  • 对于其他加速,您是否使用-O3 -march=native?对于编解码器,尤其是。如果您使用算术编码来表示少于一个完整字节的符号,您可能会发现位操作指令很有用。 (BMI/BMI2,由 Haswell 引入)。我自己没有使用过它们,但bextr 看起来对于从比特流中解压缩比特非常有用。内在是uint64_t _bextr_u64 (uint64_t a, unsigned int start, unsigned int len)。因此,如果您想为具有 BMI 的 CPU 创建一个函数版本,您可能会获得它们的加速。如果适用,当然还有向量。
【解决方案2】:

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

'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'修改的函数有意想不到的真实调用。

【讨论】:

  • 问题指出inline关键字没有被使用。
  • 编译器可以内联没有'inline'的函数。所以我认为你的程序通过内联进行了优化。
  • 是的。大多数小函数都被内联了,除非编译器检测到它很少被调用。使用gcc -fdump-ipa-inline,可以看到gcc的内联决策。请注意,这是一个大转储,可能难以阅读。我在慢速和快速版本上都这样做了。从转储来看,内联决策似乎是相同的。
  • 你也可以看看编译生成的汇编代码——我想你是用-s编译的。最坏的情况是,您使用“快速”版本生成汇编代码,保存汇编代码,然后添加额外函数,注释掉有问题的函数,并将其与编译后的汇编代码链接。
  • 我做了“生成程序集”练习。问题是,结果是一个巨大的文件(几万行)。它太大了,我无法有用地解码。我试图找出“快”和“慢”版本之间的差异的尝试失败了,它们看起来几乎相同。由于所有标签都不相同,因此无法进行简单的“文件比较”,因此读取程序集是我找到比较的唯一解决方案。这么多的代码不实用。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-11-21
  • 1970-01-01
  • 1970-01-01
  • 2013-05-30
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多