Ruby's JIT Journey: From MJIT to YJIT to ZJIT

by Sujay Prabhu, Senior System Analyst

JIT Journey

With Ruby 4.0 out, one of the most talked-about additions is ZJIT. Before jumping into ZJIT, it’s important to understand what JIT is, why Ruby needed it, and how Ruby’s JIT story evolved over time.

What is JIT?

JIT stands for Just-In-Time compiler.

In simple terms, a JIT compiler compiles code at runtime, instead of fully interpreting it every time. This allows the runtime to observe how the code actually executes and apply optimizations based on that information.

How Ruby Executed Code Before JIT

Before any JIT existed, Ruby execution looked like this:

  • Ruby source code is parsed and tokenized
  • Converted to an AST (Abstract Syntax Tree)
  • Converted to Instruction Sequences (ISEQs), also called YARV bytecode
  • Executed by the YARV (Yet Another Ruby Virtual Machine) VM with an interpreter loop that reads and executes each instruction one by one

Ruby is a dynamic language with features like metaprogramming and open classes. This means quite a few checks need to be done at runtime like if a method is defined, if a variable is defined, if a class is defined, if a module is defined, etc. JIT can make use of hot spots (parts of the code that are executed frequently) to optimize them instead of repeatedly interpreting them.

MJIT

MJIT stands for Method-based Just-In-Time compiler.

As the name suggests, MJIT compiles code at the method level. A method becomes eligible for JIT compilation after being called multiple times.

How MJIT Works

  • MJIT takes a block of YARV bytecode and generates inline C code for it.
  • It actually generates a C file that contains the instructions for the compiled method.
  • Then it invokes an external C compiler (like GCC/Clang) to convert the C file into native code that Ruby loads at runtime.
  • So, the next time this method is called, Ruby says "Wait, I have the machine-ready version" of this. It skips the interpreter loop and runs the native code directly.

What does "inline" mean?

You can think inline as copy-pasting.

Inlining replaces a function call like result = add(1, 2) by pasting the logic of add directly into the calling code. This eliminates the "jump" to a different part of memory and the "jump back," removing the function call overhead entirely.

def add(a, b)
  a + b
end

result = add(1, 2)

result = 1 + 2 // inlined code

MJIT's Drawbacks

  • Even though it showed performance improvements in some benchmarks but the impact was either small or negative for production applications especially in Rails.
  • Slow warmup: Compilation took time before benefits appeared
  • It was not self contained. It needed a C compiler

YJIT

YJIT is directly integrated into Ruby. Its core principle is Lazy Basic Block Versioning (LBBV).

How YJIT works

  • Instead of using an external C compiler on the entire method as MJIT did, YJIT compiles code in small pieces using LBBV. When a block becomes hot (executed repeatedly, defaults to 30 times), YJIT compiles that block.
  • It keeps a type context for each block: if a variable is known to be Integer in that context, generated machine code assumes that type.
  • Each generated block begins with guard checks to verify its assumptions. If a guard fails at runtime, YJIT bails out and falls back to interpreter.
  • YJIT can then compile a new version of that block for the new type if it becomes hot.
  • These guards ensure correctness so that the program will always produce the right result even if types change.

Here's an example to illustrate how this works:

def foo(x)
  if x > 10
    x + 1
  else
    x - 1
  end
end

foo(20) # is called
  • YJIT observes x is an Integer
  • It compiles the x > 10 path
  • When x is greater than 10, compiled code runs
  • If x <= 10, no compiled version exists for that path
  • Execution falls back to the interpreter, which returns the correct result

YJIT watches which path you actually take, and compiles only that path, specialized to the observed types. This gave it significant performance benefits and it became the default in Ruby 3.3

ZJIT

ZJIT is the newest JIT and has been merged into Ruby 4.0. It is built by the same team (from Shopify) that built YJIT. ZJIT is method-based, similar to MJIT.

Why Method-Based Again?

The YJIT team itself suggested the method based approach for ZJIT as this is a known design with less risk. YJIT's LBBV approach works very well, but extending it into a "full optimizing compiler" is risky, complex, and research-heavy. ZJIT chooses a safer, proven compiler architecture instead.

How ZJIT works

  • ZJIT uses a modern compiler pipeline with an Intermediate Representation (IR) in Static Single Assignment (SSA) form.
  • Ruby code is still parsed and compiled into YARV bytecode.
  • ZJIT translates YARV bytecode into SSA-based HIR.
  • ZJIT performs analysis and optimizations on this IR, then lowers it to machine code.
  • ZJIT does not use YJIT’s Lazy Basic Block Versioning. It does use historical profiling information collected during interpretation (such as commonly used types and paths) to guide optimizations.

What are IR and SSA?

  • IR is the data structure or code used internally by a compiler or VM to represent the source code. In ZJIT, High-level IR (HIR), is a graph-like representation of Ruby Code. Unlike YARV's stack-based instructions, HIR uses a graph of basic blocks where data flows explicitly between instructions.

  • SSA is a property of an IR where every variable is assigned exactly once. If the original code reassigns the variable, it creates multiple versions (like x_1, x_2). This simplifies many analyses, such as figuring out what value a variable has at a given point.

While Ruby now ships with ZJIT compiled into the binary by default, it is not enabled by default at run-time. Due to performance and stability, YJIT is still the default compiler choice in Ruby 4.0. To experiment with ZJIT, enable it using the --zjit flag, the RUBY_ZJIT_ENABLE environment variable, or by calling RubyVM::ZJIT.enable after starting your application.

Ruby's JIT journey reflects a pragmatic evolution in performance optimization. From MJIT's ambitious but problematic external compilation, to YJIT's successful lazy block versioning, and now to ZJIT's proven compiler architecture with SSA-based IR, each iteration has learned from the previous one.

While YJIT remains the default for stability, ZJIT represents the future of Ruby optimization. As it matures, we can expect to see it become the standard choice for production applications.

References

More articles

Evaluating LLM Agents: Building the Design Phase Right

The first step to production-ready agents. Master visual debugging, trace collection, and unit testing with LangGraph Studio, LangSmith, and Vitest before moving to pre-production.

Read more

Reverse Proxying over WebSockets: Building a Production-Ready Local Tunnel

A practical look at the challenges of exposing local servers to the internet, and how we built a production-ready tunneling system using WebSockets and NestJS.

Read more

Ready to Build Something Amazing?

Codemancers can bring your vision to life and help you achieve your goals