读懂 -O2 做了什么
一句话总结
到这里做出来的各个阶段——解析、语义分析、中间表示、优化、寄存器分配、代码生成——在 gcc 和 clang 里也原样存在。把它们的输出(-S 的汇编、-emit-llvm 的 LLVM IR、objdump -d 的机器码)按函数切开来数一数,优化做了什么——是不是把调用融化掉了、是不是把循环折叠成了一个常量、是不是把除法换成了乘法、是不是把递归变成了循环——就会用数字呈现出来。
为什么需要它
知道“用 -O2 构建会变快”,却不知道什么变快了,遇到性能问题就只能靠猜。反过来,遇到“只有在 -O2 下结果才奇怪”的 bug,在怀疑编译器之前,有一件事可以先确认——这段代码是不是依赖了未定义行为。两种情况的答案,都是直接去读编译器的输出。本模块把这种读的眼力,接在前面九个模块里亲手做过的东西上。
工作原理
材料 /opt/fixtures/mini/real/opt.c 里的每个函数,各自瞄准一种变换。
| 函数 | -O2 做的事 | 汇编中看到的东西 |
|---|---|---|
sum_to |
把从 1 加到 100 的循环整个算出来 | movl $5050, %eax · ret 两行 |
sum_squares |
把 square 的调用融进去(内联) |
call square 消失了 |
div10 |
把除法(idiv,几十个周期)换成乘法和移位 | movabsq $7378697629483820647 · imulq · sarq |
fact |
把尾递归变成循环 | call fact 消失,出现了向后的跳转 |
always |
把 x + 1 > x 当作恒为真(假定有符号溢出不会发生) |
movl $1, %eax · ret |
fib |
把递归部分展开,并把寄存器全部用上 | 指令数有时会超过 -O0 的十倍 |
除法的魔数。x / 10 等于 x × 0x6666666666666667 ÷ 2^66(再加一次负数修正)。0x6666…67 是把 2^66 / 10 向上取整得到的数。乘法只要几个周期,除法却要几十个周期,所以当除数是常量时,编译器几乎总会这样改。换一个除数重新编译,魔数也会变——所以本实验的评分器会生成变体来试。
未定义行为与优化。always(int x) { return x + 1 > x; } 在 x 为 INT_MAX 时会溢出,而在 C 里,有符号整数的溢出是未定义行为。编译器可以假定“这种事不会发生”,所以把整个表达式折叠成 1。无符号的 uwrap 不能这样做,因为溢出被定义为回绕,所以实际的比较会保留下来。mini 之所以把溢出定义为“回绕”,原因就在这里——定义好之后,解释器、VM 和原生代码就必须给出同样的答案,优化器也不能改变这个答案。
LLVM IR 与 SSA。clang -S -emit-llvm 会把 LLVM 的中间表示用文本显示出来。在 -O0 下,每个局部变量都是一个 alloca(栈格子),每次使用都是 load,每次修改都是 store——和第 8 个模块的代码生成器是同一种方式。在 -O1 下,这些格子消失了(mem2reg、SROA),并在两条路径汇合的地方出现了 phi——正是第 7 个模块里用支配边界算出位置的那个 φ。
-O0: fib %2 = alloca i64 -O1: fib %6 = phi i64 [ … ], [ … ]
store i64 %0, ptr %3 (alloca·load·store 없음)
%4 = load i64, ptr %3
JIT 做的也是同样的事。JVM 的 JIT(HotSpot C2)和浏览器的 V8,会在运行中跑这些阶段。区别只在于,输入里多了运行中看到的事实(这个调用点总是传来这种类型),所以本模块中读到的变换——内联、常量折叠、循环变换——在 JIT 日志里也原样可见。
在现场相遇的样子
- Compiler Explorer 与
objdump -d。在性能评审中,确认“这个循环有没有被向量化”“这个函数有没有被内联”,最快的办法就是读输出。objdump -d --no-show-raw-insn 실행파일(占位符为可执行文件名)对已经烧制好的二进制文件也能显示同样的内容。 - 只在 -O2 下出现的 bug。有符号溢出、未初始化的变量、违反指针别名规则,在 -O0 下碰巧是对的,在 -O2 下就错了。先用
-fwrapv(把有符号溢出定义为回绕)或-fsanitize=undefined来确认。 - 构建选项就是契约。修改发布二进制文件的
-O级别,应当与修改代码同等对待。同样的源码,指令数可能相差十倍,未定义行为的结果也会改变。
下一项实验要做什么
在 readasm.py 中做出把 gcc 汇编切成按函数划分的指令列表的解析器、指令数与调用列表、找出被折叠成常量的函数、提取除法的魔数、统计向后的跳转(循环的痕迹)、统计 LLVM IR 中按函数划分的指令和指令种类、比较 -O0 与 -O1 的内存指令和 φ。最后,把 opt.c 和你在第 8 个模块里做的代码生成器所生成的 fib 并排数一数,写成报告留下来。