为啥clang用-O0(对于这个简单的浮点总和)产生效率低下的asm?

Posted

技术标签:

【中文标题】为啥clang用-O0(对于这个简单的浮点总和)产生效率低下的asm?【英文标题】:Why does clang produce inefficient asm with -O0 (for this simple floating point sum)?为什么clang用-O0(对于这个简单的浮点总和)产生效率低下的asm? 【发布时间】:2018-11-18 23:16:19 【问题描述】:

我在 llvm clang Apple LLVM 版本 8.0.0 (clang-800.0.42.1) 上反汇编这段代码:

int main() 
    float a=0.151234;
    float b=0.2;
    float c=a+b;
    printf("%f", c);

我编译时没有使用 -O 规范,但我也尝试使用 -O0(给出相同的值)和 -O2(实际计算值并将其存储为预先计算)

得到的反汇编如下(我去掉了不相关的部分)

->  0x100000f30 <+0>:  pushq  %rbp
    0x100000f31 <+1>:  movq   %rsp, %rbp
    0x100000f34 <+4>:  subq   $0x10, %rsp
    0x100000f38 <+8>:  leaq   0x6d(%rip), %rdi       
    0x100000f3f <+15>: movss  0x5d(%rip), %xmm0           
    0x100000f47 <+23>: movss  0x59(%rip), %xmm1        
    0x100000f4f <+31>: movss  %xmm1, -0x4(%rbp)  
    0x100000f54 <+36>: movss  %xmm0, -0x8(%rbp)
    0x100000f59 <+41>: movss  -0x4(%rbp), %xmm0         
    0x100000f5e <+46>: addss  -0x8(%rbp), %xmm0
    0x100000f63 <+51>: movss  %xmm0, -0xc(%rbp)
    ...

显然它正在执行以下操作:

    将两个浮点数加载到寄存器 xmm0 和 xmm1 将它们放入堆栈 从堆栈中加载一个值(不是 xmm0 之前的那个)到 xmm0 执行添加。 将结果存储回堆栈。

我觉得它效率低下,因为:

    一切都可以在注册表中完成。我以后不使用 a 和 b,所以它可以跳过任何涉及堆栈的操作。 即使它想使用堆栈,如果它以不同的顺序执行操作,它也可以节省从堆栈中重新加载 xmm0。

鉴于编译器永远是对的,为什么它会选择这种策略?

【问题讨论】:

因为您没有启用优化,这是最简单的方法。 尽管基本答案很简单,但感谢您编写这个格式良好的问题。有一些有趣的事情要说,这看起来是放置我经常作为其他答案的一部分重复的规范答案的好地方。现在我可以将其链接为 -O0 的首选,因为它是查看编译器生成的 asm 的错误选择,而 -O0 对 asm 的含义正是如此。 不要试图通过查看 asm/c 代码来预测执行时间,现代 CPU 极其复杂的黑匣子,如果你不是专家,你很容易就错了。 CPU 以不同的速度、流水线、数据依赖性、超标量无序执行指令 - 所有这些东西都可以比较短和明显的更快地运行较长的虚拟程序。这是一般规则,总是运行,不要看代码。 【参考方案1】:

-O0(未优化)是默认设置。它告诉编译器您希望它快速编译(编译时间短),不要花费额外的时间编译以生成高效的代码。

(-O0 并不是字面上没有优化;例如 gcc 仍然会消除 if(1 == 2) 块内的代码。尤其是 gcc 比大多数其他编译器仍然会在 -O0 处使用乘法逆进行除法之类的事情,因为它仍然在最终发出 asm 之前,通过逻辑的多个内部表示来转换您的 C 源代码。)

另外,即使在-O3,“编译器永远是正确的”也是夸大其词。编译器在大规模方面非常出色,但在单个循环中仍然很常见轻微的错过优化。通常影响非常小,但循环中浪费的指令(或 uops)会占用乱序执行重新排序窗口中的空间,并且在与另一个线程共享内核时对超线程不友好。有关在简单的特定情况下击败编译器的更多信息,请参阅C++ code for testing the Collatz conjecture faster than hand-written assembly - why?。


更重要的是,-O0 还意味着处理所有类似于volatile 的变量以进行一致的调试。也就是说,您可以设置断点或单步并修改 C 变量的值,然后继续执行并让程序按照您在 C 抽象上运行的 C 源代码所期望的方式工作机器。所以编译器不能做任何常量传播或值范围简化。 (例如,已知为非负的整数可以使用它来简化事情,或者使某些 if 条件始终为真或始终为假。)

(它不像volatile 那样糟糕相当:在一个语句中对同一个变量的多次引用并不总是导致多次加载;-O0 编译器仍然会在一个单个表达式。)

编译器必须专门针对-O0 进行反优化,方法是在语句之​​间将所有变量存储/重新加载到它们的内存地址。 (在 C 和 C++ 中,每个变量都有一个地址,除非它使用(现已过时的)register 关键字声明并且从未使用过它的地址。根据其他变量的 as-if 规则优化地址是可能的,但未在-O0 完成)

