TT Lab
开始
学习 学习路径 课程

编译器 — 从头到尾亲手打造一门小语言

读懂 -O2 做了什么

在 TT Lab 中继续学习

一句话总结

到这里做出来的各个阶段——解析、语义分析、中间表示、优化、寄存器分配、代码生成——在 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 日志里也原样可见。

在现场相遇的样子

下一项实验要做什么

在 readasm.py 中做出把 gcc 汇编切成按函数划分的指令列表的解析器、指令数与调用列表、找出被折叠成常量的函数、提取除法的魔数、统计向后的跳转(循环的痕迹)、统计 LLVM IR 中按函数划分的指令和指令种类、比较 -O0 与 -O1 的内存指令和 φ。最后,把 opt.c 和你在第 8 个模块里做的代码生成器所生成的 fib 并排数一数,写成报告留下来。