跳到内容

第 11 卷 / 第 8 章 / 第 3 课

8.3 AOT、JIT 与反优化:用运行时信息下注,也要能撤回 ​

分层 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. 分层编译控制启动与峰值 ​

典型但非唯一的层次:

text
解释器或字节码执行
  ↓ 热度阈值
快速基线编译
  ↓ 更多画像
优化编译

阈值太低会编译大量只运行几次的代码,阈值太高又错过热点收益。系统还需决定失效代码何时回收、多个版本是否共存,以及编译线程能占多少 CPU。

AOT 也能使用 profile-guided optimization:先在代表性工作负载采集 profile,再反馈给下一次构建。静态与动态编译不是“有没有画像”的绝对分界。

3. 推测优化需要可验证的保护 ​

假设方法定义与原型关系保持不变,且已按语言规则处理空接收者。若某调用点过去只见到 Point,JIT 可把动态分派特化并内联,用 guard 检查当前输入:

text
if receiver.shape != PointShape:
    deopt
fast_path_for_Point

guard 失败不能返回错误结果。它把执行转到通用版本,或触发重新编译。更激进的推测可能减少快速路径上的工作,也可能增加 guard、代码膨胀和失效成本,收益需要测量。

画像是历史样本,不是语义证明。保护不一定是每次调用都执行的一条 guard:运行时也可登记依赖,在方法、类层次或原型改变前同步使相关代码失效。仅检查对象 shape 不足以保护所有调用目标变化。缺少守卫、静态证明或可靠失效机制的假设,会把性能预测变成正确性 bug。

4. Deoptimization 要重建抽象状态 ​

优化代码可能:

  • 把多个源码变量折叠成一个寄存器;
  • 删除未使用对象分配;
  • 内联多层调用;
  • 把值保存在常量或重计算表达式中。

deopt 时必须根据元数据重建解释器或低层版本需要的栈帧、局部变量和对象。每个可退出点要保存 state map:某个抽象值当前在寄存器、栈槽、常量还是需物化的虚拟对象中。

这也是为什么优化不能只产出机器码;调试、GC、异常和反优化都需要源码与运行状态映射。物化虚拟对象时还要保持对象身份与共享关系,不能随意重新调用有副作用的构造函数。

5. OSR 让正在运行的循环换版本 ​

若一个函数进入超长循环,等函数返回后再使用优化版本没有意义。On-Stack Replacement(OSR)允许在循环中的安全入口把当前执行状态迁移到优化代码。

反向也可能发生:优化假设失效后,从循环中退回通用版本。OSR 映射要处理 live locals、栈值、异常状态和虚拟对象,错误通常只在特定热路径和迭代时机出现。

6. Safepoint 连接编译器与运行时 ​

许多托管运行时在 safepoint 才提供 GC、协作式线程挂起与 deopt 所需的精确栈和寄存器描述。这是具体运行时的协议;并非操作系统只能在这些位置抢占线程,也不是所有 GC 都要求所有工作在 safepoint 完成。编译器为这些位置生成:

  • 哪些位置含对象引用;
  • 内联调用栈;
  • 可恢复局部状态;
  • 返回地址与异常处理信息。

safepoint 太少会增加暂停等待,太多会增加检查和元数据成本。无界纯计算循环可能需要显式 poll,保证运行时最终能取得控制权。

7. Code cache 与 W^X ​

JIT 要写入新代码并执行它。安全实现通常避免同一虚拟映射同时可写和可执行:生成期间使用可写页,发布执行前按平台协议切换权限并同步指令缓存。某些平台使用指向同一物理存储的 RW/RX 双映射;这并不等于可执行物理内容不可写,仍须严格控制可写别名、补丁权限和并发发布。

还要处理:

  • 指令缓存同步;
  • 代码地址重定位;
  • code cache 容量和淘汰;
  • 并发线程是否仍在旧代码中;
  • 生成代码的控制流完整性和沙箱边界。

JIT 扩大攻击面,尤其当 IR 或字节码来自不受信任来源时。验证输入、限制编译资源和隔离可执行内存属于运行时设计的一部分。

8. 编译器正确性需要多层证据 ​

IR verifier ​

每个 pass 后检查结构、类型、SSA 与 CFG 不变量。

差分测试 ​

让同一程序通过解释器、未优化编译器和优化编译器执行,比较结果与可观察行为。参考实现也可能有 bug,因此差异需要归因,不能自动判新版本错误。

随机与变异测试 ​

生成有定义行为的小程序,或对现有程序做保持语义的变换。若输入包含未定义行为,不同输出可能都合法,会制造假阳性。

Translation validation ​

对每次具体转换或整个函数,尝试证明前后 IR 等价或满足精化关系。它不必证明优化器实现永远正确,而是验证这次输出。

回归与性能测试 ​

正确性、编译时间、代码大小、峰值性能、warmup 和内存都要单独门禁。只统计汇编行数不能证明更快。

9. 串起完整编译流程 ​

编译器流水线现在完整了:

text
字符
→ token
→ 语法树
→ 名称与类型
→ IR / CFG / SSA
→ 优化
→ 目标指令与寄存器
→ 可执行代码

真实编译器会来回迭代、保留多层 IR,并与链接器、运行时、调试器和 GC 协作。这条线不是一次性的“翻译”,而是一系列带不变量的语义转换。

常见误区 ​

  • JIT 只编译热点,所以冷代码没有成本:解释、画像和基线代码同样占资源。
  • profile 观察到的类型可以当作永远成立:推测必须配守卫或可靠的依赖失效机制,以及安全退出路径。
  • deopt 只是跳回解释器:还要精确重建已被优化掉的抽象状态。
  • 输出与参考不同就一定错:未定义行为、非确定性和参考 bug 都需排除。

练习 ​

  1. 为单态调用内联设计 shape guard 与 deopt 路径。
  2. 列出一个内联两层函数的 state map 需要保存哪些信息。
  3. 设计同时测量 warmup 与稳态吞吐的基准,避免只报告最佳一次。
  4. 为一个常量折叠 pass 设计 verifier、差分和随机测试。

小结 ​

AOT 和 JIT 的差别在可用信息与成本发生的时间。JIT 用 guard 对运行时规律下注,用 deopt 在规律失效时撤回;OSR、safepoint 和 code cache 则把这种策略落到真实运行时。编译器正确性最终依靠清晰语义、局部验证和多层测试共同守住。

参考资料 ​

Built with VitePress | Software Systems Atlas