从零编写现代编译器 22 - 测量编译与运行性能
编译器能正确地把 Sprout 程序翻译成本机代码了。但"能用"和"够快"是两件事。本篇把编译器和它生成的程序分开测量:前者是开发体验问题——改一行代码要等多久;后者是产出质量问题——生成的二进制跑得有多快。两件事的测量方法、影响因素和优化方向完全不同。
分阶段计时
编译器内部有明确的阶段边界:词法分析、语法解析、名称解析与类型检查、SSA 构造、自制优化、LLVM IR 生成、LLVM 后端处理。在每个阶段入口和出口取一次墙钟时间,差值就是该阶段的耗时。
1 | |
用墙钟时间(wall time)而不是 CPU 时间。编译器是前台交互工具,用户感知的是从按下回车到得到二进制的总延迟,包含 I/O 和系统调度。在负载稳定的开发机上,两者差别不大;如果差别显著,说明有值得调查的 I/O 或调度问题。
对一份约 500 行的多文件 Sprout 程序,典型的阶段分布可能是:
| 阶段 | 耗时 | 占比 |
|---|---|---|
| 词法 + 语法 | 3 ms | 2% |
| 名称解析 + 类型检查 | 8 ms | 6% |
| SSA 构造 + 自制优化 | 44 ms | 35% |
| LLVM IR 生成 + LLVM 后端 | 72 ms | 57% |
LLVM 后端通常是最大的单项开销。这不意外——它负责指令选择、寄存器分配、指令调度和目标文件生成。自制前端相比之下很轻量。但如果前端占比异常升高,说明自制部分有性能问题需要排查。
冷启动与热编译
第一次编译时,LLVM 的 target 初始化、pass 注册和内部数据结构创建都会产生一次性开销。在进程生命周期内的后续编译(例如编译器支持 watch 模式或增量编译)可以跳过这些初始化。
测量方法:先单独跑一次编译,记录从进程启动到完成的总时间(冷启动);然后在同一进程内连续编译同一份源码 10 次,取后 9 次的中位数(热编译)。两者的差值就是 LLVM 初始化的额外成本。
1 | |
在本系列的规模下,冷启动开销大约 20–40 ms,对于几百行的程序占总编译时间的 15%–30%。工业编译器(如 rustc)的冷启动开销更大,因此才需要增量编译和守护进程。
运行时基准测试
编译器产出的二进制有多快?需要用固定输入的程序来测量,而不是随手写一个递归 Fibonacci。
递归 Fibonacci 几乎只测试函数调用开销和整数加法。它没有内存访问模式、没有数组遍历、没有分支预测压力、没有 I/O。用它来代表"编译器性能"是以偏概全。
选择基准程序应覆盖不同特征:
| 程序 | 测量重点 | 固定输入 |
|---|---|---|
sum_to(10_000_000) |
循环、标量运算 | 常量参数 |
| 插入排序 10000 个整数 | 数组随机访问、比较、交换 | 固定种子伪随机数组 |
| 矩阵乘法 200x200 | 嵌套循环、内存局部性 | 全一矩阵(结果可验证) |
| GCD 链式调用 100 万次 | 函数调用、分支 | 固定参数序列 |
每个程序运行足够多次(至少 10 次),报告中位数和 p5/p95 分位值。单次运行的数字没有统计意义——操作系统调度、缓存状态、后台进程都会引入波动。
1 | |
hyperfine 自动处理预热、多次运行和统计输出。如果没有 hyperfine,至少手动跑 10 次取中位数,不要只跑一次就下结论。
O0 与 O2 的差距
同一份 Sprout 程序在不同优化级别下编译,运行时间可以差几倍:
1 | |
200x200 矩阵乘法的典型数据:O0 约 180 ms,O2 约 45 ms。LLVM 在 O2 下做了循环向量化、指令调度和内存访问优化,对计算密集型代码效果显著。
但编译时间也会增加。O0 下 LLVM 后端可能只需 30 ms,O2 下涨到 90 ms。优化不是免费的——它用编译时间换运行时间。开发阶段用 O0 缩短迭代周期,发布时用 O2 或更高级别。
产物大小
可执行文件包含什么?
1 | |
| 优化级别 | 大小 | 说明 |
|---|---|---|
| O0 | 18 KB | 未优化,保留所有局部变量的内存操作 |
| O2 | 14 KB | 死代码消除、内联减少了指令数 |
| Os | 12 KB | 在 O2 基础上进一步压缩体积 |
Sprout 的运行时很小——只有 print_i64、引用计数和内存分配的薄包装。如果可执行文件异常大,用 nm 和 size 检查符号表和各段大小:
1 | |
调试信息(-g)会让文件膨胀数倍,但不影响运行时性能。strip 可以去除符号表和调试段。
与 C 对照
同一算法用 C 写一份,比较编译时间和运行时间,给出上下文参照:
1 | |
1 | |
Sprout 编译时间可能是 cc 的 2–5 倍——主要差距在前端(手写教学编译器 vs 高度优化的产品编译器)。但运行时间的差距应该很小,因为两者最终都经过 LLVM 后端。如果 Sprout 生成的二进制明显慢于 C 版本,说明自制前端的 IR 生成质量有改进空间——可能是缺少某些信息标注(如 nsw)或产生了不必要的内存操作。
测量陷阱
几个常见的错误需要避免:
只跑一次。 单次测量受系统噪声影响极大。至少跑 10 次,看分布而不是单个数字。如果 p5 和 p95 相差超过 20%,说明环境不够稳定或程序行为不确定。
预热不足。 第一次运行可能触发页缓存加载、动态链接器解析。用 --warmup 参数丢弃前几次结果。
把编译时间和运行时间混在一起。 "我的编译器比 GCC 快"可能只是因为生成的代码慢——编译快但运行慢。必须分开报告。
用递归 Fibonacci 代表一切。 已经说过,它只覆盖函数调用和整数加法。实际程序有内存、I/O、分支、字符串操作。用多个不同特征的程序分别测量。
在笔记本电脑上追求绝对数字。 频率调节、散热策略、后台更新都会影响结果。关注相对比较(O0 vs O2、Sprout vs C)比追求绝对毫秒数更有意义。
练习
- 编写一个约 1000 行的 Sprout 程序(可以包含多个排序算法和辅助函数),开启
--emit=timing分阶段计时,找出耗时最长的阶段。提出一个针对该阶段的优化思路,说明预期收益和实现难度。 - 用
hyperfine对比同一程序在 O0 和 O2 下的编译时间与运行时间,画出四个数字的对比表格。 - 把
sum_to同时用 Sprout 和 C 实现,在 O2 下对比运行时间。如果差距超过 10%,用--emit=llvm-ir对比两份 IR,找出差异来源。
上一篇:21 - 差分、属性与模糊测试。下一篇:23 - 不完整代码与增量分析。
