当前位置: 首页 > 知识库问答 >
问题:

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

容阳焱
2023-03-14

在编写优化的ftol函数时,我在gcc4.6.1中发现了一些非常奇怪的行为。让我先向您展示代码(为了清楚起见,我标记了差异):

快速执行一,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 */
}

第二,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_onefast_trunc_two快30%左右。现在我的问题是:这是什么引起的?


共有3个答案

宗政金鹏
2023-03-14

神秘已经给出了一个很好的解释,但是我想我要补充一点,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版本短,但没有第二个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代码。

不管怎样,说到优化,这是一个彩票——它就是这样...要知道为什么代码会以任何特定的方式编译并不总是容易的。

昌栋
2023-03-14

这是编译器的本质。假设他们会选择最快或最好的路径,这是完全错误的。任何人都意味着你不需要对你的代码做任何事情来优化,因为“现代编译器”填补了空白,做最好的工作,做最快的代码等等。实际上,我看到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(叮当声)?

Mystical在解决代码分析/优化问题所需的思考过程中做了出色的工作,期望编译器能想出任何一种方法,这是任何“现代编译器”所不期望的。

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

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

将引导编译器实现:以这种形式实现它,执行if-then-else,然后在公共代码上收敛以完成并返回。或者B:保存分支,因为这是函数的结尾。也不用麻烦使用或保存r。

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

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

这是深入研究gcc源代码的绝佳机会。您似乎发现了一种情况,优化器在一种情况下看到一件事,然后在另一种情况下看到另一件事。然后采取下一步,看看你是否能让gcc看到这种情况。每个优化都存在,因为某些个人或团体认识到优化并有意将其放在那里。每次有人把它放在那里(然后测试它,然后在将来维护它)时,这个优化就会出现并工作。

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

底线是您向编译器提供了不同的源代码,并期望得到相同的结果。问题不在于编译器的输出,而在于用户的期望。对于一个特定的编译器和处理器来说,添加一行代码可以使整个函数的速度大大降低,这是相当容易的。例如,为什么更改a=b2;到a=b2;因为(填充)(填充)(填充)(填充)(填充)(填充))编译器(命名)(?答案当然是编译器在输入端输入了不同的代码,因此编译器生成不同的输出是完全有效的。(更好的是,当您交换两行不相关的代码并导致输出发生显著变化时)输入的复杂性和大小与输出的复杂性和大小之间没有预期的关系。在叮当声中输入类似的内容:

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

它产生了大约60-100行汇编程序。它展开了循环。我没有计算行数,如果你仔细想想,它必须添加,将结果复制到函数调用的输入,进行函数调用,最少三个操作。因此,根据目标,可能至少有60条指令,80条指令(如果每个循环4条),100条指令(如果每个循环5条),等等。

曹智
2023-03-14

已更新以与OP的编辑同步

通过修改代码,我了解了GCC是如何优化第一种情况的。

在我们理解它们为什么如此不同之前,首先我们必须了解GCC如何优化fast_trunc_one()

信不信由你,fast\u trunc\u 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\u trunc\u one()-寄存器名称和所有内容完全相同的程序集。

请注意,在fast_trunc_one()的程序集中没有xors。这就是我放弃它的原因。

第1步:符号=-符号

首先,让我们看一下<代码>符号>代码>变量。因为sign=i

  • 符号=0
  • 符号=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\u trunc\u one()完全相同。我不给你这个集会,但它是一样的——注册名和所有的。

第二步:数学约简:x(y^x)=y

符号只能取两个值中的一个,00x8000000

  • x=0时,然后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\u trunc\u two()优化为相同的东西呢?

fast\u trunc\u one()的关键部分是x(y^x)=y优化。在fast\u trunc\u 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 */
}

 类似资料:
  • 问题内容: 我正在使用具有Spring安全性的BCryptPasswordEncoder。我的期望是,对于相同的输入,我将始终获得相同的输出。但是对于相同的输入,我得到不同的输出。您可以使用以下代码段对其进行测试: 输出:$ 2a $ 10 $ cYLM.qoXpeAzcZhJ3oXRLu9Slkb61LHyWW5qJ4QKvHEMhaxZ5qCPi 输出2:$ 2a $ 10 $ KEvYX9y

  • 我不明白为什么我会有这个错误信息。 首先,我不仅找不到两个之间的任何区别,而且为什么错误消息说添加(布尔)?我想应该是add(int index)。这里有什么问题吗?

  • 我正在使用带有Spring Security的BCryptPasswordEncoder。我的期望是,对于相同的输入,我总是得到相同的输出。但是对于相同的输入,我得到不同的输出。您可以使用下面的代码片段对其进行测试: 输出:$2A$10$CYLM.QOXPEAZCZHJ3OXRLU9SLKB61LHYWW5QJ4QKVHEMHAXZ5QCPI 输出2:$2A$10$kevyx9yjj0f1x3wl

  • 运行这个简单的程序时,根据编译器的不同,观察到不同的行为。 它在由GCC 11.2编译时打印,在由MSVC 19.29.30137编译时打印(两者都是截至今天的最新版本)。 相关引文(摘自最新的C 23工作草案N4901): 给定 20.15.5.4 [元一元道具], (如11.4.5.3/11[class . copy . ctor],11.4.6/9 [class.copy.assign],1

  • 这是代码: 如果我在我的机器()或这里()上尝试: 相反,这里(): 这是不同的。这是由于机器厄普西隆?还是编译器精度标志?还是不同的评估? 造成这种漂移的原因是什么?问题似乎出现在函数中(因为其他值似乎相同)。

  • 问题内容: 似乎以下代码应返回true,但返回false。 这有什么意义? 问题答案: 常规()和严格()相等之间的唯一区别是,严格相等运算符禁用类型转换。由于已经在比较两个相同类型的变量,因此使用的相等运算符的类型无关紧要。 不管您使用常规相等还是严格相等,对象比较仅 在您比较相同的精确对象时得出 。 也就是说,给定,,,但。 两个不同的对象(即使它们都具有零或相同的精确属性)也永远不会相等地进