Backstory
考虑一下不同代码写法会不会影响性能?例如:
写法 A
1 | int sum(const std::vector<int> &v) |
写法 B
1 | int sum(const std::vector<int> &v) |
这两种写法在功能上等效。但如果想真正确认哪一种在性能上取胜,则需要使用基准测试 (benchmark)。在这里,写法 B 的性能更好。
与此同时,我们还想知道自己能否承担这种性能提升的代价,这时需要查看编译器输出的汇编结果了。
不要只看汇编结果。我们看不完那么多汇编指令,而且这还是经过编译器优化修改过的产物。如果需要比较,请一并做好基准测试。
x86_64 Assembly 101
寄存器
x86_64 架构下的寄存器有:
rax
,rbx
,rcx
,rdx
,rsp
,rbp
,rsi
,rdi
,r8
-r15
xmm0
-xmm15
rdi
,rsi
,rdx
等用来存储变量rax
用于存储返回值
eax
?在 x86_64 架构中,rax
的长度是 64 位,eax
的长度是 32 位。当数据写入 eax
中时,实际上是将 rax
的高位的 32 个位清 0,仅在低位的 32 个位写入数据。这是一个寄存器相关的技巧。
指令格式
我们使用 Intel 语法格式:
1 | op |
这里的 op
指代任意汇编指令,比如 mov
, call
。dest
和 src
是寄存器或者内存指针,使用 “基址 + 偏移” 表示,即 base + [reg1] + [reg2 * (1, 2, 4 or 8)]
汇编指令与伪 C 代码的对照
汇编
1 | mov eax, DWORD PTR [r14] |
伪 C 代码
1 | int eax = *r14 // int *r14; |
有人会将 edx = 0
肉编译为 mov edx, 0
,但这里却是用 xor edx, edx
。有两个原因:1)mov
中使用的 0 必须以 4 字节存储,体积要大得多;2)指令集已经明白 xor
有清 0 的作用,因此进行对应优化 —— 换句话说,xor
更偏向 “重置”。
Compiler Explorer
最初的 Compiler Explorer 是一个本地 shell 脚本,主要功能部分长这样:
1 | g++ /tmp/test.cc -O2 -c -S -o --masm=intel \ |
然后用 Unix 工具 watch
持续运行脚本,得到近实时编译结果:
1 | sum(std::vector<int, std::allocator<int> > const&): |
后面部署到线上,有了 GUI,可以在 Compiler Explorer 上探索。
通过示例,我们可以清晰地看到写法 A 和写法 B 的汇编区别:写法 B 的汇编明显更短。
本文的示例使用 x86-64 gcc 15.1 -O2 编译这些函数,而原作者使用 x86-64 gcc 8.1 编译,得到的结果会有差异。
通过引入 numeric
库,我们还有写法 C:
1 | int sum(const std::vector<int> &v) |
写法 C 的汇编几乎和 B 相同,只有两个指令的差异 —— 不过是位置的变化罢了。