为什么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_one
比fast_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
首先,让我们看一下sign
variables。 由于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
只能取两个值中的一个,即0
或0x80000000
。
- 当
x = 0
,则x + (y ^ x) = y
那么平凡就成立了。 - 通过
0x80000000
添加和xoring是相同的。 它翻转标志位。 因此当x = 0x80000000
时x + (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
类似但不相同的代码。
无论如何,当涉及到优化,这是一个彩票 – 它是这样…要知道你为什么编码得到任何特定的方式并不总是容易。