11

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

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

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

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

奇怪的效果是这样的:

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

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

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

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

如果static fnew现在从编码器端调用,性能fdec仍然很强。

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

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

而且我猜这个游戏将永远持续下去,每一个小小的修改fnew或任何类似的东西,都需要进一步的调整。

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

到目前为止,我可以发明的唯一试探性解释是,也许简单的存在fnew就足以跨越某种global file threshold会影响fdec. fnew可以在以下情况下“不存在”:1. 不存在,2.static但不能从任何地方调用 3.static并且足够小可以内联。但这只是隐藏了问题。这是否意味着我不能添加任何新功能?

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

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

[编辑]

让我们进行一些更疯狂的测试。现在我正在添加另一个完全无用的功能,只是为了玩。它的内容严格来说就是 的复制粘贴fnew,但是函数的名称明显不同,所以我们称之为wtf

存在时,是否为静态wtf无关紧要, : 的值是多少,性能恢复正常。即使不使用也不从任何地方调用...... :'(fnewmax-inline-insns-autofdecwtf

[编辑 2] 没有inline说明。所有功能要么正常要么static。内联决策完全在编译器的范围内,到目前为止效果很好。

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

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

4

2 回答 2

2

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

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

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


在聊天中,OP 发现其他人有此问题,但已发布代码这可能与 OP 看到的问题相同,并且是代码对齐对 Sandybridge 风格的 uop 缓存很重要的主要方式之一。

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

问题mov开始于0x..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)的性能计数器,看看是否有任何不同。例如,更多的高速缓存未命中可能表明线程之间错误地共享高速缓存行。此外,配置文件并查看“慢”版本中是否有新热点。(例如perf record ./cmd && perf report在 Linux 上)。

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

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

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

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

英特尔的 IACA有时可用于分析循环,但 IIRC 它不知道采用的分支,而且我认为没有复杂的前端模型,如果错位会破坏性能,这显然是问题所在。

于 2015-09-02T20:42:08.590 回答
0

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

'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%。

编辑:

您可以通过阅读已编译的汇编代码来调查发生了什么。我猜对用“内联”修改的函数有意想不到的真实调用。

于 2015-09-02T14:45:47.940 回答