不幸的是,调试信息格式无法通过寄存器跟踪变量的位置,因此如果没有这种缓慢而愚蠢的代码生成,就不可能进行完全一致的调试。

如果您不需要这个,您可以使用-Og 进行编译以进行轻度优化,而无需进行一致调试所需的反优化。 GCC 手册建议将其用于通常的编辑/编译/运行周期,但在调试时,您将针对许多具有自动存储功能的局部变量进行“优化”。全局变量和函数参数通常仍然有它们的实际值,至少在函数边界处。


更糟糕的是,-O0 生成的代码即使在您使用 GDB 的 jump 命令在不同的源代码行继续执行时仍然有效。因此,每个 C 语句都必须编译成完全独立的指令块。 (Is it possible to "jump"/"skip" in GDB debugger?)

for() 循环不能转换为idiomatic (for asm) dowhile() loops,以及其他限制。

由于上述所有原因,(micro-)benchmarking 未优化的代码是对时间的巨大浪费;结果取决于您编写源代码的愚蠢细节,当您使用正常优化进行编译时,这些细节并不重要。 -O0-O3 的性能不是线性相关的;有些代码的速度会比其他代码快得多

-O0 代码中的瓶颈通常与-O3 不同——通常位于内存中的循环计数器上,从而创建了一个~6 循环循环携带的依赖链。这可以在编译器生成的 asm 中产生有趣的效果,例如 Adding a redundant assignment speeds up code when compiled without optimization(从 asm 的角度来看,这很有趣,但对于 C,不是

“否则我的基准优化”不是查看-O0 代码性能的有效理由。 有关为-O0 进行调优的兔子洞的示例和更多详细信息,请参见C loop optimization help for final assignment。


获得有趣的编译器输出

如果您想查看编译器如何添加 2 个变量,请编写一个接受 args 并返回值的函数。请记住,您只想查看 asm,而不是运行它,因此您不需要 main 或任何应该作为运行时变量的数字文字值。

请参阅How to remove "noise" from GCC/clang assembly output?了解更多信息。

float foo(float a, float b) 
    float c=a+b;
    return c;

使用clang -O3 (on the Godbolt compiler explorer) 编译为预期

    addss   xmm0, xmm1
    ret

但是对于-O0,它会将参数溢出到堆栈内存。 (Godbolt 使用编译器发出的调试信息来根据它们来自哪个 C 语句对 asm 指令进行颜色编码。我添加了换行符以显示每个语句的块,但是您可以在上面的 Godbolt 链接上通过颜色突出显示来看到这一点. 在优化的编译器输出中查找内部循环的有趣部分通常非常方便。)

gcc -fverbose-asm 将在每一行放置 cmets,将操作数名称显示为 C 变量。在通常是内部 tmp 名称的优化代码中,但在未优化的代码中,它通常是来自 C 源代码的实际变量。我已经手动注释了 clang 输出,因为它没有这样做。

# clang7.0 -O0  also on Godbolt
foo:
    push    rbp
    mov     rbp, rsp                  # make a traditional stack frame
    movss   DWORD PTR [rbp-20], xmm0  # spill the register args
    movss   DWORD PTR [rbp-24], xmm1  # into the red zone (below RSP)

    movss   xmm0, DWORD PTR [rbp-20]  # a
    addss   xmm0, DWORD PTR [rbp-24]  # +b
    movss   DWORD PTR [rbp-4], xmm0   # store c

    movss   xmm0, DWORD PTR [rbp-4]   # return 0
    pop     rbp                       # epilogue
    ret

有趣的事实:使用register float c = a+b;,返回值可以在语句之间保留在 XMM0 中,而不是被溢出/重新加载。该变量没有地址。 (我在 Godbolt 链接中包含了该版本的函数。)

register 关键字在优化代码中没有任何作用(除了使获取变量的地址成为错误,例如本地的 const 如何阻止您意外修改某些内容)。我不推荐使用它,但有趣的是它确实会影响未优化的代码。


相关:

Complex compiler output for simple constructor - 传递 args 时变量的每个副本通常会在 asm 中产生额外的副本。 Why is this C++ wrapper class not being inlined away? __attribute__((always_inline)) 可以强制内联,但不会优化复制以创建函数 args,更不用说将函数优化到调用者中。

【讨论】:

请注意,至少 clang 实际上开始时每个变量在堆栈上都为其分配了内存。如果可能的话,第一个优化通道(我猜-O0 省略了它)将它们变成一堆 SSA 变量。所以至少在clang上,没有“反优化”,正常的优化只是关闭了。

以上是关于为啥clang用-O0(对于这个简单的浮点总和)产生效率低下的asm?的主要内容,如果未能解决你的问题,请参考以下文章

允许这种浮点优化吗?

未优化的 clang++ 代码在一个简单的 main() 中生成不需要的“movl $0, -4(%rbp)”

为啥 CPU 上的线程浮点计算会使它们花费更长的时间?

PYTHON:简单的浮点转换不起作用,为啥?

Pytest:使用假设生成总和为 1 的浮点数列表

为啥 clang 和 gcc 在这个虚拟继承代码上存在分歧?