【问题标题】:Three versions of the same C program, why is the first one so fast?同一个C程序的三个版本,为什么第一个这么快?
【发布时间】:2020-01-22 19:48:08
【问题描述】:

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

int main()
{
    int n = 0;

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

return n;

}

我用 Clang 编译并计时。它在4.711095243692398e-06 秒或0.000004711095243692398 秒内运行。

接下来,我使用 Godbolt 编译器资源管理器 (https://godbolt.org) 将 C 程序输出到 Intel 语法汇编语言,以删除 .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 秒——比 Clang 版本慢得多。

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

[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 秒 vs 1.96 秒)并且我的 NASM 版本运行在0.147 秒,为什么 C 版本运行如此之快?我感觉 0.0000047 秒的 C 版本的结果是错误的;它似乎不可能快。这是汇编语言的 Clang 输出:

    .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 正在优化那个无用的循环,只是在做 return 1000000000
  • 换句话说,Clang 看到它只是数到十亿并跳过计数步骤并循环报告最终的预期数字?
  • 没错。这是一个很常见的优化。可以看证明here
  • 要破坏循环优化并强制循环,您可以将 n 标记为 volatile,如 volatile int n = 0;
  • 不,永远不要在没有优化的情况下进行基准测试。未优化的代码可以做疯狂的事情,计时结果不可用

标签: c assembly x86 reverse-engineering


【解决方案1】:

Clang 只是意识到这个循环运行了1000000000 次,并且相当于return 1000000000;

我的输出是 -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

这完全删除了循环,只返回1000000000

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

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

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

    return n;
}

输出(再次使用-O3):

        .text
        .file   "test.c"
        .globl  main                    # -- Begin function main
        .p2align        4, 0x90
        .type   main,@function
main:                                   # @main
        .cfi_startproc
# %bb.0:
        movl    $0, -4(%rsp)
        movl    -4(%rsp), %ecx
        movl    -4(%rsp), %eax
        cmpl    $1000000000, %ecx       # imm = 0x3B9ACA00
        je      .LBB0_3
        .p2align        4, 0x90
.LBB0_1:                                # =>This Inner Loop Header: Depth=1
        addl    $1, %eax
        movl    %eax, -4(%rsp)
        movl    -4(%rsp), %ecx
        movl    -4(%rsp), %eax
        cmpl    $1000000000, %ecx       # imm = 0x3B9ACA00
        jne     .LBB0_1
.LBB0_3:
        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

【讨论】:

  • 令我困惑的是,在 Clang 的汇编输出中(如上所示),LBB0_1: cmp dword ptr [rbp - 4], 1000000000; je .LBB0_3 - 正在测试十亿。所以它似乎不仅仅是循环播放。
  • @RTC222 汇编代码中的条件通常与 C 代码相反。如果n 是十亿,则跳转到返回,否则执行循环内容。
  • OP 使用 Intel 语法;如果您在答案中做同样的事情,可能会更清楚。 (我认为-masm=intel 适用于clang,或者只是像普通人一样使用Godbolt 来消除额外指令的噪音。)
【解决方案2】:

您有 3 个案例:

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

    由于您使用add rax,5,因此您只进行了n++ 循环的1/5 迭代。您可以将其视为展开 5,然后将 5 倍 n++ 优化为 n+=5。你可以让这个因子尽可能大,并且让运行时间任意小,直到你像编译器一样到达mov eax, 1000000000

参见the first 2 on Godbolt,我使用了clang -O3gcc -O0。请注意,int n 是标准 x86-64 ABI 中的 32 位变量,因此无需为 64 位操作数大小花费额外的代码大小(REX 前缀)。

请参阅Why are loops always compiled into "do...while" style (tail jump)?,了解为什么高效的简单循环在底部有条件分支而no 是无条件分支。请注意,这就是 gcc 即使在 -O0 处所做的事情(使用 jmp 进入循环到底部的循环条件)。

Clang 在 -O0 上编写了更加幼稚的代码,其结构与 C 相同,顶部是中断条件,底部是无条件的 jmp,就像您的手写 asm。


所以你的 asm 应该比反优化的 C 编译器输出快大约 6 * 5 倍,或者是一半,如果它不能以每次迭代 1 个时钟周期运行你的 NASM 循环.在实践中,您测量的系数为 13.333,非常接近 15。

因此,您可能在 Haswell 之前拥有 Intel 或在 Ryzen 之前拥有 AMD。如果至少有一个分支未被占用,则更新的 CPU 的吞吐量为每个时钟 2 个分支。

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

【讨论】:

  • 因为这是一个从 1s 到 10 亿计数的测试,我消除了循环展开(add rax,5 现在是 add rax,1)所以我的速度是 0.35 而未优化的 Clang 是 1.87,或者由于将变量存储在寄存器中,速度提高了 5.3 倍(而不是循环展开时的 12.5 倍)。
  • @RTC222:Agner Fog 将内存目标add 列为 Sandybridge 上的 6 个周期延迟。因此,对于存储转发延迟与寄存器目标的 1 个周期,这几乎是正确的。我假设您还优化了循环条件,以便您的 NASM 版本每次迭代可以运行 1 个时钟。您测量的是挂钟时间而不是周期,因此对于相对较短的 0.35 秒版本,可能存在一些涡轮增压与空闲时钟效应。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-05-19
  • 1970-01-01
  • 2016-10-21
  • 1970-01-01
  • 1970-01-01
  • 2014-09-09
相关资源
最近更新 更多