JIT 编译

本页目录

JIT 在程序运行期间把某种表示编译成可执行代码。输入可以是字节码、IR 或其他表示;编译单位可以是函数、循环、trace 或模块。是否按首次调用触发、是否后台预编译,由运行时策略决定。

关键权衡是编译成本能否被后续执行收益摊薄。它既需要生成快代码,也需要避免把短命代码优化得过于昂贵。

一个简单的收益模型

设优化编译成本为 C,旧代码每次执行耗时 t₀,新代码为 t₁,预计还会执行 K 次。在忽略其他成本时,只有 K(t₀−t₁)>C 才能净节省时间。

例如编译花 4ms,每次节省 2μs,至少还要执行约 2000 次才抵消编译成本。实际还要考虑 profiling、守卫、代码空间、缓存和反优化成本。这解释了为什么“生成的代码更快”不一定让整个短程序更快。

临界点只适用于这次剩余工作量

4 ms 的编译成本与每次 2 μs 的节省,在 2000 次时只是持平;想获得净收益,需要超过这个次数。热点此前已经执行了多少次不直接进入这笔未来收益账,因为已经花掉的解释时间无法通过本次编译追回。

compile_us = 4000
saved_per_call_us = 2
def gain(remaining_calls):
    return remaining_calls * saved_per_call_us - compile_us
assert gain(1000) == -2000
assert gain(2000) == 0
assert gain(3000) == 2000
# 假设另外支付一次 600 μs 的反优化/重编译成本。
assert gain(3000) - 600 == 1400

实际决策面对的是不确定的剩余执行次数与失效率。优化一个即将结束的循环可能得不偿失;后台编译也会占 CPU 和内存带宽,不能因为编译线程与请求线程不同,就把编译成本从整个服务的资源账中删除。上面只是串行时间预算模型,不是并行编译时墙钟收益的直接公式。

过去已经跑热,能抵消这次编译成本吗?

这里需要比较的是从现在开始的两条策略。过去执行越多,两条总账都会增加同一份沉没成本,所以不会改变未来净收益。下图把过去单列为共同成本,编译投资只与剩余 K 次的节省比较。等号是持平,不是已经盈利;真实系统还需要预测 K,而预测可能错误。

正在呈现知识画面
过去已经跑热,能抵消这次编译成本吗?

已执行部分是共同沉没成本。只用未来节省 K(t₀−t₁) 偿还本次 C;K=2000 持平,超过才有净收益。更热的过去并不保证还有足够未来。

分层:先降低启动成本,再投资热点

层次目标常见工作
解释器快速开始执行解码与分派,收集部分反馈
Baseline 编译器用较低编译成本消除解释开销快速生成机器码,有限优化
优化编译器提高热点代码效率内联、类型特化、冗余消除等

并非所有运行时都具备全部层次,也不保证每个函数逐层经过。策略可以参考调用数、循环回边、代码大小、已有反馈和系统资源;失效或环境变化还可能引起重编译。

以 V8 的公开设计为例,Ignition 是解释器,Sparkplug 是从字节码生成机器码的 baseline 编译器;Maglev 的设计展示了中间优化层如何平衡编译速度与代码质量。这些说明用于理解角色,不把某篇历史文章里的层次配置当作所有版本固定不变的流水线。Sparkplug、Maglev

反馈与内联缓存:记住这个访问点发生过什么

假设同一源码位置反复执行 obj.x。通用属性查找可能需要解释对象布局、属性描述符和继承关系。内联缓存记录该访问点观察到的 shape 等条件,以及对应的快速操作。

推测优化:快速路径必须有合法的失配处理 读取 obj.x 检查 shape / 依赖 命中:固定偏移读取 无需完整属性查找 失配:慢路径或反优化 保持原语言语义 守卫检查对象布局等条件;缓存命中率与具体工作负载有关,没有固定百分比。

单态缓存保留一种形态,多态缓存保留少量形态;观察到很多不同形态后,可以改走适合高多态的查找策略。不同系统使用的结构与阈值不同。

缓存通常与访问点及其上下文关联,不是把“上一次任意对象的类型”作为全局答案。对象布局、原型链或方法定义变化时,需要守卫或依赖失效机制,避免缓存仍指向过时实现。IC 也可以存在于解释器中,并非只有优化机器码才能使用。

推测优化:假设必须显式受保护

JIT 观察到变量多为整数,可以生成整数快速路径,但语言可能允许下一次出现浮点数、字符串或大整数。守卫验证相关前提;失配时进入通用慢路径,或恢复到较低优化层执行。

AOT 也能通过静态分析、PGO、代码版本化与运行时检查使用推测。区别在于反馈获得与代码生成的时机,而不是 AOT 永远不知道运行时信息、JIT 才能优化。

反优化:恢复语言层面的状态

优化会把变量放进寄存器、删除冗余值、内联函数,甚至把一个对象拆成几个标量。当推测失效时,运行时必须重建后续执行需要的逻辑状态:当前字节码位置、局部变量、操作数栈、被内联的调用帧和必要对象。

因此反优化元数据记录如何从当前机器状态恢复较低层表示。一些值可以直接读取,一些需要按记录重新物化。仅保存几个寄存器不足以恢复被内联和标量替换的程序。

恢复点还要考虑副作用。假设已经执行一次扣款,然后守卫失败,不能从函数开头重跑导致再次扣款。反优化必须继续到一个语义正确的位置,不能笼统说“回解释器重做全部操作”。

