skip to content

In CRuby 4.0, what does the YJIT compiler do to your running Ruby code, and what does turning it on cost?

level: juniorimportance: must knowfreq 55%

answer

  1. interpreter runs YARV bytecode
  2. hot code becomes machine code
  3. lazy basic block versioning
  4. side exit back to interpreter
  5. disabled by default, costs memory

basics

~20 s

YJIT is CRuby's built-in just-in-time compiler: once enabled, it turns frequently run Ruby methods from YARV bytecode into native machine code, falling back to the interpreter when needed. It is off by default and costs memory and compile time.

solid answer

~40 s

CRuby normally interprets **YARV bytecode**. YJIT, a JIT built into CRuby, counts calls and, once a method or block has run `--yjit-call-threshold` times (30 by default, raised to 120 once the process holds 40,000 instruction sequences), compiles it **lazily, one basic block at a time**, specialising the code for the values it actually meets (*basic block versioning*). When compiled code hits a case it has no machine code for, it takes a **side exit** and the interpreter carries on, so results never change, only speed. The price is **memory** for generated code and metadata (bounded by `--yjit-mem-size`, 128 MiB by default in 4.0) and **compile time** up front. It is disabled until you pass `--yjit`, set `RUBY_YJIT_ENABLE=1` or call `RubyVM::YJIT.enable`.

go deeper

for a junior

Recall that YJIT turns hot Ruby code into machine code, is off by default, and trades extra memory for speed without changing results.

for a middle

Explain the call threshold, lazy block-by-block compilation, basic block versioning and side exits back to the interpreter.

for a senior

Show you know what defeats it in production: TracePoint, binding, redefined core operators, boot-only code and too little memory for the hot set.

for a principal

Frame YJIT as a memory-for-CPU trade whose payoff depends on how much of the fleet's time is spent running Ruby code rather than waiting.

## The interpreter YJIT sits on top of CRuby (the reference Ruby, also called MRI) does not run your source text directly. It parses each file, compiles it to **YARV bytecode** (instruction sequences, or *ISEQs*, one per method, block and class body) and a stack-based virtual machine executes those instructions one at a time. Every instruction pays for dispatch, and every `+` or method call re-checks what kind of object it is dealing with. A **just-in-time (JIT) compiler** removes part of that cost by generating **native machine code** for code that runs often. **YJIT** ("Yet Another Ruby JIT") is the JIT built into CRuby itself, written in Rust. It stopped being experimental in Ruby 3.2 and ships with Ruby 4.0, but it is **disabled by default**: `ruby --help` lists the `yjit` feature as "default: disabled". ## When a method gets compiled YJIT does not compile the program at start-up. It works lazily: 1. The interpreter runs every ISEQ first and counts how often it is called. 2. After `--yjit-call-threshold` calls (30 by default) the ISEQ is considered hot. Since Ruby 3.3 the threshold rises to 120 once the process holds more than 40,000 live ISEQs, so large applications compile less boot-time noise. 3. YJIT compiles the hot code **one basic block at a time** (a basic block is a straight run of instructions with no branch in the middle), and only the blocks that actually execute. 4. It uses **basic block versioning (BBV)**: when a value's type is known at one point, it emits a version of the following block specialised for that type, which removes repeated type checks. 5. `--yjit-cold-threshold` (200K global calls by default) stops it compiling ISEQs that were rarely used before that point. ## Side exits: speed changes, behaviour does not Compiled code makes assumptions, such as "this receiver is an `Integer`" or "nobody redefined `Integer#+`". A guard checks each assumption, and when one fails the code takes a **side exit**: it hands its state back to the interpreter, which continues from the same instruction. Some events invalidate compiled code wholesale. YJIT's own documentation warns that `TracePoint` and `binding` can make it deoptimise code, and that redefining basic integer operators or the meaning of `nil` and equality undermines its assumptions. The result is the key property to state in an interview: - a JIT changes **how fast** Ruby code runs, never **what it computes**; - anything YJIT cannot handle still runs, just in the interpreter; - there is no separate "YJIT-compatible Ruby" you have to write. ## What turning it on costs | Cost | Where it comes from | Knob | |---|---|---| | Memory | machine code plus metadata about each compiled block | `--yjit-mem-size` (MiB, default 128 since 3.4) | | Warm-up | the first calls run interpreted, then compile work happens | `--yjit-call-threshold` | | CPU at start | compiling code that only runs during boot | enable later with `RubyVM::YJIT.enable` | | Platform | x86-64 and arm64 on Linux, macOS and BSD only | build needs `rustc` | The memory cost is the one people notice. YJIT's documentation says plainly that it will use more memory than the interpreter, because it keeps generated code and state in memory. A Ruby built without a Rust compiler has no YJIT at all. ## Turning it on, in one breath Three switches enable it: the `--yjit` command-line flag, the `RUBY_YJIT_ENABLE=1` environment variable, or `RubyVM::YJIT.enable` called from Ruby code (Ruby 3.3 and later). `RubyVM::YJIT.enabled?` reports whether it is on, and `RUBY_DESCRIPTION` (what `ruby -v` prints) gains `+YJIT` when it is. ## Common misreadings - "YJIT compiles everything at boot" - it compiles lazily, only hot code. - "YJIT is on by default in Ruby 4.0" - it is off until one of the three switches is used. - "A JIT makes Ruby use less memory" - it uses more; that is the trade. - "YJIT replaces the interpreter" - the interpreter still runs cold code and every side exit.

  • What is a side exit in YJIT, and why does it matter for correctness?
    A side exit is the path compiled code takes when a guarded assumption fails, such as an unexpected receiver type. YJIT hands the current state back to the interpreter, which resumes at the same bytecode instruction. That fallback is why enabling YJIT cannot change a program's results: anything the machine code cannot handle is still executed, just interpreted and more slowly.
  • Why does YJIT wait for a call threshold instead of compiling each method on its first call?
    Most code runs only a few times, often only during boot, and compiling it costs CPU and memory that the few remaining calls never repay. Waiting for 30 calls (120 in processes with over 40,000 ISEQs since 3.3) concentrates the memory budget on code that is actually hot. The `--yjit-call-threshold` flag changes the number.
  • Does YJIT compile code before it knows the types of the values involved?
    No. It compiles lazily, block by block, as execution reaches each block, so it can see the actual values in play. Basic block versioning then emits type-specialised versions of later blocks, which removes repeated type checks. Code written so a variable keeps one type benefits most.

YJIT is like a barista who starts making a regular's order before they reach the counter: after enough identical visits the routine is pre-set, and if the regular orders something different the barista just takes the order the normal way.

saying these in an interview costs you the question

  • YJIT is enabled by default in Ruby 4.0
  • YJIT compiles the whole program ahead of time at boot
  • Enabling a JIT can change what a Ruby program computes
  • YJIT reduces memory because machine code replaces the bytecode
  • Once YJIT is on, the interpreter no longer runs any code