深度解析塑造未来的技术文章。

JIT 编译器如何让动态语言变快

JIT 编译器如何通过消除冗余操作优化 Ruby、Python、JavaScript 等动态语言,并结合 YJIT 与 ZJIT 的实例讲解其原理。

一只 Ruby 宝石进入发光的机器,化作一道飞速划过的光之彗星

Ruby 一直被认为很慢。Python 也是。JavaScript 也曾如此,直到 V8 把它提速到足以承担服务端负载。动态语言如何变快,背后的故事就是 JIT(Just-In-Time,即时编译)的故事,也是实践计算机科学中最迷人的领域之一。最新一章是:Ruby 的 ZJIT 正在中间表示(IR)层面消除冗余的对象加载和存储,这与让 V8 的 TurboFan 如此高效的那类优化一脉相承。

如果你曾好奇为什么 Ruby 代码跑起来只有 C 的一小部分速度,或者 JavaScript 是怎么快到能支撑 VS Code 的,答案就在于理解 JIT 编译器究竟做了什么,以及为什么优化动态语言从根本上比优化静态语言更难。

动态语言的根本难题

C 编译器看到 a + b 时,编译期就知道 a 和 b 的类型。如果都是整数,它就生成一条 ADD 指令;如果是浮点数,就生成浮点加法。CPU 一个周期就能执行完毕。没有歧义,运行时也无需任何决策。

Ruby 解释器看到 a + b 时几乎一无所知。a 可能是整数、浮点数、字符串、数组,或任何定义了 + 方法的对象。解释器必须检查 a 的类型,为该类型找到 + 方法,检查 b 的类型,必要时进行类型转换,处理各种边界情况(溢出、冻结对象),最后才执行运算。这一个 + 可能涉及几十条指令、内存查找和分支判断。

# What the programmer writes:
def sum(a, b)
a + b
end
# What the interpreter actually has to do (pseudocode):
def sum(a, b)
method = lookup_method(a.class, :+)      # Hash lookup
raise NoMethodError unless method         # Branch
if a.is_a?(Integer) && b.is_a?(Integer)   # Type checks
result = integer_add(a.value, b.value)  # Actual math
if overflow?(result)                     # Overflow check
result = promote_to_bignum(result)    # Bignum promotion
end
elsif a.is_a?(Float) || b.is_a?(Float)
result = float_add(to_float(a), to_float(b))
elsif a.is_a?(String)
result = string_concat(a, b.to_s)
else
result = call_method(method, a, [b])    # Generic dispatch
end
result
end

这些开销(类型检查、方法查找、分派)就是动态性的代价。能写出 a + b 并让它同时适用于整数、浮点数、字符串和自定义对象,这份灵活性在运行时是很昂贵的。

JIT 编译如何反击

JIT 编译器的工作,是观察程序在运行时的实际行为,并根据这些观察生成优化过的机器码。关键洞察在于:Ruby 代码可以操作任意类型,但在实践中,任何一个调用点通常只会遇到相同的类型。

如果 sum(a, b) 被调用了 10,000 次,而 a 和 b 一直都是整数,JIT 就能生成专门的机器码,假定它们今后依然是整数。它会发出一条整数加法指令,外加一个“守卫”(guard),也就是一个快速的类型检查,一旦假设被打破,就跳转到慢速路径。

Interpreter execution of sum(a, b) where a and b are Integers:
1. Load a from stack
2. Check if a is an object reference
3. Load a's class pointer
4. Look up :+ in method table (hash lookup)
5. Check method visibility
6. Load b from stack
7. Check if b is an object reference
8. Check if b is compatible type
9. Unbox a's integer value
10. Unbox b's integer value
11. Perform addition
12. Check for overflow
13. Box result as new Integer object
14. Return result
JIT-compiled version (after profiling shows a,b are always Integers):
1. Guard: check a is Integer (bail if not)
2. Guard: check b is Integer (bail if not)
3. ADD instruction
4. Guard: check no overflow (bail if overflow)
5. Return result

这相当于把一个 14 步的过程压缩成 5 步,其中第 1、2、4 步都只是单条比较指令。真正的计算 ADD 只需一个 CPU 周期。JavaScript 正是这样从“慢到没法做正经事”变成“快到能跑完整 IDE”的。

Ruby 的 JIT 之路:从 MJIT 到 YJIT 再到 ZJIT

Ruby 的 JIT 历史本身就是这个难题有多难的一个案例。Ruby 经历过多种 JIT 实现,每一种都采取了不同的思路。

MJIT(Ruby 2.6,2018)把 Ruby 字节码翻译成 C 代码,再调用 GCC 或 Clang 编译。生成的代码优化得很好,但预热时间极长,编译 C 需要几秒钟,而不是几毫秒。等 JIT 编译的代码准备好时,程序可能早已执行完毕。

YJIT(Ruby 3.1,2022)是 Shopify 的贡献,最初用 C 编写,后来用 Rust 重写。YJIT 采用了一种叫“惰性基本块版本化”(lazy basic block versioning)的技术:它以基本块为单位进行编译,只在代码真正执行时才编译,并根据观察到的类型生成专门化版本。这使得预热很快(毫秒级,而非秒级),峰值性能也不错。在 Rails 应用等真实负载下,YJIT 通常能将 Ruby 性能提升 15%–30%。

ZJIT 是下一代演进,也是最有意思的部分。ZJIT 引入了中间表示(IR),即介于字节码和机器码之间、结构化的程序表示。这种 IR 让 YJIT 那种直接从字节码到机器码的路线难以实现的经典编译器优化成为可能。

