统计并读懂 gcc 与 clang 的输出
目标
做出一个把 gcc 的 -S 汇编和 clang 的 LLVM IR 按函数切开来数的工具 readasm.py,并用它以数字确认 -O0 与 -O2(-O1)的差别——内联、常量折叠、除法换乘法、尾递归变循环、mem2reg 与 φ。
为什么重要
不知道优化做了什么,遇到性能问题就只能靠猜,遇到只在 -O2 下出现的 bug 就会去怀疑编译器。直接读输出,两者都能确认。评分器会在评分时重新编译改了常量和除数的变体 C,所以你的工具必须按规则去读,才能通过。
规则
어셈블리(gcc -S) 맨 앞이 글자·_·. 인 'name:' 줄이 이름표. '.' 으로 시작하면 안쪽 이름표(.L3 등),
아니면 함수의 시작. 들여 쓴 줄 가운데 '.' 으로 시작하지 않는 것이 명령. # 뒤는 주석.
명령은 앞뒤 공백을 걷고 명령어와 피연산자 사이 공백을 하나로('movl\t$1, %eax' → 'movl $1, %eax')
functions(asm) {함수: [명령…]} (이름표·지시어 제외) · count(asm, f) 는 그 수(endbr64 포함)
calls(asm, f) call·callq 의 대상(나온 순서), '@PLT' 는 뗀다
returns_constant endbr64 를 뺀 몸체가 [mov(l|q) $값, %eax|%rax ; ret] 이면 그 값, [xor %eax, %eax ; ret] 이면 0, 아니면 None
div_magic(asm, f) f 안 첫 movabs(q) 의 즉시값을 부호 없는 64비트로 본 '0x' + 16자리 소문자 16진수, 없으면 None
back_edges(asm, f) f 안에서 앞서 나온 안쪽 이름표로 가는 j* 명령의 수
IR(clang -emit-llvm) 'define … @이름(' 부터 맨 앞의 '}' 까지. 이름표 줄('3:')·빈 줄 제외, ';' 뒤는 주석
opcode(명령) '%5 = add nsw i64 …' → add · 'store …' → store · tail/musttail/notail 은 건너뛴다
opcode_counts {opcode: 수} · ssa_summary(O0 IR, O1 IR, f) → {"O0": {alloca, load, store, phi}, "O1": {…}}
步骤
- 补全
/root/mini/readasm.py的asm_lines(asm)和functions(asm)。请先做出材料:gcc -O0 -S -fno-asynchronous-unwind-tables /opt/fixtures/mini/real/opt.c -o opt-O0.s(-O2 也一样)。 - 补全
mnemonic、count和calls——能看到 -O2 下square的调用消失了。 - 补全
returns_constant——sum_to、always和never在 -O2 下被折叠成了什么。 - 补全
div_magic——评分器也会用改了除数的变体来看。 - 补全
back_edges——fact在 -O0 下是 call,在 -O2 下是循环。 - 补全
ir_functions、opcode和opcode_counts(clang -O0 -S -emit-llvm …,-O1 也一样)。 - 补全
ssa_summary。 - 把用你的 readasm 读出的值写入
/root/mini/real_report.json:fib_O0和fib_O2(opt.c 中 fib 的指令数)、fib_mini(用第 8 个模块的代码生成器为/opt/fixtures/mini/programs/fib.mini生成的汇编中mini_f_fib的指令数——用python3 mini.py asm生成)、sum_to_O2、div10_magic、fact_O2_calls(调用次数)、fact_O2_back_edges、fib_O1_phi(-O1 IR 中 fib 的 phi 个数)。
参考
- 直到代码生成器为止的文件(
codegen.py、mini.py)在实验开始时已经铺好。 - 用
objdump -d --no-show-raw-insn opt.o,也能看到烧制之后的机器码,形状是一样的(本实验的评分以 -S 输出为准)。 - 常见错误:把标号(.L3:)当作指令来数,不处理制表符,导致文字不同,把魔数写成有符号数,把所有跳转都当作循环来数,把 IR 的标号行当作指令来数。
- 文件名之所以叫 readasm:inspect 是 Python 标准模块的名字,用这个名字保存的话,会把标准模块遮住。
- 会话从 60 分钟开始,可以用+时间延长,结束后
/root/mini会消失。
把汇编切成函数
每一行都先去掉 # 之后的部分,把从行首开始的 'name:' 当作标号。以 '.' 开头的标号是当前函数的内部标号,否则就是新函数的开始。缩进的行里,只有不以 '.' 开头的才是指令,用 split(None, 1) 把空白压缩成一个。
指令数与调用
count 就是 functions 结果的长度(endbr64 也是指令)。calls 收集第一个单词是 call 或 callq 的指令的目标,并去掉 '@' 之后的部分。比较一下 sum_squares 在 -O0 和 -O2 下的调用列表。
折叠成一个常量的函数
只在去掉 endbr64 后,函数体恰好是两条指令、并且第二条是 ret 时才处理。如果第一条指令是 mov(l|q) $值, %eax(或 %rax),结果就是那个值;如果是用 xor 把 %eax 与自己清零,结果就是 0。
用乘法代替除法——魔数
从函数的指令开头往后看,找 movabs(或 movabsq)$数, %寄存器。gcc 用有符号十进制来写,所以要对 2 ** 64 取余,变成无符号数,再用 '0x%016x' 写出来。
递归是否变成了循环
用 asm_lines 依次查看一个函数的各行,把目前出现过的内部标号收集起来。以 j 开头的指令,如果目标是已经出现过的标号,就是向后的跳转。-O2 下的 fact,call 消失了,并且出现了这个数。
把 LLVM IR 切成函数
在 'define … @名字(' 这一行开启函数,在行首为 '}' 的那一行结束。中间的行去掉 ';' 之后的部分,如果是空行或者 '3:' 这样的标号,就去掉。opcode 在有 '=' 时取其后的第一个单词,tail、musttail 和 notail 要跳过。
数出栈格子变成 φ 的过程
对两份 IR 分别求出 opcode_counts,只取 alloca、load、store 和 phi 这四种(没有则为 0)。-O0 的 alloca 在 -O1 下变成 0,并出现 phi,这就是在第 7 个模块里算出的 φ 位置的实物。
把 gcc 与你的编译器并排比较
重新做出材料(gcc -O0/-O2 -S、clang -O1 -emit-llvm、mini.py asm),用你的 readasm 函数计算并写入八个值。不要手工抄数字——评分器会用同样的编译器重新读一遍来对照。