为什么 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

这是 极端的区别。这实际上也显示在配置文件中,fast_trunc_onefast_trunc_two快30% 左右。现在我的问题是,是什么导致了这一切?

35007 次浏览

更新为与 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()的程序集中没有 xor,这就是我所了解到的。


为什么?


步骤1: sign = -sign

首先,让我们看看 sign变量。自 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: 数学简化: x + (y ^ x) = y

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

  • x = 0,然后 x + (y ^ x) = y,然后琐碎持有。
  • 通过 0x80000000添加和 xoring 是相同的。它翻转符号位。因此,当 x = 0x80000000

因此,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)表达式在分支中被分割。

我怀疑这可能足以使海湾合作委员会感到困惑而不进行这种优化。(它需要将 ^ -sign从分支中提升出来,并在最后将其合并到 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 */
}

这是编译器的本质。假设他们会采取最快或最好的路径,是相当错误的。任何暗示你不需要对你的代码做任何事情来优化的人,因为“现代编译器”填补了空白,做了最好的工作,编写了最快的代码,等等。事实上,我看到 gcc 至少在胳膊上从3.x 恶化到4.x。到目前为止,4.x 可能已经赶上了3.x,但是早期它产生的代码更慢。通过练习,你可以学习如何编写代码,这样编译器就不必那么辛苦地工作,结果就会产生更加一致和预期的结果。

这里的错误是您对将要产生的东西的期望,而不是实际产生的东西。如果希望编译器生成相同的输出,请向它提供相同的输入。不是数学上的相同,不是有点相同,但实际上是相同的,没有不同的路径,没有从一个版本到另一个版本的共享或分发操作。这是理解如何编写代码以及查看编译器如何处理代码的一个很好的练习。不要错误地假设,因为针对一个处理器的一个版本的 gcc 有朝一日会产生一个特定的结果,这是所有编译器和所有代码的规则。您必须使用许多编译器和许多目标来了解正在发生的情况。

Gcc 是相当令人讨厌的,我邀请你看看幕后,看看 gcc 的内核,尝试增加一个目标或修改自己的东西。它仅仅是用胶带和钢丝捆绑在一起。在关键位置添加或删除的额外代码行会导致崩溃。事实上,它已经产生了可用的代码,这是一件值得高兴的事情,而不是担心为什么它没有满足其他的期望。

你看过 GCC 的不同版本产生了什么吗?3.x 和4.x 尤其是4.5 vs 4.6 vs 4.7等等?对于不同的目标处理器,x86,arm,mips 等,或者不同风格的 x86,如果这是你使用的本地编译器,32位对64位,等等?然后 lvm (叮当)为不同的目标?

在分析/优化代码的过程中,Mystical 做了一个出色的思维过程,期望编译器能够提出任何一个,嗯,不是任何“现代编译器”所期望的。

在不涉及数学属性的情况下,这个表单的代码

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

将导致编译器到 A: 实现它在这种形式,执行如果然后,然后收敛到公共代码完成和返回。或者 B: 保存一个分支,因为这是函数的尾部。也不用为使用或保存 r 而烦恼。

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

然后,你可以进入,因为神秘指出符号变量消失所有一起为代码写。我不希望编译器看到符号变量消失,所以你应该自己去做,而不是强迫编译器去解决它。

这是深入研究 gcc 源代码的绝佳机会。看来您已经找到了这样一种情况,即优化器在一种情况下看到了一种情况,而在另一种情况下又看到了另一种情况。然后进行下一步,看看你能不能让 GCC 看到那个案子。每个优化都存在,因为一些个人或团体认识到了这个优化,并故意把它放在那里。为了使这个优化在每次有人必须把它放在那里的时候都能发挥作用(然后测试它,然后将它维护到未来)。

绝对不要假设代码越少越快,代码越多越慢,创建并找到这种假设的例子是非常容易的。更少的代码可能比更多的代码更快。正如我从一开始就说明的那样,虽然你可以创建更多的代码来保存分支或循环等,并且最终的结果是更快的代码。

底线是你给编译器提供了不同的源代码,并期望得到相同的结果。问题不在于编译器的输出,而在于用户的期望。对于特定的编译器和处理器来说,演示这一点相当容易,因为添加一行代码会使整个函数显著地变慢。例如,为什么将 a = b + 2; 改为 a = b + c + 2; cause _ fill _ in _ the _ space _ Editor _ name _ 会产生截然不同且速度较慢的代码?答案当然是编译器在输入上被提供了不同的代码,所以编译器生成不同的输出是完全有效的。(如果你交换了两行不相关的代码,导致输出发生巨大变化,那就更好了)输入的复杂度和大小与输出的复杂度和大小之间没有预期的关系。把这样的东西变成叮当声:

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

它生产了60-100条装配线。它打开了循环。我没有计算行数,如果你仔细想想,它必须添加,将结果复制到函数调用的输入,进行函数调用,最少三个操作。所以根据目标,大概至少有60条指令,如果每个循环4条,就是80条,如果每个循环5条,就是100条,等等。

Mystiical 已经给出了一个很好的解释,但是我想补充一下,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的两边,然后在结尾使用一个条件移动来选择正确的一个。

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代码。

无论如何,当涉及到优化的时候,这是一个抽签-它是什么... 它并不总是很容易知道为什么你的代码得到编译任何特定的方式。