为什么GCC为几乎相同的C代码生成这种完全不同的程序集?

在写一个优化的ftol函数的时候,我在GCC 4.6.1发现了一些非常奇怪的行为。 让我先告诉你代码(为了清晰起见,我标出了区别):

fast_trunc_one,C:

 int fast_trunc_one(int i) { int mantissa, exponent, sign, r; mantissa = (i & 0x07fffff) | 0x800000; exponent = 150 - ((i >> 23) & 0xff); sign = i & 0x80000000; if (exponent < 0) { r = mantissa << -exponent; /* diff */ } else { r = mantissa >> exponent; /* diff */ } return (r ^ -sign) + sign; /* diff */ } 

fast_trunc_two,C:

 int fast_trunc_two(int i) { int mantissa, exponent, sign, r; mantissa = (i & 0x07fffff) | 0x800000; exponent = 150 - ((i >> 23) & 0xff); sign = i & 0x80000000; if (exponent < 0) { r = (mantissa << -exponent) ^ -sign; /* diff */ } else { r = (mantissa >> exponent) ^ -sign; /* diff */ } return r + sign; /* diff */ } 

似乎同样的权利? GCC不同意。 用gcc -O3 -S -Wall -o test.s test.c编译后,这是汇编输出:

fast_trunc_one,生成:

 _fast_trunc_one: LFB0: .cfi_startproc movl 4(%esp), %eax movl $150, %ecx movl %eax, %edx andl $8388607, %edx sarl $23, %eax orl $8388608, %edx andl $255, %eax subl %eax, %ecx movl %edx, %eax sarl %cl, %eax testl %ecx, %ecx js L5 rep ret .p2align 4,,7 L5: negl %ecx movl %edx, %eax sall %cl, %eax ret .cfi_endproc 

fast_trunc_two,生成:

 _fast_trunc_two: LFB1: .cfi_startproc pushl %ebx .cfi_def_cfa_offset 8 .cfi_offset 3, -8 movl 8(%esp), %eax movl $150, %ecx movl %eax, %ebx movl %eax, %edx sarl $23, %ebx andl $8388607, %edx andl $255, %ebx orl $8388608, %edx andl $-2147483648, %eax subl %ebx, %ecx js L9 sarl %cl, %edx movl %eax, %ecx negl %ecx xorl %ecx, %edx addl %edx, %eax popl %ebx .cfi_remember_state .cfi_def_cfa_offset 4 .cfi_restore 3 ret .p2align 4,,7 L9: .cfi_restore_state negl %ecx sall %cl, %edx movl %eax, %ecx negl %ecx xorl %ecx, %edx addl %edx, %eax popl %ebx .cfi_restore 3 .cfi_def_cfa_offset 4 ret .cfi_endproc 

这是一个极端的差异。 这实际上也显示在configuration文件中, fast_trunc_onefast_trunc_two快大约30%。 现在我的问题:这是什么原因造成的?

更新为与OP的编辑同步

通过修改代码,我已经设法看到GCC如何优化第一种情况。

在我们理解为什么它们如此不同之前,首先我们必须了解GCC如何优化fast_trunc_one()

相信与否, fast_trunc_one()正在为此进行优化:

 int fast_trunc_one(int i) { int mantissa, exponent; mantissa = (i & 0x07fffff) | 0x800000; exponent = 150 - ((i >> 23) & 0xff); if (exponent < 0) { return (mantissa << -exponent); /* diff */ } else { return (mantissa >> exponent); /* diff */ } } 

这产生了与原来的fast_trunc_one()完全相同的程序集 – 寄存器名称和一切。

请注意, fast_trunc_one()的程序fast_trunc_one()没有xor或。 这就是给我的。


怎么会这样?


第1步: sign = -sign

首先,让我们看一下signvariables。 由于sign = i & 0x80000000; ,只有两个可能的价值sign可以采取:

  • sign = 0
  • sign = 0x80000000

现在认识到,在这两种情况下, sign == -sign 。 因此,当我将原始代码更改为:

 int fast_trunc_one(int i) { int mantissa, exponent, sign, r; mantissa = (i & 0x07fffff) | 0x800000; exponent = 150 - ((i >> 23) & 0xff); sign = i & 0x80000000; if (exponent < 0) { r = mantissa << -exponent; } else { r = mantissa >> exponent; } return (r ^ sign) + sign; } 

它产生与原始fast_trunc_one()完全相同的程序集。 我会免去你的集会,但它是相同的 – 注册名称和所有。


