12.6 优化正确性与取舍
只有当变换后程序在语言规则下正确表示源程序允许的每种行为,优化才合法。“测试里两边都返回 42”只是证据,不是证明。可观察行为还可能包括 I/O 顺序、volatile 访问、异常、不终止、分配、同步、浮点细节和调试保证。
编译器必须先明确语言契约。有些语言的有符号溢出会 wrap,有些会 trap,还有些将其视为 undefined。浮点表达式可能要求严格 IEEE 行为,除非 fast-math 明确放宽。并发还引入普通代数不能忽略的内存顺序。
前置条件让重写成立
每条重写都应被理解为“规则 + 前置条件”:
x / x → 1它并非普遍正确。x 可能是 0、NaN、无穷,或者本身是一个在源表示中被求值两次的副作用表达式。可靠规则要限定类型,在需要时证明非零与有限,还要保持求值次数。
类似地,把 load 移出循环,需要证明任何迭代都不会写入其 alias,不会有同步改变值,也不会因提前执行而引入新 fault。优化正确性就存在于这些 side condition 中。
差分测试与翻译验证
一种实用防线是差分执行:对同一批生成输入分别运行源 IR 与优化 IR,比较结果及事件轨迹。随机和覆盖引导生成常能找到涉及溢出、异常路径、alias 或副作用的意外反例。
测试无法覆盖无限输入空间。形式化证明、经过验证的 peephole 规则生成器、SMT solver 与翻译验证(translation validation)能提供更强保证。翻译验证不是一次性证明优化器实现,而是验证每一组实际生成的 before/after。每个 pass 后还应运行独立 IR verifier,检查类型、支配、phi 边和 terminator。
fuzzer 找到差异后,应把它约减为仍会失败的最小函数。只有十条指令、一个相关 flag 的反例,远比完整应用容易调试。seed、目标架构、优化流水线和语义模式都应进入回归测试。
优化是多目标问题
即使变换合法,也不一定值得做。编译器要平衡:
- 运行延迟与吞吐;
- 编译时间与内存消耗;
- 代码体积与指令缓存压力;
- 启动时间与峰值性能;
- 调试质量、可复现性与能耗。
Inlining 可以消除调用开销并暴露常量,但会膨胀代码,让后续分析更贵。Unrolling 减少分支,却增加体积与寄存器压力。Vectorization 能加速规则循环,却可能需要运行时检查和标量 fallback。几乎不存在支配所有目标的唯一配置。
优化级别是一套策略
-O0、-O2、-Os 之类优化级别不是质量分数,而是针对使用场景选择 pass、阈值、重复次数与假设的策略。开发构建可能优先快速编译和可预测调试;生产服务器愿意花更多编译时间换吞吐;嵌入式软件则可能优先体积与确定时延。
Profile-guided optimization 会使用观测到的热点路径和调用目标继续特化决策。但 profile 是样本,不是法律:优化程序仍必须对未观测输入保持正确,也应避免对训练数据中的偶然行为过拟合。
严谨的优化流水线会记录每次变换为何触发,在代表性工作负载上测量前后差异,并始终把 legality 与 cost model 分开。正确性是不能越过的边界,性能则是边界之内的经验工程决策。