同一个C程序的三个版本,为什么第一个这么快?

同一个C程序的三个版本,为什么第一个这么快?,c,assembly,x86,reverse-engineering,C,Assembly,X86,Reverse Engineering,下面是一个非常简单的C程序: int main() { int n = 0; while(n != 1000000000){ n += 1; } return n; } 我用叮当声编译了它并计时。它运行时间为4.711095243692398e-06秒,或0.000004711095243692398秒 接下来,我使用Godbolt编译器浏览器()将C程序输出到英特尔语法汇编语言,以删除.cfi指令: .file "Svx.c" .intel_

下面是一个非常简单的C程序:

int main()
{
    int n = 0;

    while(n != 1000000000){
        n += 1;
    }

return n;

}
我用叮当声编译了它并计时。它运行时间为
4.711095243692398e-06
秒,或
0.000004711095243692398

接下来,我使用Godbolt编译器浏览器()将C程序输出到英特尔语法汇编语言,以删除
.cfi
指令:

.file   "Svx.c"
.intel_syntax noprefix
.text
.globl  main
.type   main, @function
main:
    push    rbp
    mov rbp, rsp
    mov DWORD PTR -4[rbp], 0
    jmp .L2
.L3:
    add DWORD PTR -4[rbp], 1
.L2:
    cmp DWORD PTR -4[rbp], 1000000000
    jne .L3
    mov eax, DWORD PTR -4[rbp]
    pop rbp
    ret
    .size   main, .-main
    .ident  "GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0"
    .section    .note.GNU-stack,"",@progbits
我用GCC编译它并计时。结果是
1.96
秒——比叮当声版本慢得多

最后,我创建了自己的程序集版本:

[BITS 64]
[default rel]

global main:function

section .data align=16

section .text

main:
xor rax,rax
l_01:
cmp rax,1000000000
je l_02
add rax,5
jmp l_01
l_02:
ret
我用
nasm
编译它,并用
ld
链接它:

sudo nasm -felf64 Svx.asm

sudo ld -shared Svx.o -o Svx.so
然后计时。它在
0.14707629615440965
秒内运行

如果反向编译的版本运行得非常慢(
0.0000047
seconds vs
1.96
seconds),而我的NASM版本运行时间为
0.147
seconds),为什么C版本运行得这么快?我感觉C版本在
0.0000047
秒时的结果是错误的;它似乎快得不可思议。这是它对汇编语言的铿锵输出:

    .text
    .intel_syntax noprefix
    .file   "Svx.c"
    .globl  main                     # -- Begin function main
    .p2align    4, 0x90
    .type   main,@function
main:                                    # @main
    .cfi_startproc
# %bb.0:
    push    rbp
    .cfi_def_cfa_offset 16
    .cfi_offset rbp, -16
    mov rbp, rsp
    .cfi_def_cfa_register rbp
    mov dword ptr [rbp - 4], 0
.LBB0_1:                                # =>This Inner Loop Header:     Depth=1
    cmp dword ptr [rbp - 4], 1000000000
    je  .LBB0_3
# %bb.2:                                #   in Loop: Header=BB0_1   Depth=1
    mov eax, dword ptr [rbp - 4]
    add eax, 1
    mov dword ptr [rbp - 4], eax
    jmp .LBB0_1
.LBB0_3:
    mov eax, dword ptr [rbp - 4]
    pop rbp
    .cfi_def_cfa rsp, 8
    ret
.Lfunc_end0:
    .size   main, .Lfunc_end0-main
    .cfi_endproc
                                        # -- End function
    .ident  "clang version 8.0.0-3~ubuntu18.04.1 (tags/RELEASE_800/final)"
    .section    ".note.GNU-stack","",@progbits
    .addrsig
清单显示他们使用的是变量堆栈,而不是寄存器,这(通常)比较慢


0.0000047
秒的速度下,数到十亿似乎是不可能的快。如果这个速度是正确的,它的秘密是什么?反向工程并没有揭示任何东西,事实上,Godbolt版本要慢得多

Clang刚刚意识到这个循环运行了
100000000次
并执行了与
返回100000000次等价的操作

您指定要使用的
-O3
的我的输出:

        .text
        .file   "test.c"
        .globl  main                    # -- Begin function main
        .p2align        4, 0x90
        .type   main,@function
main:                                   # @main
        .cfi_startproc
# %bb.0:
        movl    $1000000000, %eax       # imm = 0x3B9ACA00
        retq
.Lfunc_end0:
        .size   main, .Lfunc_end0-main
        .cfi_endproc
                                        # -- End function

        .ident  "clang version 8.0.0 (tags/RELEASE_800/final)"
        .section        ".note.GNU-stack","",@progbits
        .addrsig
