15.3 JIT 编译流水线
解释器启动非常快,因为它几乎不进行前期的编译工作,但会反复付出指令解码(decode)、分发(dispatch)、类型检查(type-check)与通用操作分派(generic-operation)的运行成本。即时编译器(just-in-time compiler,JIT)在程序运行时观察其执行行为,将选中的热点代码编译为高效率的原生指令。核心要点在于“选中”:若在每个函数第一次被调用时都进行激进的代码编译,那么编译所消耗的时间和内存,可能比直接解释执行该代码所需的开销还要多。
因此,现代运行时(runtime)常采用分层执行(tiered execution)机制。函数首先在解释器(interpreter)中执行;在调用一定次数后,转交给快速的基线编译器(baseline compiler);在积累了更强的证据表明其持续为热点后,才进入优化编译器(optimizing compiler)。运行时可以在各个执行层级(tiers)之间自由移动执行状态,同时仍能完美保留同一个语言级别的执行状态。
运行热度与分层机制把编译变成投资
函数入口(Function entry)与循环回边(loop backedge)上设置的计数器(counters)用于精确估计其执行频率。当计数器达到设定阈值(threshold)后,运行时会将对应的编译任务放入后台编译队列。基线编译器几乎是机械式地翻译字节码(bytecode),快速产生机器码(machine code),这期间也可以插入轻量级的运行时性能剖析(profiling)。优化编译器层级(Optimizing tier)则会构造结构更丰富的中间表示(IR),利用收集到的运行时反馈(runtime feedback)执行函数内联和专门特化(inlining/specialization)、物理寄存器分配(register allocation),并生成垃圾回收(GC)、异常(exception)、调试(debugging)与反优化元数据(deoptimization metadata)。
编译阈值的选定需要小心平衡互相冲突的成本。过低的阈值会编译大量的冷代码(cold code),增加程序启动时间,并增大代码缓存压力(code-cache pressure);过高的阈值则会让高度热门的代码在低效的层级中停顿过长时间。编译任务虽然可以在独立的后台线程运行,但是仍会占用宝贵的 CPU 资源,并且安全地同步更新已安装的代码(code installation)也具有技术挑战。移动端(Mobile)、交互式应用、服务端以及生命周期极短的工作负载(workload)通常需要迥然不同的编译策略(policy)。
一个实用的工程模型会仔细比较一次性的编译方案成本(compilation cost)与每次迭代所能带来的性能节省。若优化编译需花费 2 毫秒,每一次迭代能节省 20 纳秒,就需要大约 100,000 次后续迭代才能收回编译的“投资”成本。真实的系统还必须考虑基线成本(baseline cost)、编译队列延迟(queue delay)、反优化开销、指令缓存效应(instruction-cache effect)与剖析信息准确度(profile accuracy)。
推测(Speculation)带来速度,也必须保留出口
动态类型的程序语言允许同一个操作接触到多种值类型(type)与对象形状(object shape),但在频繁执行的热点位置(hot site),往往只会观察到其中的一个稳定子集。具有优化特性的 JIT 编译器因此可以进行推测(speculation):例如当守护条件(guard)能保证两个操作数都是小整数且不会发生溢出(overflow)时,直接把 x + y 编译为无须装箱的原生整数加法(unboxed integer addition);属性读取操作可先假定接收器形状(receiver shape)为指定的类型,从而直接通过固定的字段偏移量(field offset)完成读取;函数调用则可直接内联运行阶段性能剖析中观察到的唯一目标函数(target)。
然而,每一个如此高效的假设都需要辅以 guard 验证。一旦 guard 校验失败,执行流程就不能继续穿过这些高度专门化的机器码,而必须进入反优化(deoptimization)流程,恢复并将执行转移到等效语言级别的通用解释器/基线执行层级(generic tier)。JIT 编译器会在每个安全点(safepoint)或守护退出点(guard exit)生成详细的反优化记录(deoptimization record),写明如何使用寄存器值、栈槽、常量以及被标量替换优化删除的对象,重建解释器或基线编译器所需的局部变量、操作数栈以及字节码指针位置。
举例来说,标量替换(scalar replacement)优化可能消除了一个生命周期极短的 Point 对象分配(allocation),只将其局部字段值存放在物理寄存器中。若后续的 shape guard 失败,反编译器可能必须在堆中创建并具体化(materialize)一个真正的 Point 对象,然后才能继续恢复状态。内联的函数还需要虚拟栈帧(virtual frames),以确保重建后的堆栈可以同时包含调用者(caller)与被内联的被调用者(inlined callee)。所有这些状态恢复还必须精确遵循异常(exception)与副作用(side effects)的发生边界:已经生效提交的操作直接向后继续,而尚未发生的操作则在故障退出前予以还原。
代码缓存、安全点与失效机制
JIT 生成的机器码保存在受管理的代码缓存(code cache)中。运行时(runtime)分配可写内存用于指令发射(emission),应用重定位(relocations),在特定平台上刷新 CPU 的指令缓存,随后立刻把页面属性变更为可执行(executable),而不是长期保留极具安全风险的“可写且可执行”页面。入口存根(Entry stub)或间接跳转单元(indirection cell)允许运行时以原子方式安装新编译好的代码版本,同时让正在已有代码中执行的线程安全地跑完旧版本代码。
安全点(Safepoint)提供了一个关键的位置:在该位置,线程的执行状态可以被垃圾回收(GC)与运行时精确描述。栈映射(Stack map)标识了各个寄存器或堆栈槽(registers/slots)中哪些是活跃的对象引用(live references);安全点轮询(poll)可以让虚拟机请求 GC、缓存失效(invalidation)、调试挂起或异步中断。移动式对象回收器可能需要动态修补代码(patch code)或使用间接句柄(handles)来移动对象;硬编码在机器码中的对象指针也需要精细的重定位与生命周期管理。
某些全局性假设即使程序没有触发当前局部的 guard 也会在外部失效。比如动态加载了一个新的子类(subclass)可能会打破先前的“该类没有其他子类”的单一继承性假设,从而导致基于去虚拟化优化的方法调用失效;重定义一个方法可能会使得所有已被内联的调用(inlined call)全部报废。运行时会精确记录已编译代码到其设计假设(assumptions)的依赖关系(dependency),一旦假设被打破,就标记受影响的编译后代码为“不可进入”(non-entrant),重定向未来所有的函数入口,并让活动的栈帧(active frame)在到达安全点时强制执行反优化。
JIT 编译器的正确性高度依赖于跨执行层级转换(tier transition)的差分测试与压力测试(differential/stress testing)。测试应当:强制调低编译阈值以模拟高并发编译;在每一个符合条件的控制点都注入并触发反优化(deopt);在编译或安装代码期间高频触发 GC;并发触发全局机制的 invalidation;将编译优化后的结果与参考解释器的执行结果作逐一对比;并给代码缓存(code cache)施加严格的硬性上限。性能测试也应当包含精细的预热曲线(warm-up curve)和稳态性能(steady state)测量;只上报程序预热完成后的最快迭代数据,往往会隐藏用户在冷启动阶段必须支付的巨大编译投资。