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

orl*_*rlp 183 c x86 assembly gcc compiler-optimization

在编写优化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 */
}
Run Code Online (Sandbox Code Playgroud)

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 */
}
Run Code Online (Sandbox Code Playgroud)

好像是对的吗?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
Run Code Online (Sandbox Code Playgroud)

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
Run Code Online (Sandbox Code Playgroud)

这是一个极端的差异.这实际上也显示在配置文件上,fast_trunc_one比它快30%左右fast_trunc_two.现在我的问题是:是什么导致这个?

Mys*_*ial 255

已更新以与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 */
    }
}
Run Code Online (Sandbox Code Playgroud)

这将产生与原始完全相同的程序集fast_trunc_one()- 寄存器名称和所有内容.

请注意,xor程序集中没有s 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;
}
Run Code Online (Sandbox Code Playgroud)

它生成与原始组件完全相同的组件fast_trunc_one().我会为你免费组装,但它是相同的 - 注册名称和所有.


第2步:数学减少:x + (y ^ x) = y

sign只能取两个值中的一个,0或者0x80000000.

  • 什么时候x = 0,x + (y ^ x) = y然后琐碎的举行.
  • 添加和xoring 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;
}
Run Code Online (Sandbox Code Playgroud)

再次,这编译成完全相同的程序集 - 寄存器名称和所有.


以上版本最终简化为:

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 */
    }
}
Run Code Online (Sandbox Code Playgroud)

这几乎就是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 */
}
Run Code Online (Sandbox Code Playgroud)

  • 答案再次更新.我不确定它是否足够令人满意.但是,如果不确切知道相关的GCC优化如何通过,我认为我不能做得更好. (11认同)
  • 编辑,看起来我已经回答了第二版.目前的修订翻了两个例子并稍微修改了代码......这令人困惑. (4认同)
  • @Mysticial:严格来说,只要在这段代码中错误地使用了signed类型,编译器在这里进行的几乎所有转换都是在行为未定义的情况下...... (4认同)
  • @nightcracker不用担心.我已将我的答案更新为与当前版本同步. (2认同)

old*_*mer 63

这是编译器的本质.假设他们将采取最快或最好的路径,是非常错误的.任何暗示你不需要对你的代码做任何事情来优化的人,因为"现代编译器"填补空白,做最好的工作,制作最快的代码等.实际上我看到gcc从3.x变得更糟. 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位vs 64位等?然后llvm(clang)针对不同的目标?

神秘在完成分析/优化代码问题所需的思考过程中做得非常出色,期望编译器能够提出任何一个,而不是任何"现代编译器".

没有进入数学属性,这种形式的代码

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

将引导编译器到A:以该形式实现它,执行if-then-else然后收敛于公共代码以完成并返回.或者B:保存分支,因为这是函数的尾部.也不用担心使用或保存r.

if (exponent < 0) {
  return((mantissa << -exponent)^-sign)+sign;
} else {
  return((mantissa << -exponent)^-sign)+sign;
}
Run Code Online (Sandbox Code Playgroud)

然后你可以进入,因为Mystical指出sign符号变量一起消失所有代码所写的.我不希望编译器看到符号变量消失所以你应该自己做,而不是强迫编译器试图找出它.

这是深入了解gcc源代码的绝佳机会.看来你已经找到了一个案例,优化器在一个案例中看到了一件事,在另一个案例中看到了另一件事.然后采取下一步,看看你是否不能让gcc看到这种情况.每个优化都在那里,因为一些个人或团体认可了优化并故意将其放在那里.每次有人必须将它放在那里(然后测试它,然后将其保存到将来),这种优化就可以在那里工作.

绝对不要假设代码越少越快,代码越慢,就越容易创建并找到不正确的示例.通常情况下,更少的代码比更多的代码更快.正如我从一开始就证明的那样,你可以创建更多的代码来保存分支或者循环等,并且最终的结果是更快的代码.

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

for(ra=0;ra<20;ra++) dummy(ra);
Run Code Online (Sandbox Code Playgroud)

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


Cha*_*acy 23

Mysticial已经给出了一个很好的解释,但是我想我会补充说,为什么编译器会为一个编译器而不是另一个编译器进行优化,实际上没有什么根本的.

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
Run Code Online (Sandbox Code Playgroud)

此代码不像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                         
Run Code Online (Sandbox Code Playgroud)

这是令人着迷的,因为它计算了两侧,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                  
Run Code Online (Sandbox Code Playgroud)

和类似但不相同的代码fast_trunc_two.

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

  • 你不会将编译器命名为一些绝密的超级编译器吗? (10认同)
  • 我也相信它是ICC.编译器知道处理器能够具有指令级并行性,因此可以同时计算两个分支.条件移动的开销远低于错误分支预测的开销. (5认同)
  • Top Secret编译器可能是Intel``icc``.我只有32位变体,但它产生的代码非常类似于此. (4认同)