对比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等效的循环,这个问题以后再研究。