注意
main
的内容:

# %bb.0:
        movl    $1000000000, %eax       # imm = 0x3B9ACA00
        retq
这将完全删除循环,只返回
100000000

解决这个问题的一个技巧是使用
volatile

int main(void)
{
    volatile int n = 0;

    while(n != 1000000000) {
        n += 1;
    }

    return n;
}
输出(再次使用
-O3
):


Clang刚刚意识到这个循环运行了
100000000次
,并执行了与
返回100000000次等价的操作

您指定要使用的
-O3
的我的输出:

        .text
        .file   "test.c"
        .globl  main                    # -- Begin function main
        .p2align        4, 0x90
        .type   main,@function
main:                                   # @main
        .cfi_startproc
# %bb.0:
        movl    $1000000000, %eax       # imm = 0x3B9ACA00
        retq
.Lfunc_end0:
        .size   main, .Lfunc_end0-main
        .cfi_endproc
                                        # -- End function

        .ident  "clang version 8.0.0 (tags/RELEASE_800/final)"
        .section        ".note.GNU-stack","",@progbits
        .addrsig
注意
main
的内容:

# %bb.0:
        movl    $1000000000, %eax       # imm = 0x3B9ACA00
        retq
这将完全删除循环,只返回
100000000

解决这个问题的一个技巧是使用
volatile

int main(void)
{
    volatile int n = 0;

    while(n != 1000000000) {
        n += 1;
    }

    return n;
}
输出(再次使用
-O3
):

您有3个案例:

  • C启用优化:用其结果替换循环:
    mov eax,100000000
    /
    ret
    。你测量的时间都是开销。编译器可以很容易地证明
    n
    必须具有该值才能退出循环,并且循环不是无限的
  • 禁用优化的C:将C变量保存在内存中,包括循环计数器。在现代x86 CPU上,循环在存储转发延迟上的瓶颈大约为每次迭代6个周期。()
  • 手工编写的asm循环(效率低下),但至少在寄存器中保留值。因此循环携带的依赖链(递增
    n
    )只有1个周期延迟

    由于您使用了
    addrax,5
    ,因此您只进行了
    n++
    循环迭代的五分之一。您可以将其视为展开5,然后将5x
    n++
    优化为
    n+=5
    。您可以将该因子设置为任意大,并将运行时间设置为任意小,直到达到
    moveax,100000000
    的程度,就像编译器所做的那样

请参见我在哪里使用了
clang-O3
gcc-O0
。请注意,
int n
在标准x86-64 ABI中是一个32位变量,因此不需要为64位操作数大小花费额外的代码大小(REX前缀)

请参阅,了解为什么高效的简单循环在底部有条件分支,而没有无条件分支。请注意,这是gcc在
-O0
时所做的(使用
jmp
进入循环到底部的循环条件)

Clang在
-O0
中生成了更为幼稚的代码,其结构与C相同,顶部有一个中断条件,底部有一个无条件的
jmp
,就像您手工编写的asm一样


因此,asm的速度应该是反优化C编译器输出速度的6*5倍,如果它不能以每次迭代1个时钟周期的速度运行NASM循环,则速度应该是反优化C编译器输出速度的一半。实际上,你测得的系数是13.333,非常接近15

因此,您可能在Haswell之前拥有Intel或在Ryzen之前拥有AMD。较新的CPU在每个时钟上有2个分支的吞吐量,前提是至少有一个分支没有被占用

或者Skylake(循环缓冲区被微码更新禁用)和一些前端效应(如将循环拆分为64字节边界)阻止它以1 iter/时钟的速度发出,因为它无法足够快地从uop缓存读取数据。

您有3种情况:

  • C启用优化:用其结果替换循环:
    mov eax,100000000
    /
    ret
    。你测量的时间都是开销。编译器可以很容易地证明
    n
    必须具有该值才能退出循环,并且循环不是无限的
  • 禁用优化的C:将C变量保存在内存中,包括循环计数器。在现代x86 CPU上,循环在存储转发延迟上的瓶颈大约为每次迭代6个周期。()
  • 手工编写的asm循环(效率低下),但至少在寄存器中保留值。因此循环携带的依赖链(递增
    n
    )只有1个周期延迟

    由于您使用了
    addrax,5
    ,因此您只进行了
    n++
    循环迭代的五分之一。您可以将其视为展开5,然后将5x
    n++
    优化为
    n+=5
    。您可以将该因子设置为任意大,并将运行时间设置为任意小,直到达到
    moveax,10000