为什么使用-O0选项(针对这个简单的浮点数求和)时,clang生成效率低下的汇编代码?

12

我正在使用 llvm clang Apple LLVM 版本 8.0.0 (clang-800.0.42.1) 对这段代码进行反汇编:

Translated text:

Using llvm clang Apple LLVM version 8.0.0 (clang-800.0.42.1), I am disassembling this code:

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)
    ...

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

  1. 将两个浮点数加载到寄存器xmm0和xmm1中
  2. 把它们放入堆栈
  3. 从堆栈中加载一个值(不是xmm0之前拥有的值)到xmm0中
  4. 执行加法运算。
  5. 把结果存回堆栈。

我认为这样做效率低下,因为:

  1. 所有操作都可以在寄存器中完成。由于我后面不会再使用a和b,所以可以跳过涉及堆栈的任何操作。
  2. 即使想要使用堆栈,如果按不同的顺序执行操作,它也可以避免重新从堆栈加载xmm0。

既然编译器总是正确的,那么它为什么选择这种策略呢?


10
因为您没有启用优化,这是最简单的方法来实现它。 - sepp2k
1
尽管基本答案很简单,但感谢您撰写这篇格式良好的问题。有一些有趣的东西要说,而且这似乎是一个很好的地方来放置我经常在其他答案中重复的规范答案。现在我可以将其链接到这里,作为查看编译器生成的汇编代码时“-O0”不是一个好选择以及“-O0”对汇编代码意味着什么的首选。 - Peter Cordes
不要试图通过查看汇编/ C 代码来预测执行时间,现代 CPU 是极其复杂的黑匣子,如果你不是专家,很容易出错。CPU 按照不同的速度和顺序执行指令,具有流水线、数据依赖性、超标量等特性 - 所有这些都可能使更长的虚拟程序比更短明显的程序运行得更快。这是一个普遍规则,始终运行程序,不要只看代码。 - Yura
1个回答

33

-O0(未优化)是默认设置,它告诉编译器你想要快速编译(短编译时间),而不是花费额外的时间来编译出高效的代码。

-O0并不是没有任何优化;例如,gcc仍会消除if(1 == 2){}块内部的代码。特别是gcc比大多数其他编译器更能在-O0时使用乘法逆元进行除法,因为它仍然会将您的C源代码转换成多个内部表示形式,最终发出汇编代码。)

此外,“编译器始终正确”即使在-O3时也是夸张的。编译器在大规模上非常出色,但单个循环中仍然经常发生微小的优化遗漏。通常对于影响不大的循环指令或uops浪费,这些指令或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语句都必须编译为完全独立的指令块。(GDB调试器中是否可以“跳过”?for()循环不能转换为成语(用于汇编)do{}while()循环,还有其他限制。
由于以上所有原因,对未经优化的代码进行(微)基准测试是巨大的时间浪费;结果取决于您编写源代码的愚蠢细节,这些细节在使用正常优化编译时不重要。 -O0-O3性能之间的关系不是线性的;一些代码将比其他代码更快。 -O0代码中的瓶颈通常与-O3不同-通常是在内存中保留的循环计数器上,从而创建大约6个周期的循环传递依赖链。 这可以在编译器生成的汇编中创建有趣的效果,例如添加冗余赋值会加速没有优化编译的代码(这对于C语言来说是有趣的asm角度,但不是C语言)。 “我的基准测试消除了优化”不是查看-O0代码性能的有效理由。 有关示例和有关针对-O0进行调整的详细信息,请参见C循环优化帮助最终分配

获取有趣的编译器输出

如果您想看看编译器如何添加两个变量,请编写一个带有参数并返回值的函数。 请记住,您只想查看汇编代码,而不是运行它,因此您不需要任何数字文字值来表示应该是运行时变量的任何内容。

此外,有关此问题的更多信息,请参见如何从GCC / clang汇编输出中删除“噪音”?

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

使用clang -O3编译(在但是在使用-O0选项时,它会将参数溢出到堆栈内存中。(Godbolt使用编译器发出的调试信息来将汇编指令按照它们来自哪个C语句进行着色编码。我添加了换行符以显示每个语句的块,但是您可以在上面的Godbolt链接中通过颜色突出显示来查看此功能。通常非常方便,可以找到优化后编译器输出中有趣部分的内部循环的部分。) gcc -fverbose-asm会在每一行上放置注释,显示操作数名称作为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防止您意外修改某些内容一样)。我不建议使用它,但有趣的是它确实会影响未优化的代码。

相关:


5
请注意,至少在clang编译器中,每个变量实际上都有分配给它的堆栈内存。其中一个最早的优化步骤(我猜想在-O0时被省略了)会尽可能将这些变量转换为一堆SSA变量。因此,至少在clang中,没有进行“反优化”,只是关闭了常规的优化。 - fuz

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接