Ruby YJIT 深度工程实战:从 Basic Block Versioning 到 Rust 重写的 JIT 编译器设计
执行摘要:大多数人对 JIT 编译器的心智模型来自 HotSpot 的 C2 或 V8 的 TurboFan——基于 profile 的方法级编译 + SSA 中间表示 + 激进优化。YJIT(Yet Another Ruby JIT)几乎在每一条上都反着来:它不做方法内联的全局分析、不构建 SSA、不生成 native 栈帧,而是用一种名为 Lazy Basic Block Versioning(惰性基本块版本化,LBBV) 的技术,在 Ruby 字节码(YARV ISeq)粒度上做「带类型假设的特化 + 守卫 + 侧边出口(side exit)」。更反直觉的是:YJIT 是用Rust写的,而不是像 MJIT 那样用 C 生成 C 代码。本文拆解这套机器:LBBV 如何把 Ruby 的动态分派变成单态直接调用、guard 失败后如何回退到解释器、Rust 重写带来了什么工程红利、以及 Rails 生产环境里 YJIT 的收益边界与三个真实坑点。
一、先认清对手:Ruby 为什么难被 JIT 编译
Ruby 的语义密度极高,几乎每一条语句都可能触发元编程钩子。看一个最普通的例子:
def total(orders)
sum = 0
orders.each do |o|
sum += o.amount # 这一行至少有三个未知
end
sum
end
编译这一行时,JIT 在静态上什么都不知道:
o的类型未知 →amount可能是 attr_reader、可能是method_missing、可能被prepend的模块覆盖;+不是算术指令,而是sum.+(o.amount)的方法调用,可被Integer#+之外的实现覆盖;- 任何一次调用都可能执行
TracePoint、触发 GC、甚至通过BasicObject#instance_eval改写正在执行的 ISeq。
HotSpot 的解法是投机(speculation)+ 去优化(deoptimization):C2 假设 o 是 Order,生成内联的直接字段访问,用 uncommon trap 兜底。V8 用 feedback vector + TurboFan 走同样的路。这条路非常有效,但代价是编译期长、内存占用大、实现复杂。
YJIT 的设计约束从一开始就不同:目标不是峰值吞吐,而是「在真实 Rails 应用上跑得更快,同时不炸内存、不拖慢启动、不破坏 Ruby 的语义」。Shopify 的实测数据是:Ruby 3.4 上 YJIT 给 Rails 带来约 15–25% 的延迟改善,而代码体积增长控制在 1.5–2 倍 native code / Ruby heap 以内。
二、LBBV:不做 SSA,而是给基本块做「版本」
传统 JIT 的思路是:先收集类型信息 → 构建一个方法级的 IR → 优化 → 生成代码。YJIT 的思路是:边看边编,每遇到一个分支就为「这条路径上的类型状态」生成一个新版本的基本块。
核心数据结构只有两个概念:
- Block Version:一段机器码,对应 YARV ISeq 中的一段指令区间,并携带一组已知的类型/值假设(
ctx: Context)。 - Side Exit:当运行时某个假设被打破时,从机器码中间跳回解释器继续执行的一段 trampoline。
伪代码描述 YJIT 的编译循环:
compile_block(iseq, insn_idx, ctx):
key = (iseq, insn_idx, ctx.stack_types, ctx.self_type)
if key in version_map: return version_map[key] # 版本复用
block = codegen.start_block()
for insn in iseq[insn_idx..]:
match insn:
case Send(:amount):
recv_t = ctx.peek(0)
if recv_t is known_class(C): # 单态:特化
cme = lookup_method(C, :amount) # 缓存到 inline cache
if cme.is_cfunc or cme.is_iseq:
emit_guard(recv.class == C) # 一条 cmp + jne
inline_call(cme, block) # 直接调用,不走查表
else:
side_exit(insn_idx, ctx) # 遇到 method_missing 等,退出
else:
emit_generic_call() # 多态:退回通用路径
case Branch:
# 为 then/else 各自生成带不同 ctx 的新版本,递归继续
compile_block(iseq, then_idx, ctx.refine(...))
compile_block(iseq, else_idx, ctx.refine(...))
break
这就是「版本化」的含义:同一个 sum += o.amount 这段代码,在「o 是 Order」的上下文里是一个版本,在「o 是 Hash」的上下文里是另一个版本。它们不是优化前后的关系,而是并存的兄弟。
这带来三个关键性质:
- 编译是增量的、廉价的。没有方法级的 IR 构建,编译一个基本块通常只需几十微秒,因此 YJIT 可以在
--yjit-call-threshold=2(默认 Ruby 3.3+ 已经很低)就开始编译。 - 内存是可控的。版本数量受限于
Context的组合爆炸,YJIT 用「栈深度上限 + 类型泛化(unknown)」来截断:一旦某个栈槽的类型超过阈值次数出现不同类,就把它折叠成unknown,后续不再分版本。 - 代码与解释器共享栈帧。YJIT 生成的机器码直接操作 CRuby 的 VM 栈,不构造独立 native frame。这让 side exit 变得极其简单——因为机器码执行到一半时,VM 栈的状态与解释器期望的完全一致,直接
jmp回解释器即可。
三、Guard 与 Side Exit:把「投机失败」变成一次跳转
LBBV 的每个特化都伴随一条守卫。以 Integer#+ 为例,YJIT 生成的 x86-64 代码大致是(简化):
; 假设栈顶两个值都是 fixnum(Ruby 的小整数,低位 tag 为 1)
mov rdi, [rb_sp] ; 取出接收者
mov rsi, [rb_sp+8] ; 取出参数
mov rax, rdi
and rax, rsi
test rax, 1 ; 两个都是 fixnum?
jz side_exit_0x1a2b ; 不是 → 侧边出口,回解释器
mov rax, rdi
sub rax, 1 ; 去掉 tag
add rax, rsi ; 真正的整数加法
jo side_exit_0x1a2b ; 溢出 → Bignum 需要解释器处理
mov [rb_sp+8], rax
注意这里没有函数调用。Integer#+ 的语义被完全内联成三条算术指令,这是 Ruby 这种「一切皆方法调用」语言最大的一块收益来源。
Side exit 的处理是 YJIT 工程上最精妙的部分。它做两件事:
- 状态重建:把机器码里的寄存器值写回 VM 栈和局部变量的槽位。因为 YJIT 一直维持「VM 栈是唯一真相」的约定,这一步只是若干次 store。
- 退出计数(exit counter):每个 side exit 位置带一个计数器。当某个位置的退出次数超过阈值,YJIT 会重新编译该基本块,并在
Context里把这个槽位标记为unknown,从此不再对它做 fixnum 投机。这就是「惰性」的体现——先乐观特化,被打脸了再退化。
# 观察 side exit 热点(需要 --enable-yjit=dev 或 stats 构建)
RUBYOPT="--yjit-stats" bundle exec rails runner 'MyHotJob.perform'
# 输出片段:
# compiled_iseq_count: 8421
# invalidation_count: 317
# side_exit_count: 1_284_003
# ratio_in_yjit: 94.7% # 关键指标:94% 的指令在 JIT 代码里执行
ratio_in_yjit 是判断 YJIT 是否真正生效的唯一可信指标。低于 85% 通常意味着某处存在持续的 invalidation(见第五节)。
四、为什么是 Rust:从 MJIT 的失败里学到的
Ruby 2.6 的 MJIT 走的是「生成 C 代码 → 调 GCC/Clang → dlopen」的路子。它失败得很彻底:
| 维度 | MJIT | YJIT |
|---|---|---|
| 编译触发 | 方法调用 10000 次 | 方法调用 ~2–10 次 |
| 编译产物 | 磁盘上的 .c/.so 文件 | 内存中的 code page(mmap 的 W^X 内存) |
| 编译延迟 | 依赖外部 C 编译器,秒级 | 纯内存 codegen,微秒级 |
| 与 GC 交互 | .so 持有对象引用,难以 write barrier | code page 与对象图显式关联,参与 write barrier |
| 实现语言 | C(与 CRuby 源码耦合) | Rust(通过 rb_* C API 边界交互) |
YJIT 用 Rust 写,最初是因为 Shopify 团队需要一个能安全表达「寄存器分配 + 汇编发射 + 复杂状态机」的语言,而 C 在这类代码上极易写出内存安全 bug。Rust 的 enum + match 恰好是描述 ISeq 指令与 Context 状态迁移的理想工具:
// yjit/src/codegen.rs(示意,非完整源码)
pub enum Type {
TUnknown,
TFixnum,
TFloat,
TArrayExact,
CNil,
CTrue,
}
fn gen_send(asm: &mut Assembler, cd: *const rb_call_data, ctx: &mut Context) {
let recv_type = ctx.get_opnd_type(StackOpnd(0));
match recv_type {
Type::TFixnum => {
let method = unsafe { rb_vm_method_lookup(FIXNUM_CLASS, cd) };
if let Some(known) = inline_fixnum_fastpath(asm, method) {
asm.cmp(recv_opnd, imm_opnd(RUBY_FIXNUM_FLAG));
asm.jz(Target::side_exit(ctx.clone()));
return;
}
}
Type::TArrayExact if is_array_each(cd) => { /* 数组 each 的特化 */ }
_ => {}
}
gen_send_general(asm, cd, ctx); // 兜底:通用调用
}
但 Rust 不是没有代价。YJIT 与 CRuby 之间有一层厚 unsafe 边界:所有对 VALUE、方法缓存、ISeq 结构的访问都在 unsafe 块里。真正的工程纪律是:把 unsafe 收敛在 yjit/src/cruby.rs 与 bindings.rs 里,上层 codegen 逻辑尽量保持 safe Rust。这也是为什么 YJIT 能作为 Ruby 3.1 的默认可选特性合入主干,而没有被 CRuby 核心团队以「不可维护」为由否决。
五、GC 与失效:JIT 代码里的对象引用怎么管
这是 JIT 与 GC 协同里最容易出错的一环。YJIT 生成的代码里嵌着 GC 需要看见的引用:内联字符串、冻结的字面量、被特化的类对象。
YJIT 的做法:
- Code page 与对象引用分离。机器码放在 mmap 出来的可执行内存,而对 Ruby 对象的引用记录在该 code page 对应的
yjit_code_block_t元数据里。GC marking 阶段遍历这些元数据,把引用的对象标活。 - Write barrier 覆盖 JIT 侧。当 Ruby 代码
prepend一个模块、重定义方法、或调用define_method时,CRuby 会 bump 全局的ruby_vm_global_invalidation或特定 ISeq 的 method invalidation。YJIT 的机器码在关键位置检查这些标志(或由 GC/VM 直接mprotect掉对应 page),触发整块失效。 - Code GC。Ruby 3.3 引入的 code GC 会在 code page 用尽时,按 LRU 淘汰长时间未执行的基本块版本,回收内存。
生产坑点一:过度使用 define_method / method_missing 会让 YJIT 反复失效。 很多 Rails 代码在 before_action 里做动态方法定义,如果发生在 warmup 之后,invalidation_count 会持续上涨,ratio_in_yjit 掉到 70% 以下,反而比关闭 YJIT 更慢。解法是把动态定义集中到启动阶段,或在 config/environments/production.rb 里冻结这些元编程。
生产坑点二:fork 型 server(Puma cluster / Unicorn)的 JIT 预热浪费。 YJIT 的代码在 fork 之后不会继承给子进程(code page 属于父进程的内存,且 pre-fork 编译的代码通常不在热路径上)。正确做法是使用 Puma 的 fork_worker + warmup 钩子,让每个 worker 在 fork 后各自预热:
# config/puma.rb
workers 4
preload_app!
plugin :fork_worker # 分层 fork,共享父进程内存
fork_worker 20 # 每 fork 20 个 worker 换一个新的父
on_worker_boot do
# 让每个 worker 独立跑一遍关键路径,触发 YJIT 编译
Rails.application.executor.wrap { WarmupRoutes.call(limit: 200) }
end
生产坑点三:用 microbenchmark 测 YJIT 必然得出错误结论。 YJIT 的编译阈值极低,纯计算型 benchmark 在第一轮就会被编译,测出来的是「预热后」的峰值;而真实服务里大量方法只被调用几次,它们永远停在解释器。衡量 YJIT 只能用端到端 P95 延迟 + ratio_in_yjit,不能用 benchmark-ips 的单方法数字。
六、调优清单
| 现象 | 根因 | 动作 |
|---|---|---|
ratio_in_yjit < 85% | 持续 invalidation / 大量 side exit | RUBYOPT=--yjit-stats 定位退出热点,收敛元编程到启动期 |
| 内存上涨明显 | 基本块版本爆炸 / 未启用 code GC | 升到 Ruby 3.3+;--yjit-exec-mem-size=128(默认 128MB,容器里可下调到 64) |
| 启动变慢 | 编译过早触发 | --yjit-call-threshold=30(默认 Ruby 3.4 为 10) |
与 --yjit 冲突的 gem | 依赖 RubyVM::InstructionSequence 内部结构或 C 扩展替换栈帧 | 检查 tracing/profiler 类 gem(如老版本 ddtrace、ruby-prof) |
| P95 无改善 | 瓶颈在 IO / 数据库而非 CPU | YJIT 只优化 CPU 侧,先用 APM 确认 time-in-Ruby 占比 |
最小可用生产配置:
# Dockerfile
ENV RUBYOPT="--yjit --yjit-exec-mem-size=96"
ENV RUBY_YJIT_ENABLE=1
# 容器内必须限制 code page 大小,否则默认 128MB 会算进 cgroup 内存
七、结论
- YJIT 的核心创新不是「更快的优化」,而是「更便宜的投机」。LBBV 把方法级编译的复杂度拆成基本块级,使编译延迟降到微秒级,从而能在极低阈值下开始工作——这对 Rails 这类「大量方法只跑几次」的负载才是关键。
- Side exit 是一等公民,不是异常路径。YJIT 从设计上就接受「假设会错」,用 VM 栈作为唯一真相来换取 side exit 的极简实现。这也是它能保持与解释器语义完全一致的原因。
- Rust 重写的真正红利是表达力与安全边界,而非性能。YJIT 的收益来自 LBBV 本身,Rust 让这套复杂状态机在两年内达到可合入主干的质量。
- 生产上唯一可信的指标是
ratio_in_yjit和端到端 P95。任何脱离真实流量的 benchmark 都会误导你。 - 对工程团队的启示很朴素:元编程是 JIT 的天敌,稳定的类型分布是 JIT 的朋友。把动态定义收敛到启动期、避免热路径上的
method_missing,往往比调任何 YJIT 参数都有效。

发表评论 取消回复