16.5 真实编译器中的实用折中
在真实世界中,数学上根源的不可判定性边界,并不是为了阻碍编译器架构师(compiler engineer)去积极分析复杂的程序,而是强硬地要求我们在设计分析机制(analysis)时:诚实说明该机制做出了哪些契约承诺、可能在哪些物理边界下丢失精度、被允许耗费多少系统资源,以及在计算不可判定时应当如何安全处理。一个优秀的生产级编译器,通常会精妙地组合合理的语法限制(restriction)、数学上的过近似(approximation)、运行时性能剖析(profile)、形式化数学证明(proof)、全方位差分测试(test)与动态运行时检查(runtime check),让各个维度的机制完美覆盖彼此的理论盲区。
实用的系统方案设计必须始终从系统消费者(consumers)的真实关切出发。例如,代码优化器(Optimizer)绝对无权改变规范中已定义行为的精确语义,因此其合法性判定分析(legality analysis)必须始终恪守最保守的底线:凡是无法绝对排除存在别名指针(aliasing)干扰的地方,就必须一律无条件拒绝执行内存读写重排。而问题警告工具则常常偏向于提供更少的假阳性误报(false positive),从而避免开发者因陷入疲劳而彻底忽略了由于精度丢失给出的宝贵警告;安全性校验器(verifier)则刚好相反,它完全能够接受适当的误报,但必须强硬地拒绝一切未被数学严格证实的存疑代码。在开发环境中,IDE 需要毫秒级(millisecond)的极速反馈,因此可以退而求其次地使用增量近似计算(incremental approximation),稍后在后台或持续集成中再由功能稳健的批处理静态分析器(batch analyzer)精确精化。
精度、成本与安全保证(Guarantee)是独立的控制项
在静态分析的控制理论中,我们有四个独立的控制项:
- 流敏感性(Flow sensitivity):负责区分同一段代码中不同内部程序点(program points)上的状态变化;
- 路径敏感性(Path sensitivity):负责区分不同分支判定条件(branch conditions)下的内存取值差异;
- 上下文敏感性(Context sensitivity):负责区分来自各个不同函数调用点(call sites)的入口执行上下文;
- 字段敏感性(Field sensitivity):负责区分同一个复合对象中不同物理字段值(object fields)之间的独立性。
在工程实践中,提高上述任何一个分析维度都能够显著改善最终的分析精度(precision),但在最坏情况下这也同样会成倍、甚至呈几何级数增加底层的计算状态数目与内存开销(states and memory)。为此,我们必须聪明地引入堆抽象设计(Heap abstraction)、限制迭代的加宽算子(widening)、函数行为总结(function summary)以及状态合并(state merge)等手段来强行控制分析开销,代价是损失一部分关联精度(precision)。
时间与内存的开销范围限制(Time/memory budget)本身就是编译器分析契约(contract)中最重要的不可分割项。一个分析遍(pass)可以限制最大迭代次数(iterations)、一旦达到设定的阈值(threshold)便执行强制加宽(widen)、对大型被调用者(callee)采取保守的直接总结而不做深入分析、限制符号探索路径(symbolic paths)数的上限,或者在 SMT 求解器超时(solver timeout)发生时果断返回不确定(unknown)。分析器绝对不能在内部静默地把底层由于资源耗尽(resource exhaustion)引发的超时,自作聪明地直接等同为“已经通过了安全证明(proof)”。代码优化器总是可以安全地选择跳过该项代码变换优化(transformation);安全性校验器(verifier)选择诚实地上报当前存在未得到证明的约束义务(unproved obligation);而诊断信息(diagnostic)也应当清晰地指出分析由于性能超限而未能顺利完成。
某些工具为缺陷发现(bug finding)使用乐观的默认假设(optimistic assumptions):比如将不透明的外部调用一律假定为无副作用的纯净函数(pure);在路径探索时优先探索概率极高的一侧路径(likely path);或者简单忽略微乎其微的别名情况(alias cases)。只要这类缺陷报告配备了清晰的置信度标签,且工具本身不做出百分之百完美的可靠性声明(soundness),这在工业界工程实践中便同样具备极高的实用价值;但这套乐观的默认假设绝对不能在底层作为代码优化器去粗暴删除安全数组边界检查(bounds check)的合法性硬安全依据(legality)。
分层保证(Layered Assurance)胜过想象中的完美工具
在真实的编译器工程中:
- 静态类型系统(Type system):能以极低成本在编译初期消除大量格式错误或语义非法的代码(malformed program);
- 数据流分析(data-flow analysis):快速捕获并修复局部的变量误用,并支撑核心的底层优化;
- 抽象解释器(abstract interpreter):在循环边界证明特定的安全状态不变量(invariant);
- 有界符号执行(bounded symbolic execution):在有界深度内强力探索深度的并发执行路径;
- 模糊测试(fuzzer):自动生成具体的对象及指令组合,寻找编译器崩溃(crash)的真实物理反例;
- 运行时检测工具(sanitizer):在实际可观察的执行链上发现潜在的内存违规或未定义行为;
- 运行时守卫检查(runtime guards):对于静态推理无法安全删除的潜在越界行为提供底层的最后兜底;
- 差分测试(test):对比不同的编译器层级所产生目标机器码的可观察一致性;
- 生产环境遥测数据:暴露线下各种模拟及测试套件中无法预见的实际用户流量分布与偶发故障。
这些保护维度(layers)在底层是完美互补的,而不是非此即彼的。模糊测试(Fuzzing)不保证形式化的完备性证明,却能产生最直观、可复现的代码反例;可靠的静态分析警告可能由于多变量的相互制约在底层无法给出具体的运行轨迹,但结论依然安全有效;运行时动态检查在真实故障点极其精确,但也同样需要支付不可忽视的性能开销,且只能覆盖实际在物理中运行过的路径;而形式化证明(proof)虽然其数学说服力极高,但是在超出其环境建模假设范围(例如底层未建模的物理硬件故障或外部未知动态库链接)时仍然会完全失效。
让假设(Assumptions)、失效契约(Invalidation)与安全回退(Fallback)显式化
每一个底层的静态分析遍都极其依赖于特定的底层环境假设(environment model)。例如在全程序编译(Whole-program compilation)下可以大胆假设整个程序的函数调用集合是完全封闭且完备的,而运行时的动态链接(dynamic linking)则会无情破坏该不变量;去虚拟化优化(devirtualization)高度依赖于对类继承结构完备性(class hierarchy completeness)的假设;JIT 编译器生成的特化机器码(JIT code)依赖于目前已观察到的属性形状(observed shape),一旦对象运行中由于类型改变而发生突变(mutation),就必须强制触发反优化(deoptimize)回退。增量编译器(incremental compiler)会大范围缓存其分析出的中间结果分析事实(facts),但是一旦底层的源文件内容、编译标志位(flags)、项目依赖结构、目标 CPU 特性或底层 ABI 约定发生任何微小改变,对应的缓存条目就必须立刻被标记失效(invalidation)。
优秀的系统架构应当将上述设计假设(assumptions)明确表达为带有图依赖边(dependency edges)的结构化底层元数据,而不是将其记录在难以被机器读写的注释(comment)中。例如,一个高级变换证明记录(Optimization proof record)可以明晰地记录究竟是哪一个别名指针特征(alias fact)、变量值范围(range fact)、性能剖析数据(profile)以及平台语法不变量(language rules)共同支撑了当前的优化变换(transform)。一旦其中任何一个依赖因子(Dependency)发生失效或变动,相关的优化段便能由引擎立刻丢弃或重新核查。此外,编译器架构还必须永远保留极具安全底线的安全回退路径(fallback):例如当证明失效时使用通用的慢速方法调用(generic call)来直接代替高风险的内联(inlining),或者使用运行时动态数组边界检查来替代边界求值证明,或直接切换高可靠性的保守目标代码生成(conservative codegen)来替代激进的高风险优化变换(risky transform)。
编译器静态分析与优化诊断信息(Diagnostic)也应当非常明确地告诉用户当前的“分析局限范围(scope)”。与其在终端输出一条让人莫名妙且不知所云的模糊安全警报(alarm),不如诚实地告诉用户:“当前可能存在空指针解引用风险(null dereference);由于外部静态分析器(analyzer)无法建模复杂的多线程异步回调函数(callback f)”。明确细分:确定存在的物理漏洞(definite)、怀疑可能的存疑点(possible)、已被主动抑制的安全问题(suppressed)、计算超时造成的分析中断(timed out)以及编译器暂不支持的高级特性(unsupported),并主动为开发人员提供包含源码溯源路径、具体复现反例、推导出的状态不变量以及特定环境配置在内的完整溯源链(source trace、counterexample、inferred invariant 与 configuration),帮助其快速开展本地复现与安全编辑。
同时验证分析流程的正确性与实用性
在实践中,我们应当聪明地结合:极小规模的手动形式化正例(hand-proved cases)、蜕变变换测试(metamorphic transformation)、差分判定器(differential oracle)、随机生成的大型代码样本(randomized program)、由于求解器超时的极端破坏测试(solver timeout)、针对路径爆炸的恶意攻击用例(adversarial path growth)以及暂不支持的边界语言特性(unsupported-language feature)来全方位差分测试各种分析过程(analysis)。代码优化器的优化逻辑可以使用翻译校验(translation validation)或 Alive 风格的代码转换规则检查(Alive-style rule checking)来进行严密检验;安全性校验器(verifier)则可以刻意植入已知的历史缺陷用例与绝对安全的常规代码,来精确统计该机制真实的漏报率与误报率(false negative/positive)。性能监控也应当全方位跟踪长尾优化的分布特征(performance distribution),而不能只盯着全局平均时间耗时(average),因为往往一个病态怪异的大函数(pathological function)便会直接主导甚至拖垮整个大型项目的编译运行吞吐(build latency)。
质量度量指标(Metric)同样需要处于清晰的语境(context)之下。“95% checks 已证明”不能说明剩余 5% 是否集中在关键代码;“零 bug report”也可能表示 coverage 很低。应按 component、severity、exposure 与 failure consequence 建立 risk model。High-assurance code 可能需要 restricted language subset、review、formal contract、reproducible build 与 runtime containment;普通 application code 可选择更快反馈。
面对 undecidability 的成熟回应不是悲观,而是工程诚实:选择有价值的 decidable fragments;正确性要求高时使用 conservative approximation;只需 witness 时使用 heuristic search;按风险分配资源;证据耗尽时报告 unknown。真实 compiler 正是这样兼具力量与可信度。