为啥 GCC 会为几乎相同的 C 代码生成如此完全不同的程序集?

Posted

技术标签:

【中文标题】为啥 GCC 会为几乎相同的 C 代码生成如此完全不同的程序集?【英文标题】:Why does GCC generate such radically different assembly for nearly the same C code?为什么 GCC 会为几乎相同的 C 代码生成如此完全不同的程序集? 【发布时间】:2012-05-02 06:52:25 【问题描述】:

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

【问题讨论】:

出于测试目的,我创建了一个 gist here,您可以在其中轻松复制/粘贴源代码,看看是否可以在其他系统/版本的 GCC 上重现该错误。 将测试用例放在自己的目录中。用-S -O3 -da -fdump-tree-all 编译它们。这将创建中间表示的许多快照。并排浏览它们(它们已编号),您应该能够在第一种情况下找到缺失的优化。 建议二:将int全部改成unsigned int,看看差异是否消失。 这两个函数的数学运算似乎略有不同。虽然结果可能相同,但表达式 (r + shifted) ^ signr + (shifted ^ sign) 不同。我想这让优化器感到困惑? FWIW, MSVC 2010 (16.00.40219.01) 生成的列表几乎完全相同:gist.github.com/2430454 @DCoder:该死的!我没有发现。但是,这不是差异的解释。让我用一个新版本来更新这个问题,排除这个问题。 【参考方案1】:

这是编译器的本质。假设他们将采取最快或最好的路径,这是完全错误的。任何暗示您不需要对代码做任何优化的人,因为“现代编译器”填补了空白,做最好的工作,制作最快的代码等等。实际上我看到 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 位等?然后 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 指出的符号变量一起消失的代码。我不希望编译器看到符号变量消失,所以您应该自己完成,而不是强迫编译器尝试找出它。

这是深入了解 gcc 源代码的绝佳机会。您似乎发现了一种情况,优化器在一种情况下看到一件事,然后在另一种情况下看到另一件事。然后进行下一步,看看能不能让gcc看到那个case。每个优化都在那里,因为一些个人或团体认识到优化并故意将其放在那里。每次有人必须将它放在那里(然后对其进行测试,然后将其维护到未来)时,这种优化就在那里并发挥作用。

绝对不要假设代码越少越快,代码越多越慢,很容易创建和查找不正确的示例。通常情况下,更少的代码比更多的代码更快。正如我从一开始就展示的那样,尽管您可以创建更多代码来保存这种情况下的分支或循环等,并最终获得更快的代码。

底线是您为编译器提供了不同的源并期望得到相同的结果。问题不在于编译器输出,而在于用户的期望。为特定的编译器和处理器演示是相当容易的,添加一行代码会使整个函数显着变慢。例如为什么改变 a = b + 2;到 a = b + c + 2;导致_fill_in_the_blank_compiler_name_ 生成完全不同且速度较慢的代码?答案当然是编译器在输入上输入了不同的代码,因此编译器生成不同的输出是完全有效的。 (更好的是当您交换两行不相关的代码并导致输出发生巨大变化时)输入的复杂性和大小与输出的复杂性和大小之间没有预期的关系。将这样的内容输入到 clang 中:

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

它产生了 60-100 行汇编程序。它展开了循环。我没有数行数,你想想,它必须添加,复制结果到输入到函数调用,进行函数调用,最少三个操作。所以取决于目标,至少可能是 60 条指令,如果每个循环有 4 条指令,则为 80 条,如果每个循环有 5 条,则为 100 条,等等。

【讨论】:

你为什么破坏你的答案? Oded 似乎也不同意编辑;-)。 @PeterA.Schneider 他的所有答案似乎都在同一天遭到破坏。我认为有人拥有他的(被盗?)帐户数据。【参考方案2】:

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 的两边,然后在末尾使用条件移动来选择正确的。

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 的类似但不相同的代码。

无论如何,在优化方面,它是一种彩票——它就是这样......要知道为什么你的代码会以任何特定的方式编译并不总是那么容易。

【讨论】:

编译器是你不会命名的绝密超级编译器吗? 最高机密编译器可能是 Intel icc。我只有 32 位版本,但它生成的代码与此非常相似。 我也相信是ICC。编译器知道处理器具有指令级并行能力,因此可以同时计算两个分支。条件移动的开销远低于错误分支预测的开销。【参考方案3】:

已更新以与 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() 的程序集中没有 xors。这就是给我的原因。


怎么会?


第 1 步: sign = -sign

首先,让我们看一下sign 变量。由于sign = i &amp; 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 的添加和异或是相同的。它翻转符号位。因此x + (y ^ x) = yx = 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 */

【讨论】:

编辑,看起来我已经回答了第二版。当前版本翻转了两个示例并稍微更改了代码......这令人困惑。 @nightcracker 不用担心。我已更新我的答案以与当前版本同步。 @Mysticial:您的最终陈述不再适用于新版本,使您的答案无效(它没有回答最重要的问题,“为什么 GCC 会生成如此完全不同的程序集".) 答案再次更新。我不确定它是否足够令人满意。但如果不确切了解相关的 GCC 优化传递如何工作,我认为我不能做得更好。 @Mysticial:严格来说,只要在这段代码中错误地使用了有符号类型,编译器在这里所​​做的几乎所有转换都是在行为未定义的情况下进行的......

以上是关于为啥 GCC 会为几乎相同的 C 代码生成如此完全不同的程序集?的主要内容,如果未能解决你的问题,请参考以下文章

为啥我的字体文本看起来如此粗体?

为啥 GCC 没有尽可能地优化这组分支和条件?

为啥只有注释更改的两个程序二进制文件在 gcc 中不完全匹配?

为啥 emacs 会为修改过的文件创建临时符号链接?

为啥每次使用 std::random_device 和 mingw gcc4.8.1 运行都会得到相同的序列?

为啥 auc 与 sklearn 和 R 的逻辑回归如此不同