8.3 AOT、JIT 与反优化:用运行时信息下注,也要能撤回
高塔最后一台机器没有一次性把全部代码编到最高级别。冷代码先解释或快速编译,热点积累画像后再优化;如果关于类型和调用目标的推测失效,机器必须回到安全版本继续执行。
本课目标
- 区分 AOT、JIT、分层编译与 PGO;
- 理解 speculative optimization、guard 和 deoptimization;
- 说明 OSR、safepoint 与 code cache 的工程成本;
- 用差分、随机和 IR 验证检查编译器正确性。
1. AOT 与 JIT 使用信息的时间不同
AOT 在部署或运行前生成目标代码。优势通常包括:
- 启动行为可预测;
- 运行时不需要完整编译器;
- 更容易控制可执行内存与部署产物;
- 可花较长时间做全程序或链接期优化。
JIT 在运行过程中编译,可观察:
- 函数和循环热度;
- 实际接收者类型;
- 分支概率;
- 调用目标与对象形状;
- 当前硬件能力。
JIT 同时付出 warmup、编译线程、画像内存、code cache、可执行内存安全和性能波动成本。不能笼统说 JIT 比 AOT 更好。
2. 分层编译控制启动与峰值
典型但非唯一的层次:
解释器或字节码执行
↓ 热度阈值
快速基线编译
↓ 更多画像
优化编译阈值太低会编译大量只运行几次的代码,阈值太高又错过热点收益。系统还需决定失效代码何时回收、多个版本是否共存,以及编译线程能占多少 CPU。
AOT 也能使用 profile-guided optimization:先在代表性工作负载采集 profile,再反馈给下一次构建。静态与动态编译不是“有没有画像”的绝对分界。
3. 推测优化必须有守卫
若某调用点过去一直看到类型 Point,JIT 可暂时把动态分派特化为 Point 的直接调用并内联,但要插入 guard:
if receiver.shape != PointShape:
deopt
fast_path_for_Pointguard 失败不能返回错误结果。它把执行转到通用版本,或触发重新编译。推测越激进,快速路径越短,但 guard、代码膨胀和失效频率也可能更高。
画像是历史样本,不是语义证明。没有 guard 的 profile 假设会把性能预测变成正确性 bug。
4. Deoptimization 要重建抽象状态
优化代码可能:
- 把多个源码变量折叠成一个寄存器;
- 删除未使用对象分配;
- 内联多层调用;
- 把值保存在常量或重计算表达式中。
deopt 时必须根据元数据重建解释器或低层版本需要的栈帧、局部变量和对象。每个可退出点要保存 state map:某个抽象值当前在寄存器、栈槽、常量还是需物化的虚拟对象中。
这也是为什么优化不能只产出机器码;调试、GC、异常和反优化都需要源码与运行状态映射。
5. OSR 让正在运行的循环换版本
若一个函数进入超长循环,等函数返回后再使用优化版本没有意义。On-Stack Replacement(OSR)允许在循环中的安全入口把当前执行状态迁移到优化代码。
反向也可能发生:优化假设失效后,从循环中退回通用版本。OSR 映射要处理 live locals、栈值、异常状态和虚拟对象,错误通常只在特定热路径和迭代时机出现。
6. Safepoint 连接编译器与运行时
垃圾收集、线程挂起和 deopt 往往只能在 safepoint 精确理解栈和寄存器。编译器为这些位置生成:
- 哪些位置含对象引用;
- 内联调用栈;
- 可恢复局部状态;
- 返回地址与异常处理信息。
safepoint 太少会增加暂停等待,太多会增加检查和元数据成本。无界纯计算循环可能需要显式 poll,保证运行时最终能取得控制权。
7. Code cache 与 W^X
JIT 要写入新代码并执行它。安全实现通常遵循 write xor execute:内存页不同时可写和可执行,生成后切换权限,或使用双映射等平台机制。
还要处理:
- 指令缓存同步;
- 代码地址重定位;
- code cache 容量和淘汰;
- 并发线程是否仍在旧代码中;
- 生成代码的控制流完整性和沙箱边界。
JIT 扩大攻击面,尤其当 IR 或字节码来自不受信任来源时。验证输入、限制编译资源和隔离可执行内存属于运行时设计的一部分。
8. 编译器正确性需要多层证据
IR verifier
每个 pass 后检查结构、类型、SSA 与 CFG 不变量。
差分测试
让同一程序通过解释器、未优化编译器和优化编译器执行,比较结果与可观察行为。参考实现也可能有 bug,因此差异需要归因,不能自动判新版本错误。
随机与变异测试
生成有定义行为的小程序,或对现有程序做保持语义的变换。若输入包含未定义行为,不同输出可能都合法,会制造假阳性。
Translation validation
对每次具体转换或整个函数,尝试证明前后 IR 等价或满足精化关系。它不必证明优化器实现永远正确,而是验证这次输出。
回归与性能测试
正确性、编译时间、代码大小、峰值性能、warmup 和内存都要单独门禁。只统计汇编行数不能证明更快。
9. 塔顶收束
编译器流水线现在完整了:
字符
→ token
→ 语法树
→ 名称与类型
→ IR / CFG / SSA
→ 优化
→ 目标指令与寄存器
→ 可执行代码真实编译器会来回迭代、保留多层 IR,并与链接器、运行时、调试器和 GC 协作。这条线不是一次性的“翻译”,而是一系列带不变量的语义转换。
常见误区
- JIT 只编译热点,所以冷代码没有成本:解释、画像和基线代码同样占资源。
- profile 观察到的类型可以当作永远成立:推测必须配 guard 和退出路径。
- deopt 只是跳回解释器:还要精确重建已被优化掉的抽象状态。
- 输出与参考不同就一定错:未定义行为、非确定性和参考 bug 都需排除。
练习
- 为单态调用内联设计 shape guard 与 deopt 路径。
- 列出一个内联两层函数的 state map 需要保存哪些信息。
- 设计同时测量 warmup 与稳态吞吐的基准,避免只报告最佳一次。
- 为一个常量折叠 pass 设计 verifier、差分和随机测试。
小结
AOT 和 JIT 的差别在可用信息与成本发生的时间。JIT 用 guard 对运行时规律下注,用 deopt 在规律失效时撤回;OSR、safepoint 和 code cache 则把这种策略落到真实运行时。编译器正确性最终依靠清晰语义、局部验证和多层测试共同守住。
你走出咒术高塔时,馆长没有说“你懂得所有编译器了”。他只把一张新的路线图交给你:下一卷的数据预言厅会处理另一种输入——不再是必须严格合法的程序,而是缺失、偏斜、带噪声且不断变化的数据。