ruby/ruby
YJIT
YJIT is CRuby's stable production JIT. It uses Lazy Basic Block Versioning (LBBV) — instead of compiling whole methods up front, it compiles one basic block at a time, specialising on the types observed at runtime, and stitches blocks together with branch stubs that compile new versions on demand.
YJIT was developed by Maxime Chevalier-Boisvert at Shopify. It first shipped in Ruby 3.1 (Dec 2021) in C, then was rewritten in Rust for Ruby 3.2 (Dec 2022) — the first Rust code in CRuby's tree.
Files
| Path | Purpose |
|---|---|
yjit/ |
Cargo workspace member. |
yjit/Cargo.toml |
Crate manifest. |
yjit/yjit.mk |
Make rules to invoke cargo and link the static lib. |
yjit/src/lib.rs |
Crate root. |
yjit/src/codegen.rs |
The bytecode-to-machine-code translator (the bulk of the JIT). |
yjit/src/core.rs |
Block versioning, type tracking, side exits. |
yjit/src/asm/ |
Assembler abstraction for x86_64 and arm64. |
yjit/src/backend/ |
Architecture-specific lowering. |
yjit/src/cruby.rs |
Bindgen-generated bindings to CRuby C symbols. |
yjit/src/options.rs |
Command-line and runtime knobs. |
yjit/src/stats.rs |
The --yjit-stats counter table. |
yjit/src/disasm.rs |
Optional disassembly support. |
yjit.c |
C entry point — boots Rust, exposes Init_YJIT. |
yjit.h |
Public C API for the rest of CRuby. |
yjit.rb |
RubyVM::YJIT introspection methods. |
Architecture
graph LR
iseq[Hot iseq detected] -->|gen_entry_point| codegen[codegen.rs]
codegen -->|specialise on call types| block[Compiled basic block]
block -->|branch stub| nextblock[Next basic block]
nextblock -->|on first hit| codegen2[Compile next version]
codegen --> exit[Side exit on shape mismatch]
exit --> vm[Back to VM dispatch]YJIT works at the granularity of basic blocks — straight-line sequences of bytecode without internal branches. It compiles them one at a time, recording the version context (the types of all values on the operand stack and in local variables) at each entry point.
When code branches:
- The "taken" path is compiled with the appropriate type assumptions.
- The "not taken" path becomes a branch stub — a tiny piece of code that, when first executed, compiles its corresponding block with the version context at that point.
Because each block is specialised, you may end up with several versions of the "same" basic block, each tuned to a different type assumption. This is what "Lazy Basic Block Versioning" means.
Triggering compilation
YJIT only compiles iseqs that are called frequently enough. The threshold is --yjit-call-threshold=N (default 30). Each iseq has a counter; once it crosses the threshold, the next call invokes gen_entry_point.
Compilation produces a function that you can JIT-call from anywhere — the CRuby VM has a slot in rb_callcache for the JIT entry, and vm_call_iseq_setup_normal_jit jumps directly there when the cache is hot.
Specialisation and side exits
Most generated code assumes a specific receiver type. For example, 1 + x in Ruby compiles down to a call to Integer#+. YJIT generates code that:
- Checks if the receiver is a
Fixnum. - If yes → inline the addition (with overflow check).
- If no → side exit back to the interpreter, which handles the call generically.
A side exit unwinds the JIT call frame, restores the VM's cfp, and resumes interpretation at the bytecode pointer that the JIT was about to advance past. The user sees no behavioural difference — only the lost JIT efficiency.
yjit/src/core.rs defines the SideExit variants and the unwind logic. --yjit-trace-exits records every side exit and prints the top sources at process exit, which is the canonical way to debug "why isn't YJIT helping me?".
Inline caches and shape integration
YJIT consumes the same inline caches that the VM uses (rb_callcache, IV inline caches, constant inline caches). It reads the cached method entry, generates a direct call, and re-checks the cache state on every entry. If the cache is invalidated (e.g., a method redefinition or a Module#include), every dependent JIT block is patched to side-exit on entry.
The patching mechanism is branch stubs: each block has a small writable stub that, when invalidated, becomes a jump to the side-exit handler.
yjit/src/invariants.rs tracks invalidation dependencies. When you redefine Integer#+, every JIT block that assumed the cached method gets its stub patched.
Code memory
YJIT allocates a mmap'd region (--yjit-exec-mem-size=N MiB, default 64) for its generated code. The region is divided into:
- Inline code: the main body of compiled blocks.
- Outlined code: rarely-executed paths (the cold side of conditional branches, type checks).
Both regions can grow within the reserved size. When memory is exhausted, YJIT stops compiling new code (existing code keeps running).
Performance characteristics
On end-to-end Ruby workloads (Rails apps, Optcarrot, Liquid):
- 1.4–2.5x speedup over pure interpretation, depending on type stability.
- Compilation is fast: typically <1ms per method.
- Memory overhead is bounded (default 64 MB code).
YJIT is not a tracing JIT and does not do whole-program analysis. It focuses on local optimisations within a method call, plus de-virtualisation via inline caches.
Stats and tracing
./ruby --yjit --yjit-stats myapp.rb
./ruby --yjit --yjit-trace-exits myapp.rb
RUBY_DUMP_YJIT_STATS=1 ./ruby --yjit myapp.rb--yjit-stats prints (among hundreds of counters):
- Call sites compiled, side exits per reason, code memory consumed.
- Object shape transitions, IV cache hit rates.
- Per-instruction compilation counts.
RubyVM::YJIT.runtime_stats exposes the same counters from Ruby.
Disassembly
./configure --enable-yjit --enable-yjit-dev
./ruby --yjit --yjit-dump-disasm=foo myapp.rbOr interactively in IRB:
require 'rubyvm'
RubyVM::YJIT.disasm(method(:foo))This requires building with disasm feature (the Cargo.toml adds it as a feature for opt-in capstone dependency).
Common pitfalls
- Bimorphic call sites: a call site that sees two distinct receiver classes alternately produces two compiled versions, each side-exiting in the other's case. This is why "monomorphic" code is faster.
- Frequent
define_method: each new method on a hot class invalidates dependent JIT blocks. Defining many methods at runtime hurts JIT effectiveness. - TracePoint: enabling a TracePoint sets per-iseq flags that disable most JIT optimisations.
Kernel#binding/evalin hot code: these de-optimise the surrounding method.
Entry points for modification
- Add support for a new bytecode: extend
yjit/src/codegen.rs::gen_<insn>. Updateyjit/src/cruby.rsif a new C symbol is needed. - Add a new architecture: implement
yjit/src/backend/<arch>.rsand the assembler facade inyjit/src/asm/. - Tune heuristics:
yjit/src/options.rsdefines knobs;yjit/src/core.rsdecides when/where to compile. - Add a stat counter: extend the
Counterenum inyjit/src/stats.rs.
See zjit.md for the next-generation JIT and systems/vm.md for what YJIT replaces.
Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.