同一个C程序的三个版本,为什么第一个这么快?
下面是一个非常简单的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_
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 vs1.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,然后将5xn++
优化为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,然后将5xn++
优化为n+=5
。您可以将该因子设置为任意大,并将运行时间设置为任意小,直到达到moveax,10000