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 在静态上什么都不知道:

  1. o 的类型未知 → amount 可能是 attr_reader、可能是 method_missing、可能被 prepend 的模块覆盖;
  2. + 不是算术指令,而是 sum.+(o.amount) 的方法调用,可被 Integer#+ 之外的实现覆盖;
  3. 任何一次调用都可能执行 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」的上下文里是另一个版本。它们不是优化前后的关系,而是并存的兄弟。

这带来三个关键性质:

  1. 编译是增量的、廉价的。没有方法级的 IR 构建,编译一个基本块通常只需几十微秒,因此 YJIT 可以在 --yjit-call-threshold=2(默认 Ruby 3.3+ 已经很低)就开始编译。
  2. 内存是可控的。版本数量受限于 Context 的组合爆炸,YJIT 用「栈深度上限 + 类型泛化(unknown)」来截断:一旦某个栈槽的类型超过阈值次数出现不同类,就把它折叠成 unknown,后续不再分版本。
  3. 代码与解释器共享栈帧。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」的路子。它失败得很彻底:

维度MJITYJIT
编译触发方法调用 10000 次方法调用 ~2–10 次
编译产物磁盘上的 .c/.so 文件内存中的 code page(mmap 的 W^X 内存)
编译延迟依赖外部 C 编译器,秒级纯内存 codegen,微秒级
与 GC 交互.so 持有对象引用,难以 write barriercode 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 的做法:

  1. Code page 与对象引用分离。机器码放在 mmap 出来的可执行内存,而对 Ruby 对象的引用记录在该 code page 对应的 yjit_code_block_t 元数据里。GC marking 阶段遍历这些元数据,把引用的对象标活。
  2. Write barrier 覆盖 JIT 侧。当 Ruby 代码 prepend 一个模块、重定义方法、或调用 define_method 时,CRuby 会 bump 全局的 ruby_vm_global_invalidation 或特定 ISeq 的 method invalidation。YJIT 的机器码在关键位置检查这些标志(或由 GC/VM 直接 mprotect 掉对应 page),触发整块失效。
  3. 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 exitRUBYOPT=--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 / 数据库而非 CPUYJIT 只优化 CPU 侧,先用 APM 确认 time-in-Ruby 占比

最小可用生产配置:

# Dockerfile
ENV RUBYOPT="--yjit --yjit-exec-mem-size=96"
ENV RUBY_YJIT_ENABLE=1
# 容器内必须限制 code page 大小,否则默认 128MB 会算进 cgroup 内存

七、结论

  1. YJIT 的核心创新不是「更快的优化」,而是「更便宜的投机」。LBBV 把方法级编译的复杂度拆成基本块级,使编译延迟降到微秒级,从而能在极低阈值下开始工作——这对 Rails 这类「大量方法只跑几次」的负载才是关键。
  2. Side exit 是一等公民,不是异常路径。YJIT 从设计上就接受「假设会错」,用 VM 栈作为唯一真相来换取 side exit 的极简实现。这也是它能保持与解释器语义完全一致的原因。
  3. Rust 重写的真正红利是表达力与安全边界,而非性能。YJIT 的收益来自 LBBV 本身,Rust 让这套复杂状态机在两年内达到可合入主干的质量。
  4. 生产上唯一可信的指标是 ratio_in_yjit 和端到端 P95。任何脱离真实流量的 benchmark 都会误导你。
  5. 对工程团队的启示很朴素:元编程是 JIT 的天敌,稳定的类型分布是 JIT 的朋友。把动态定义收敛到启动期、避免热路径上的 method_missing,往往比调任何 YJIT 参数都有效。
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部