步骤2:math约简: x + (y ^ x) = y

sign只能取两个值中的一个,即00x80000000

  • x = 0 ,则x + (y ^ x) = y那么平凡就成立了。
  • 通过0x80000000添加和xoring是相同的。 它翻转标志位。 因此当x = 0x80000000x + (y ^ x) = y也成立。

因此, x + (y ^ x)简化为y 。 代码简化为:

 int fast_trunc_one(int i) { int mantissa, exponent, sign, r; mantissa = (i & 0x07fffff) | 0x800000; exponent = 150 - ((i >> 23) & 0xff); sign = i & 0x80000000; if (exponent < 0) { r = (mantissa << -exponent); } else { r = (mantissa >> exponent); } return r; } 

再一次,这编译到完全相同的程序集 – 注册名称和所有。


这个以上的版本最后还是这样的:

 int fast_trunc_one(int i) { int mantissa, exponent; mantissa = (i & 0x07fffff) | 0x800000; exponent = 150 - ((i >> 23) & 0xff); if (exponent < 0) { return (mantissa << -exponent); /* diff */ } else { return (mantissa >> exponent); /* diff */ } } 

这几乎是GCC在程序集中产生的。


那么为什么编译器不会优化fast_trunc_two()到同一个东西呢?

fast_trunc_one()的关键部分是x + (y ^ x) = y优化。 在fast_trunc_two()x + (y ^ x)expression式在分支上被分割。

我怀疑这可能足以混淆海湾合作委员会不做这个优化。 (它需要将分支提出来,并把它合并到最后的r + sign中。)

例如,这产生与fast_trunc_one()相同的程序集:

 int fast_trunc_two(int i) { int mantissa, exponent, sign, r; mantissa = (i & 0x07fffff) | 0x800000; exponent = 150 - ((i >> 23) & 0xff); sign = i & 0x80000000; if (exponent < 0) { r = ((mantissa << -exponent) ^ -sign) + sign; /* diff */ } else { r = ((mantissa >> exponent) ^ -sign) + sign; /* diff */ } return r; /* diff */ } 

这是编译器的本质。 假设他们将采取最快或最好的path,是相当虚假的。 任何暗示你不需要对你的代码做任何事情来优化,因为“现代编译器”填补空白,做最好的工作,做最快的代码等等。其实我看到gcc从3.x变得更糟。至less在arm上。 4.x在这一点上可能已经达到了3.x,但是在这之前就产生了较慢的代码。 通过练习,您可以学习如何编写代码,这样编译器就不需要努力工作,从而产生更一致和预期的结果。

这里的错误是你对产生的东西的期望,而不是实际产生的东西。 如果您希望编译器生成相同的输出,请为其提供相同的input。 不是math上相同,不是相同的,但实际上是相同的,没有不同的path,不从一个版本到另一个版本的共享或分配操作。 这是了解如何编写代码并查看编译器如何处理的一个很好的练习。 不要犯这样的错误,因为一个处理器的一个gcc版本的目标有一天产生了一个特定的结果,这是所有编译器和所有代码的规则。 您必须使用许多编译器和许多目标才能了解正在发生的事情。

海湾合作委员会是非常讨厌的,我邀请你看幕后,看看海胆的胆量,尝试添加一个目标或自己修改一些东西。 它几乎没有通过胶带和甩丝。 在关键位置添加或删除了额外的一行代码,并崩溃了。 它完全生成可用代码的事实令人高兴,而不必担心它为什么不符合其他期望。

你看看不同版本的gcc产生? 3.x和4.x,特别是4.5 vs 4.6 vs 4.7等等? 并为不同的目标处理器,x86,arm,mips等或不同的风格的x86,如果这是你使用的本地编译器,32位与64位等? 然后llvm(铛)为不同的目标?

神秘的工作在完成代码分析/优化所需的思想过程中做得非常出色,希望编译器能够想出任何“现代编译器”所不具备的。

没有深入math属性,这种forms的代码

 if (exponent < 0) { r = mantissa << -exponent; /* diff */ } else { r = mantissa >> exponent; /* diff */ } return (r ^ -sign) + sign; /* diff */ 

会导致编译器A:以这种forms实现它,执行if-then-else然后在通用代码上合并完成并返回。 或者B:保存一个分支,因为这是函数的尾部。 也懒得使用或保存r。

 if (exponent < 0) { return((mantissa << -exponent)^-sign)+sign; } else { return((mantissa << -exponent)^-sign)+sign; } 

