对比Go和C++编译器生成的汇编
09 Nov 2022 -
写代码的时候发现一个有趣的现象,同样的一段循环的逻辑,使用Go语言竟然比C++快不少。 十分简单的代码却有不同的运行速度,怀疑是两个编译器生成了不同的汇编,于是进行深入调查。
1. Go版本
go version go1.19.2 linux/amd64
package main
func main() {
for i := 0; i < 1000000000; i++ {}
}
编译运行
$ go build
$ time ./app
real 0m0.168s
user 0m0.168s
sys 0m0.000s
2. C++版本
g++ (Ubuntu 11.2.0-19ubuntu1) 11.2.0
int main() {
for (long long i = 0; i < 1'000'000'000ull; i++) {}
}
编译运行
$ g++ main.cpp
$ time ./a.out
real 0m0.331s
user 0m0.331s
sys 0m0.000s
3. 分析一
上述代码的运行环境是Win11 22H2 WSL2,Ubuntu 22.04,CPU是i7-12700H,内存32GB。
直接上objdump,分析上面生成的两个elf文件。
需要指出的是,我在编译C++代码时没有用-O选项。
这种简单的循环在开了g++优化选项后会被编译器当作无副作用代码而舍弃掉。
$ objdump -d ./app > app.asm
$ objdump -d ./a.out > main.asm
还原汇编后,把代码拉到最下面,就可以看到main函数真正的样子。
首先是Go编译器生成的汇编。大致意思是,将rax寄存器置0(低32位的eax对自身取异或即可实现),然后反复对rax里面的值加1(inc指令)并且判断这个值是否满足条件(第一次运行直接跳转到457c07行,随后在457c04到457c0d之间循环,直到cmp的结果不会让jl跳转,程序将执行ret退出函数)。这里go编译器把原先代码里的变量i映射为了寄存器中的一个值。
0000000000457c00 <main.main>:
457c00: 31 c0 xor %eax,%eax
457c02: eb 03 jmp 457c07 <main.main+0x7>
457c04: 48 ff c0 inc %rax
457c07: 48 3d 00 ca 9a 3b cmp $0x3b9aca00,%rax
457c0d: 7c f5 jl 457c04 <main.main+0x4>
457c0f: c3 ret
其次是g++生成的汇编。可以看到,C++代码中的变量i作为一个函数局部变量,被放到了当前的栈空间上,用-0x4(%rbp)表示其位置。其余的逻辑,就和Go的汇编一样了。
0000000000001129 <main>:
1129: f3 0f 1e fa endbr64
112d: 55 push %rbp
112e: 48 89 e5 mov %rsp,%rbp
1131: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
1138: eb 04 jmp 113e <main+0x15>
113a: 83 45 fc 01 addl $0x1,-0x4(%rbp)
113e: 81 7d fc ff c9 9a 3b cmpl $0x3b9ac9ff,-0x4(%rbp)
1145: 7e f3 jle 113a <main+0x11>
1147: b8 00 00 00 00 mov $0x0,%eax
114c: 5d pop %rbp
114d: c3 ret
那么,是不是就可以判断上述C++代码比Go代码慢一倍的原因,在于g++编译的时候没有对变量位置进行寄存器优化呢?如果猜想成立,也就意味着当使用g++寄存器优化后,两份代码的运行时间应该相近。
4. 分析二
根据上面的分析,我们需要调整编译参数或者使用代码上的技巧,来使C++编译器把变量放在寄存器中而不是内存里(cache)。不过,我并没有找到相关的优化技巧。反而是C语言支持register关键字可以指导编译器对变量进行寄存器优化。那么让我们先来用C语言试一试。
int main() {
for (register int i = 0; i < 1000000000ull; i++) {}
}
编译运行
$ gcc main.c
$ time ./a.out
real 0m0.237s
user 0m0.237s
sys 0m0.000s
的确快了,但也没有特别快,而且多次运行会出现比较大的波动,在190ms到290ms之间。看一看汇编长什么样。核心部分,将rbx寄存器置0,并且反复加1(add指令)。但是,比较的时候却是先把rbx寄存器里面的值拷贝到rax中,然后判断rax是否满足条件(cmp指令)。
0000000000001129 <main>:
1129: f3 0f 1e fa endbr64
112d: 55 push %rbp
112e: 48 89 e5 mov %rsp,%rbp
1131: 53 push %rbx
1132: bb 00 00 00 00 mov $0x0,%ebx
1137: eb 04 jmp 113d <main+0x14>
1139: 48 83 c3 01 add $0x1,%rbx
113d: 48 89 d8 mov %rbx,%rax
1140: 48 3d ff c9 9a 3b cmp $0x3b9ac9ff,%rax
1146: 76 f1 jbe 1139 <main+0x10>
1148: b8 00 00 00 00 mov $0x0,%eax
114d: 48 8b 5d f8 mov -0x8(%rbp),%rbx
1151: c9 leave
1152: c3 ret
这里每次循环都要多进行一次寄存器拷贝,也挺迷惑的。也许把这个多余的拷贝去掉速度就可以再次提升。这时,单纯写C或C++代码已经不容易引导编译器往我们期望的方向上去做了,也许只有直接写汇编能拯救这段代码了。关于汇编的问题,其实也有很多说法,也就是到底哪些部分需要用汇编替代才能实现更高效的for循环。比如如下代码,其实并不能很好的提升性能。
int main() {
register int i = 0;
while (i < 1000000000ull) {
asm("add $1, %0" : "+r"(i));
}
return 0;
}
关于怎么用gcc内联汇编去提升上面那段代码的性能,以后再写吧。
5. 总结
写最初的那段代码,本来是想用紧循环做出一个微秒级任务,结果发现了不同语言的编译器对于紧循环在处理上的差异。 当然,实际做开发时不太可能出现这种无用的紧循环代码,所以使用C/C++编译器时打开-O通常来讲不会有错。 但如果某个线程真的需要短时间的原地等待,比如大约1微妙的等待,那么C/C++应该有相应的写法, 简单的不加优化选项会导致频繁的缓存访问,不见得优雅。 要用C/C++写出和Go等效的循环,这个问题以后再研究。