跳转到内容

编译器优化

优化的正确性约束只有一条:不能改变程序的可观察行为。本章讲局部优化、循环优化与过程间优化,以及未定义行为在其中的角色——它既是优化的许可证,也是 C 程序 bug 的温床。

所有优化都要满足可观察行为等价:程序的可观察行为(输出、终止、读写外部可观察状态)不变。这正是"承诺不变、机制自由"(→ 从晶体管到应用:抽象层次)在编译器里的体现:语义是承诺,指令序列是机制。

约束的例外是未定义行为(UB):标准不定义其后果,编译器可以假设它不发生。这既是优化的许可证(删除"不可能"的路径),也是 bug 的温床(见下文)。

基本块内的改写,数据流分析(到达定义、活跃变量)驱动:

  • 常量折叠:编译期算出常量表达式(2*3.14 → 6.28);
  • 常量传播:已知值为常量的变量直接代入;
  • 公共子表达式消除(CSE):相同表达式只算一次(SSA 下变成直接的问题,→ 编译流程);
  • 死代码消除:不影响输出的计算删除;
  • 强度削减:贵的运算换便宜的(乘 2 的幂 → 移位)。

循环是程序的热点,优化收益最大:

  • 不变量外提(LICM):循环内不变的计算移到循环外;
  • 展开(unrolling):减少循环控制开销、暴露指令级并行(→ 流水线、冒险与乱序执行);
  • 自动向量化:把标量循环改成 SIMD 指令(AVX 一次算 8 个 float)——前提是编译器能证明无依赖:别名未知的指针循环无法向量化;
  • 循环交换与分块(tiling):改变遍历顺序改善缓存局部性(→ 存储层次与缓存)——分块是 BLAS 一类线性代数库快的原因。

别名分析(alias analysis)限制了很多优化:两个指针可能指向同一内存时,编译器不敢重排读写。C 的 restrict 关键字是程序员的"承诺不别名";Rust 的借用规则让编译器默认可证明(&mut 独占),这是它对优化的实际贡献。

  • 内联(inlining):把被调函数展开到调用点。内联是其他优化的使能者——不内联,跨函数的常量传播、死代码、向量化都看不到机会。代价是代码膨胀(指令缓存压力);现代编译器按"调用频率 × 受益 × 大小"启发式决定。
  • 跨模块优化(LTO):链接期做过程间优化,让内联跨过模块边界。

C/C++ 的 UB(有符号溢出、越界、解引用空指针、移位过量)给编译器"假设不发生"的许可证:

ub.c
int overflow_check(int x) {
return x + 1 > x; /* 编译器:有符号溢出是 UB,
所以 x+1 > x 恒真,优化成 return 1 ——
这个"溢出检查"被删掉了 */
}
ub2.c
if (p != NULL) *p = 1; /* 解引用暗示 p 非空,
编译器可把判空删掉 */

未定义行为是编译器优化的许可证,也是 C 程序 bug 的温床。工程纪律:用 -fsanitize=undefined 在测试中捕获 UB;写 C 时明确假设;新项目考虑内存安全语言(→ 内存安全与常见漏洞)。

-O3 有时更慢的原因都有名字:

  • 代码膨胀:激进展开与内联撑爆指令缓存(L1i 未命中的代价比省下的指令贵);
  • 向量化对齐:假设不对齐时插入额外代码;
  • 别名假设:过于保守的优化白白增加检查;
  • 编译期换运行期:PGO(基于剖析的优化)是解法——用真实 profile 指导内联与分支布局。
  • 优化的唯一约束:不改变可观察行为;UB 是"许可证",也是 bug 温床
  • 局部优化由数据流分析驱动;SSA 让 CSE 等变成直接的图问题
  • 自动向量化与循环交换的前提是"能证明无依赖";别名分析是主要限制
  • 内联是其他优化的使能者;代价是指令缓存压力
  • -O3 有时更慢:代码膨胀、对齐假设、别名保守——用 PGO 与真实 profile 校准