为什么GCC对于几乎相同的C代码生成如此根本不同的汇编?


184

在编写优化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 -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_one比快30%fast_trunc_two。现在我的问题是:是什么原因造成的?


1
为了进行测试,我在这里创建了要点,您可以其中轻松复制/粘贴源代码,并查看是否可以在其他系统/ GCC版本上重现该错误。
orlp 2012年

12
将测试用例放在自己的目录中。用编译它们-S -O3 -da -fdump-tree-all。这将创建中间表示的许多快照。并排浏览它们(编号),在第一种情况下,您应该能够找到缺失的优化。
zwol 2012年

1
建议二:将所有更改intunsigned int,看看差异是否消失。
zwol 2012年

5
这两个函数的数学运算似乎略有不同。尽管结果可能相同,但表达式(r + shifted) ^ sign与并不相同r + (shifted ^ sign)。我想这会混淆优化程序?FWIW,MSVC 2010(16.00.40219.01)产生的清单几乎彼此相同:gist.github.com/2430454
DCoder 2012年

1
@DCoder:哦,该死!我没发现。但是,这不是差异的解释。让我用排除该问题的新版本更新问题。
orlp 2012年

Answers:


256

更新以与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()-寄存器名称和所有内容。

请注意,xor的程序集中没有fast_trunc_one()。那就是给我的礼物。


为何如此?


第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只能采用两个值之一,0或者0x80000000

  • 当时x = 0x + (y ^ x) = y则微不足道。
  • 加法和异0x80000000或法相同。它翻转符号位。因此x + (y ^ x) = y也成立于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)表达式被跨分支分割。

我怀疑这可能足以混淆GCC而不进行此优化。(它需要^ -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 */
}

4
编辑,好像我已经回答了第二版。当前的修订版翻转了两个示例,并稍微更改了代码……这令人困惑。
Mysticial

2
@nightcracker不用担心。我已经更新了答案以与当前版本同步。
Mysticial

1
@Mysticial:您的最终声明在新版本中不再正确,这使您的答案无效(它没有回答最重要的问题,“为什么GCC会产生如此截然不同的程序集”。)
orlp 2012年

11
答案再次更新。我不确定是否令人满意。但是,我认为如果不确切了解相关GCC优化如何通过,我就无法做得更好。
Mysticial

4
@Mysticial:严格地说,只要签订类型错误地被在此代码中使用,几乎所有的转换编译器在此犯是在行为是不确定的情况下...的
R.,GitHub上停止帮助ICE

63

这就是编译器的本质。假设他们会走最快或最好的道路,那是错误的。任何暗示您不需要对代码做任何事情来优化的人,因为“现代编译器”填补了空白,做了最好的工作,制作了最快的代码,等等。实际上,我看到gcc从3.x变到了手臂上至少4.x。到目前为止,4.x可能已经赶上了3.x,但在早期它产生的代码速度较慢。通过实践,您可以学习如何编写代码,从而使编译器不必费劲地工作,从而可以产生更加一致和预期的结果。

这里的错误是您对将要产生的东西的期望,而不是实际产生的期望。如果希望编译器生成相同的输出,则将其输入相同的输入。在数学上不一样,不一样,但实际上是一样的,没有不同的路径,没有从一个版本到另一个版本的共享或分发操作。这是了解如何编写代码并查看编译器如何使用它的好练习。不要误以为,因为某一天针对某个处理器的gcc版本会产生某种结果,那就是所有编译器和所有代码的规则。您必须使用许多编译器和许多目标来了解正在发生的事情。

gcc非常讨厌,我邀请您在幕后看一下,看一下gcc的内心,尝试自己添加目标或修改某些内容。它几乎不能用胶带和捆扎线固定在一起。在关键位置添加或删除的额外代码行会崩溃。它完全可以生成可用代码的事实令人感到高兴,而不必担心为什么它没有达到其他期望。

您看过gcc的不同版本产生了什么?3.x和4.x,尤其是4.5、4.6、4.7,等等?以及针对不同的目标处理器,x86,arm,mips等或不同版本的x86(如果您使用的是本机编译器,是32位还是64位等)?然后llvm(clang)用于不同的目标?

Mystical在解决代码分析/优化问题所需的思考过程中做得非常出色,他期望编译器提出任何“现代编译器”都无法做到的事情。

无需进入数学属性,此形式的代码

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

将导致编译器执行A:以该形式实现它,执行if-then-else,然后收敛于通用代码以完成并返回。或B:保存分支,因为这是函数的尾端。也不必理会使用或保存r。

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

然后,您可以进入Mystical指出符号变量对于所编写的代码一起消失的状态。我不希望编译器看到sign变量消失,所以您应该自己做,而不是强迫编译器尝试弄清楚它。

这是深入研究gcc源代码的绝佳机会。看来您发现一种情况,优化器在一种情况下看到一件事,然后在另一种情况下看到另一件事。然后进行下一步,看看是否无法让gcc看到这种情况。每个优化都在那儿,因为某些个人或团体认识到该优化并将其有意地放在了那里。为了使这种优化始终存在并在每次有人将其放置在那里(然后对其进行测试,然后将其维护到将来)都起作用时起作用。

绝对不要假设更少的代码会更快,而更多的代码会更慢,这很容易创建和查找不正确的示例。更少的代码比更多的代码更快的情况可能更多。正如我从一开始就演示的那样,您可以创建更多代码来保存分支(在这种情况下或循环)等内容,并最终得到更快的代码。

最重要的是,您向编译器提供了不同的源,并期望得到相同的结果。问题不在于编译器输出,而是用户的期望。对于特定的编译器和处理器,很容易演示,添加一行代码会使整个功能大大降低。例如为什么改变a = b + 2; 到a = b + c + 2; 导致_fill_in_the_blank_compiler_name_生成根本不同且较慢的代码?答案当然是编译器在输入中输入了不同的代码,因此对于编译器生成不同的输出是完全有效的。(更好的是,当您交换两条不相关的代码行并导致输出发生巨大变化时)输入的复杂度和大小与输出的复杂度和大小之间没有预期的关系。

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

它生产了大约60-100条装配线。它展开了循环。我没有计算行数,如果您考虑一下,它必须添加,将结果复制到函数调用的输入中,进行函数调用,最少要进行三个操作。因此,取决于目标,至少可能有60条指令,如果每个循环有四个,则为80条,如果每个循环有五个,则为100条,依此类推。


你为什么要破坏你的答案?奥德(Oded)似乎也不同意编辑;-)。
彼得-恢复莫妮卡

@ PeterA.Schneider他的所有答案似乎都在同一日期被破坏。我认为有人用他的(被盗?)帐户数据进行了此操作。
trinity420 '18

23

Mysticial已经给出了很好的解释,但是我想补充一点,FWIW,关于编译器为何要为一个而不是另一个进行优化的问题,实际上并没有任何基础。

clang例如,LLVM的编译器为两个函数提供相同的代码(函数名称除外),从而得到:

_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

无论如何,当涉及到优化时,这简直就是彩票-它就是它……并非总是很容易知道为什么要以任何特定方式编译代码。


10
您不会为这个编译器命名一些绝密的超级编译器吗?
orlp 2012年

4
最高机密的编译器可能是Intel icc。我只有32位变体,但它产生的代码与此非常相似。
Janus Troelsen

5
我也相信这是ICC。编译器知道处理器具有指令级并行性,因此可以同时计算两个分支。条件移动的开销远低于错误分支预测的开销。
Filip Navara'5
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.