消除冗余的加载与存储

ZJIT 最近合入的那项具体优化(消除冗余的对象加载和存储)听起来有些晦涩,但影响很大。原因如下。

Ruby 对象把实例变量存放在一张属性表中。每次读取 @name,解释器都要从内存中对象的属性表加载值;每次写入 @name = value,就要存回该表。在一个方法里多次访问同一个实例变量时,解释器每次都会从内存加载,因为在一般情况下,两次读取之间的值可能已经被其他代码改变了。

class Rectangle
def area
@width * @height
end
def perimeter
# @width and @height are each loaded TWICE from memory
2 * (@width + @height)
end
def scale(factor)
@width = @width * factor    # Load @width, multiply, store @width
@height = @height * factor  # Load @height, multiply, store @height
self
end
end
# In a tight loop, these redundant loads add up:
rects.each { |r| r.scale(2).perimeter }

借助 IR,ZJIT 可以执行加载消除:如果 @width 已经加载过,且之后没有被修改,就直接复用寄存器里的值,而不是再次从内存加载。它还能执行存储消除:如果连续两次写入 @width,中间没有任何读取,那么第一次存储就可以被消除。

这类优化在静态语言编译器中早已是基本功,GCC 和 LLVM 已经做了几十年。但在动态语言里要做到这一点难得多,原因在于别名问题(aliasing)。在 Ruby 中,调用任何方法都可能修改任意对象的实例变量(比如通过 instance_variable_set、method_missing 或 trace hook)。JIT 必须证明:在两次读取 @width 之间,没有任何东西可能改变它,这就需要分析中间每一个操作可能产生的副作用。

IR 的优势

ZJIT 引入 IR 的原因(以及 V8 的 TurboFan、JavaScriptCore 的 DFG/FTL、HotSpot 的 C2 都使用 IR 的原因),在于它让这些优化可以相互组合。IR 本质上是一张操作图,优化可以作为图变换来施加。

  • 常量折叠:如果加法的两个操作数都是已知常量,就用其结果替换该操作。2 + 3 在编译期就变成 5。
  • 死代码消除:如果某个操作的结果从未被使用,就将其整个移除。
  • 公共子表达式消除:如果同一计算出现了两次,就只算一次,并复用结果。
  • 加载/存储消除:如前所述,移除冗余的内存操作。
  • 逃逸分析:如果一个对象在当前方法内被创建且从未逃出该方法,就把它分配在栈上而不是堆上(或者直接消除这次分配)。
  • 内联:用方法体替换方法调用,从而为其他优化暴露更多机会。

这些优化会叠加产生效果。内联一个方法会把它的操作暴露到调用者的上下文中,这可能揭示出常量值,进而触发常量折叠,使某些代码变成死代码,最终被消除。一次内联决策就可能级联地删掉几十个操作。

去优化:安全网

JIT 编译器做的每件事都带有推测性质。它假设类型不会改变、方法不会被重新定义、猴子补丁也不会让优化后的代码失效。一旦这些假设被打破,JIT 就需要“去优化”(deoptimize),即丢弃优化后的代码,回退到解释器。

去优化是 JIT 设计中最难的部分之一。优化后的代码可能已经消除了局部变量、重排了操作,或内联了层层嵌套的调用。要回退到解释器,JIT 必须从优化代码所持有的信息中重建解释器状态,包括所有局部变量、调用栈和程序计数器。这需要维护一套元数据(称为“栈上替换”或 OSR 映射),把优化代码的状态映射回解释器状态。

当去优化频繁发生时,也就是所谓的“去优化抖动”(deopt thrashing),性能甚至可能比纯解释执行还差。JIT 把时间浪费在编译优化代码、短暂运行、去优化、再重复的循环里。V8 的做法是跟踪去优化次数,最终放弃对某个函数的优化。YJIT 则采取更简单的思路:为不同的类型组合,为每条代码路径生成多个版本,从而减少去优化的需要,代价是生成更多代码。

超越 Ruby 的意义

Ruby 的 JIT 之路映照着整个动态语言领域正在发生的事。Python 的 copy-and-patch JIT(已在 CPython 3.13 中合入)是迈向 Python 真正 JIT 编译的第一步。LuaJIT 多年来凭借激进的追踪式 JIT 一直快得惊人。PHP 8.0+ 的 JIT 则使用 LLVM 来完成基于 IR 的优化。

这一规律很一致:先从解释器起步,加入性能剖析以理解运行时行为,用类型特化编译热点路径,引入 IR 做经典优化,然后不断打磨。每种语言都面临同样的挑战(动态分派、可变对象、eval、猴子补丁),并最终走向相似的解决方案。

对于使用这些语言的开发者来说,实际的结论是:动态语言与静态语言之间的性能差距正在缩小。它永远不会完全消失,类型检查和守卫仍然有代价,去优化也是固有的开销。但一个优化良好的 JIT 在计算密集型负载上,可以做到与等价 C 代码相差 2–5 倍以内,这对绝大多数应用来说已经“足够快”了。

还有一个更微妙的结论:写直白的代码。JIT 编译器擅长优化可预测的模式。单态调用点(方法总是接收相同类型)优化效果很好;多态调用点(类型会变化)就更难;超多态调用点(几十种类型)可能根本无法被优化。对人来说易于理解的代码,通常对 JIT 来说也易于优化,这是一种很好的激励一致。