编译器能正确地把 Sprout 程序翻译成本机代码了。但"能用"和"够快"是两件事。本篇把编译器和它生成的程序分开测量:前者是开发体验问题——改一行代码要等多久;后者是产出质量问题——生成的二进制跑得有多快。两件事的测量方法、影响因素和优化方向完全不同。

分阶段计时

编译器内部有明确的阶段边界:词法分析、语法解析、名称解析与类型检查、SSA 构造、自制优化、LLVM IR 生成、LLVM 后端处理。在每个阶段入口和出口取一次墙钟时间,差值就是该阶段的耗时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::time::Instant;

let t0 = Instant::now();
let tokens = lex(&source);
let t1 = Instant::now();
let ast = parse(&tokens);
let t2 = Instant::now();
let hir = typecheck(&ast);
let t3 = Instant::now();
// ... SSA, optimize, codegen ...

eprintln!("lex: {:?}", t1 - t0);
eprintln!("parse: {:?}", t2 - t1);
eprintln!("sema: {:?}", t3 - t2);

用墙钟时间(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
2
3
4
5
# 冷启动
hyperfine --warmup 0 --runs 10 'sproutc build programs/bench.spr -o /dev/null'

# 对比热编译(需要编译器支持批量模式或 watch 接口)
sproutc bench --iterations 10 programs/bench.spr

在本系列的规模下,冷启动开销大约 20–40 ms,对于几百行的程序占总编译时间的 15%–30%。工业编译器(如 rustc)的冷启动开销更大,因此才需要增量编译和守护进程。

运行时基准测试

编译器产出的二进制有多快?需要用固定输入的程序来测量,而不是随手写一个递归 Fibonacci。

递归 Fibonacci 几乎只测试函数调用开销和整数加法。它没有内存访问模式、没有数组遍历、没有分支预测压力、没有 I/O。用它来代表"编译器性能"是以偏概全。

选择基准程序应覆盖不同特征:

程序 测量重点 固定输入
sum_to(10_000_000) 循环、标量运算 常量参数
插入排序 10000 个整数 数组随机访问、比较、交换 固定种子伪随机数组
矩阵乘法 200x200 嵌套循环、内存局部性 全一矩阵(结果可验证)
GCD 链式调用 100 万次 函数调用、分支 固定参数序列

每个程序运行足够多次(至少 10 次),报告中位数和 p5/p95 分位值。单次运行的数字没有统计意义——操作系统调度、缓存状态、后台进程都会引入波动。

1
hyperfine --warmup 3 --runs 20 './bench_sum' './bench_sort' './bench_matmul'

hyperfine 自动处理预热、多次运行和统计输出。如果没有 hyperfine,至少手动跑 10 次取中位数,不要只跑一次就下结论。

O0 与 O2 的差距

同一份 Sprout 程序在不同优化级别下编译,运行时间可以差几倍:

1
2
3
sproutc build programs/matmul.spr -O0 -o matmul_O0
sproutc build programs/matmul.spr -O2 -o matmul_O2
hyperfine './matmul_O0' './matmul_O2'

200x200 矩阵乘法的典型数据:O0 约 180 ms,O2 约 45 ms。LLVM 在 O2 下做了循环向量化、指令调度和内存访问优化,对计算密集型代码效果显著。

但编译时间也会增加。O0 下 LLVM 后端可能只需 30 ms,O2 下涨到 90 ms。优化不是免费的——它用编译时间换运行时间。开发阶段用 O0 缩短迭代周期,发布时用 O2 或更高级别。

产物大小

可执行文件包含什么?

1
2
3
4
sproutc build programs/sum.spr -O0 -o sum_O0
sproutc build programs/sum.spr -O2 -o sum_O2
sproutc build programs/sum.spr -Os -o sum_Os
ls -l sum_O0 sum_O2 sum_Os
优化级别 大小 说明
O0 18 KB 未优化,保留所有局部变量的内存操作
O2 14 KB 死代码消除、内联减少了指令数
Os 12 KB 在 O2 基础上进一步压缩体积

Sprout 的运行时很小——只有 print_i64、引用计数和内存分配的薄包装。如果可执行文件异常大,用 nmsize 检查符号表和各段大小:

1
2
nm --size-sort sum_O0 | tail -10
size sum_O0

调试信息(-g)会让文件膨胀数倍,但不影响运行时性能。strip 可以去除符号表和调试段。

与 C 对照

同一算法用 C 写一份,比较编译时间和运行时间,给出上下文参照:

1
2
3
4
5
6
7
8
// sum.c
#include <stdio.h>
int main(void) {
long sum = 0;
for (long i = 1; i <= 10000000; i++) sum += i;
printf("%ld\n", sum);
return 0;
}
1
2
hyperfine 'sproutc build programs/sum.spr -O2 -o /dev/null' \
'cc -O2 sum.c -o /dev/null'

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)比追求绝对毫秒数更有意义。

练习

  1. 编写一个约 1000 行的 Sprout 程序(可以包含多个排序算法和辅助函数),开启 --emit=timing 分阶段计时,找出耗时最长的阶段。提出一个针对该阶段的优化思路,说明预期收益和实现难度。
  2. hyperfine 对比同一程序在 O0 和 O2 下的编译时间与运行时间,画出四个数字的对比表格。
  3. sum_to 同时用 Sprout 和 C 实现,在 O2 下对比运行时间。如果差距超过 10%,用 --emit=llvm-ir 对比两份 IR,找出差异来源。

上一篇:21 - 差分、属性与模糊测试。下一篇:23 - 不完整代码与增量分析