反优化恢复的是语义状态,不是从头重做函数 已完成副作用 日志 append("begin") 一次 优化循环当前状态 i = 1,total = INT32_MAX 下一次加法守卫失败 当前这一项尚未提交 恢复:从 i = 1 继续,以已有 total 为起点 已执行日志不重做;下一项由通用整数语义计算,不沿用溢出结果 教学模型采用任意精度整数语义与受守卫保护的 int32 快速路径,并非 V8 实现。

一个带副作用的反优化状态模型

设教学语言的整数可任意精度增长,优化版本只处理 int32 范围。下面用 Python 模拟“运算前检查前提,失败后恢复 i 与 total”;它不生成机器码,也不使用 Python 解释器自己的 JIT 行为作为证据。

MIN32, MAX32 = -(1 << 31), (1 << 31) - 1

def resume_generic(values, i, total):
    while i < len(values):
        total += values[i]
        i += 1
    return total

def specialized(values, effects):
    effects.append("begin")
    total = 0
    i = 0
    while i < len(values):
        item = values[i]
        candidate = total + item  # 模型用任意精度先计算守卫条件
        if not (MIN32 <= item <= MAX32 and MIN32 <= candidate <= MAX32):
            snapshot = {"pc": "loop_add", "i": i, "total": total}
            return resume_generic(values, i, total), snapshot
        total = candidate
        i += 1
    return total, None

effects = []
result, snapshot = specialized([MAX32, 1, 2], effects)
assert result == MAX32 + 3
assert snapshot == {"pc": "loop_add", "i": 1, "total": MAX32}
assert effects == ["begin"]
result, snapshot = specialized([10, 20], [])
assert result == 30 and snapshot is None

守卫失败时 i=1,表示 values[1] 尚未加入 total。若恢复成 i=2,会漏算 1;若恢复到函数开头,会再次执行 begin。真实生成器可能使用带溢出标志的机器加法后再分支,但反优化元数据必须能还原正确的原始操作数与恢复位置,不能把已经回绕的机器结果冒充语言结果。

如果这里还内联了另一个函数,低层栈上可能只有一个机器栈帧,逻辑上却需要恢复两个调用帧。若对象已经标量替换,还需物化它并保持所要求的身份关系;“生成一个字段相同的新对象”不总能保持引用相等等语义。V8 Maglev 的设计中,帧状态记录就是连接推测优化与恢复正确性的关键部分。Maglev 反优化

守卫失败后,从哪里继续才不会重复副作用?

恢复状态至少要回答:哪个操作还没做、哪些值已经算好、哪些副作用已经发生。下面故意把“从头重跑”和“跳过当前项”也做成可比较的错误策略。前者最终总和可能正确,却重复 begin;后者效果次数看似正常,却少加一项。只有同时检查值、位置和效果,才能说明反优化接续是否正确。

正在呈现知识画面
守卫失败后,从哪里继续才不会重复副作用?

第二次加法超出 int32,守卫在执行它之前失败。正确恢复 i=1、total=2147483647,并保留已经发生一次的 begin;从头重跑会重复效果,跳过当前项会漏算。

OSR:函数尚未返回就切换执行版本

一个只调用一次、却运行很久的循环,等下次函数调用才用优化代码已经太迟。On-Stack Replacement 在运行中的某个合适位置,把当前活跃状态转移到另一版本的代码。

常见用途是从解释或 baseline 的热循环进入优化代码;一些运行时也用广义的栈上替换描述降级转换。它与反优化共享状态转换问题,但不能简单定义为“反优化的特例”:升层 OSR 恰恰是向更优化的版本转移。

OSR 入口必须说明每个活跃值来自哪里

例如循环已经处理 values[0:3],即将处理下标 3。新代码必须接收当前数组引用、i=3 与已累计的 total;不能沿用普通函数入口的 i=0、total=0 初始化。循环外不再使用的临时值可不转移,但后续需要的值必须有确定来源。

转移信息为什么需要缺失时的结果
恢复/进入的程序位置区分循环头、循环体中间和回边重复或跳过一部分操作
活跃局部变量和操作数继续尚未结束的计算使用错误初始值
对象与 GC 引用位置让收集器仍能找到活跃对象对象被错误回收或更新遗漏
异常与内联调用状态保持抛出、展开和调试语义异常落到错误的处理范围

OSR 编译也可能在生成期间发现假设已经不适用,进入时仍需验证相应条件。热度计数负责决定值得尝试,入口守卫负责确认此刻可以安全切换,两者不能互相替代。

代码缓存:生成之后还要安全发布与回收

JIT 要分配代码空间、处理重定位、登记 GC 与异常展开信息,并按平台要求发布可执行代码及同步指令缓存。它不必先把每个函数写成 .so 再 dlopen;运行时通常可以直接管理内存中的代码及符号。

回收代码之前要确认没有线程仍在执行它,返回地址、函数入口或缓存引用也不能悬空。采用什么分配器、淘汰策略和写执行权限转换,由实现与平台约束决定,不存在所有 code cache 都用 slab 加 LRU 的统一规则。

评估时分开看几个阶段

指标可以暴露的问题
冷启动与首个请求解析、加载和初始编译成本
预热过程升层速度、编译线程竞争
稳态吞吐与尾延迟代码质量、GC、调度与失配
反优化及重编译次数不稳定假设、输入形态变化
代码与元数据空间专用版本膨胀、回收不及时

不要只测预热后的一小段循环,再把结果推广为服务整体性能。AOT 与 JIT 也可以组合,通过缓存、离线 profile 或预编译基础代码减少运行时工作。

相关章节:垃圾回收、链接与加载。

参考

Keywords: JIT, tiered compilation, inline cache, speculation, guard, deoptimization, OSR, code cache, AOT, PGO