然后你可以进入Mystical指出的符号variables消失在一起的代码为书面。 我不希望编译器看到符号variables消失,所以你应该自己做,而不是强迫编译器试图弄清楚。

这是深入研究gcc源代码的绝好机会。 看起来你已经发现优化器在一个情况下看到一个事件,在另一个情况下看到另一个事件。 然后采取下一步,看看你不能得到gcc看到这种情况。 每个优化都有,因为有些个人或小组认识到优化并故意将其放在那里。 为了这种优化,每次有人必须把它放在那里(然后testing它,然后再维护它)。

绝对不要认为代码越less,代码越慢,就越容易创build和查找不正确的示例。 比起更多的代码,更less的代码可能更快。 正如我从一开始就展示的那样,虽然你可以创build更多的代码来保存在这种情况下的分支或者循环等,并且最终的结果是代码更快。

底线是你喂了一个编译器不同的来源,并期望有相同的结果。 问题不在于编译器输出,而在于用户的期望。 对于一个特定的编译器和处理器来说,添加一行代码使得整个函数的速度显着变慢是相当容易的。 例如,为什么改变a = b + 2; 到a = b + c + 2; 导致_fill_in_the_blank_compiler_name_生成完全不同且较慢的代码? 当然,编译器的答案是在input上input不同的代码,所以编译器生成不同的输出是完全有效的。 (更好的情况是,当你交换两个不相关的代码行并使输出发生显着变化时)input的复杂性和大小与输出的复杂性和大小没有预期的关系。 像这样的东西进入叮当:

 for(ra=0;ra<20;ra++) dummy(ra); 

它产生了60-100行汇编程序。 它展开了循环。 我没有计算线,如果你想想,它必须添加,将结果复制到函数调用的input,进行函数调用,最less三个操作。 所以取决于至less60个指令的目标,如果每个循环有四个80,如果每个循环五个,则有100个。

Mysticial已经给出了一个很好的解释,但是我想我会补充一下,FWIW,为什么编译器会为一个而不是另一个编译器做优化是没有根本的。

例如,LLVM的clang编译器为这两个函数(函数名称除外)提供了相同的代码,给出:

 _fast_trunc_two: ## @fast_trunc_one movl %edi, %edx andl $-2147483648, %edx ## imm = 0xFFFFFFFF80000000 movl %edi, %esi andl $8388607, %esi ## imm = 0x7FFFFF orl $8388608, %esi ## imm = 0x800000 shrl $23, %edi movzbl %dil, %eax movl $150, %ecx subl %eax, %ecx js LBB0_1 shrl %cl, %esi jmp LBB0_3 LBB0_1: ## %if.then negl %ecx shll %cl, %esi LBB0_3: ## %if.end movl %edx, %eax negl %eax xorl %esi, %eax addl %edx, %eax ret 

这个代码并不像OP的第一个gcc版本那么短,但是不像第二个那样长。

来自另一个编译器(我不会命名)的代码,编译为x86_64,产生这两个函数:

 fast_trunc_one: movl %edi, %ecx shrl $23, %ecx movl %edi, %eax movzbl %cl, %edx andl $8388607, %eax negl %edx orl $8388608, %eax addl $150, %edx movl %eax, %esi movl %edx, %ecx andl $-2147483648, %edi negl %ecx movl %edi, %r8d shll %cl, %esi negl %r8d movl %edx, %ecx shrl %cl, %eax testl %edx, %edx cmovl %esi, %eax xorl %r8d, %eax addl %edi, %eax ret 

这是令人着迷的,因为它计算了if两边,然后在最后使用有条件的移动来select正确的。

Open64编译器产生以下内容:

 fast_trunc_one: movl %edi,%r9d sarl $23,%r9d movzbl %r9b,%r9d addl $-150,%r9d movl %edi,%eax movl %r9d,%r8d andl $8388607,%eax negl %r8d orl $8388608,%eax testl %r8d,%r8d jl .LBB2_fast_trunc_one movl %r8d,%ecx movl %eax,%edx sarl %cl,%edx .Lt_0_1538: andl $-2147483648,%edi movl %edi,%eax negl %eax xorl %edx,%eax addl %edi,%eax ret .p2align 5,,31 .LBB2_fast_trunc_one: movl %r9d,%ecx movl %eax,%edx shll %cl,%edx jmp .Lt_0_1538 

fast_trunc_two类似但不相同的代码。

无论如何,当涉及到优化,这是一个彩票 – 它是这样…要知道你为什么编码得到任何特定的方式并不总是容易。