ZJIT brings a method optimizer into upstream Ruby
ZJIT has merged into Ruby with a high-level SSA representation and method-based compilation. Its early implementation opens room for optimization passes while retaining substantial work before production use.
Ruby's compiler team wants more code available to an optimizer at once. YJIT's direct treatment of bytecode and basic-block compilation made it possible to build a useful JIT incrementally, but complicated optimizations across instruction boundaries. ZJIT introduces a higher-level representation between Ruby bytecode and machine-oriented instructions, giving optimization passes a place to operate independently.
Max Bernstein announced ZJIT's upstream merge on May 14. The project comes from the compiler group responsible for YJIT. It is very early in development, and the announcement explicitly says the team is not running it in production. The merge moves development into Ruby without claiming that the prototype has already reached YJIT's functionality or performance.
I find Ruby's uniform object model more approachable than Perl's traditional object conventions. Optimizing that model still requires preserving its dynamic behavior. ZJIT's treatment of an apparently simple addition shows why a compiler needs more information than the source operator alone provides.
A Ruby addition is still a method operation
The merge announcement follows a method that adds two arguments. YARV represents the operation using local-variable loads, opt_plus, and a return. It is a stack-oriented representation, so instructions communicate much of their data through pushes and pops.
The following source captures the example's relevant operation:
def add(left, right)
left + right
end
p add(1, 2)
p add(3, 4)
The operator can use a fast path for two small integers. It cannot assume that seeing integers is sufficient, because Ruby permits redefining Integer#+. Correct execution must account for both the operand types and whether the built-in operation still has its expected definition.
That is a constraint imposed by Ruby semantics, not incidental complexity in a particular compiler. Replacing the call with a machine addition unconditionally would change behavior for programs that redefine the method. A specialization needs a way to establish its assumptions and respond when they stop holding.
The interpreter's optimized instruction already contains such decisions. YARV bytecode is designed for interpreter performance, so one opcode can encapsulate several cases and control-flow decisions. An optimizer that wants to reason about those cases benefits from a representation that makes them explicit.
Profiling occurs before method compilation
ZJIT gathers historical type information in the interpreter. The announcement describes changing selected opcodes to profiling forms after a configurable number of calls. A later threshold triggers compilation. This gives the optimizer observations about the values actually used by a method before it generates specialized code.
For addition, the profiled interpreter records the operand types associated with the operation. The optimizer can then choose an integer specialization when those observations support it. The observations are evidence about prior executions, so the generated program still needs checks for future calls with different values.
This differs from YJIT's lazy basic block versioning approach. The design changes both where type information originates and how much code the compiler considers as one unit. ZJIT compiles an entire method, while YJIT builds one basic block at a time.
The upstreaming proposal connects method compilation to longer-term optimization goals. It identifies aggressive inlining as difficult to express cleanly within YJIT's existing architecture. Those goals motivate ZJIT's design, but a goal in the proposal is not evidence that the corresponding optimization is already implemented.
The high-level representation exposes dataflow
ZJIT converts bytecode into a high-level SSA intermediate representation, or HIR. Instructions refer directly to the instructions producing their operands. Basic blocks have explicit relationships, replacing much of the stack-based communication in the input bytecode with a graph the optimizer can inspect.
The initial representation of addition contains a generic method send. The compiler does not preserve opt_plus as an opaque promise that addition is already specialized. It expresses the underlying operation first and lets later passes introduce the specialization using the available profile information.
The announcement's optimized example contains a check associated with basic-operation redefinition, type guards for both operands, and a specialized small-integer addition. Separating these operations makes their dependencies visible. A pass can reason about a type guard or arithmetic operation without treating the entire interpreter opcode as one indivisible instruction.
This separation is the main architectural change. The upstream proposal describes modular optimization passes and the possibility of additional compilation tiers. A high-level intermediate representation gives those passes a shared input, so adding one transformation need not mean inserting another special case directly into bytecode emission.
I regard that modularity as useful even before a performance result is available. It makes the reasoning behind a transformation easier to locate and review. The benefit depends on the implementation preserving the Ruby behavior represented by the original graph, especially when a specialized assumption fails.
Lowering makes machine obligations explicit
After high-level optimization, ZJIT lowers HIR into a low-level representation, or LIR. The merge announcement describes this as a multi-platform assembler with a register allocator. The compiler can use virtual registers while constructing the program, then map them to physical registers or stack locations.
At this level, the integer example expands into bit tests and conditional branches. The arithmetic also needs an overflow path because the small-integer representation cannot contain every possible mathematical sum. Type specialization does not remove the need to preserve Ruby's behavior outside the chosen fast case.
The representation also includes frame setup and teardown. These details are distinct from deciding that the operands look like integers. They describe how generated code participates in the calling and execution machinery that surrounds the operation.
The low-level example attaches interpreter frame state to conditional exit points. That state includes the instruction position and the values corresponding to the Ruby stack and locals. A return to interpretation needs more than a branch target. It needs a representation of the Ruby execution state at the point where specialized code stops handling the operation.
This explains why completing side exits is substantial work. Generated machine code can hold values in registers whose arrangement differs from the interpreter's stack model. Resuming ordinary Ruby execution requires recovering the state that the interpreter expects. A correct arithmetic fast path is therefore only one part of executing the method safely across changing inputs.
The announcement shows both x86 machine code and discussion of an ARM backend. The upstream proposal says ZJIT reuses a modified version of the YJIT backend. This gives the new optimizer existing machine-code infrastructure while allowing its high-level design to differ substantially.
Fallback is still implementation work
A crucial qualification appears near the end of the merge announcement. Although the article explains guard failure in terms of returning to the interpreter, actual side exits are still being implemented. At this point, an unexpected type observed by a guard aborts. The intended architecture and the prototype's current completeness must be kept separate.
Working side exits would let unsupported or unexpected cases resume in the interpreter. The authors identify that as necessary before running the Ruby test suite for a correctness baseline and broader application benchmarks for performance work. Those baselines are planned next steps, not results supplied by the merge.
This explains why the project can be worth merging before it can run ordinary production applications. Development inside the main repository reduces the work of keeping a private fork synchronized with Ruby changes. A command-line switch keeps use of the experimental compiler explicit while implementation continues.
YJIT remains maintained alongside the new work. The upstream proposal specifically avoids replacing a tested JIT with an incomplete one. That is a migration arrangement for compiler development, rather than a recommendation that applications switch immediately because a new name appears in the source tree.
Contributor accessibility is another design objective
Maxime Chevalier-Boisvert's upstream proposal argues for an architecture closer to conventional compiler designs so additional Ruby contributors can work on it. Matz approves upstreaming while cautioning that the limited outside contribution to YJIT cannot simply be blamed on basic block versioning. That distinction keeps a design objective from becoming an unsupported explanation for contributor behavior.
The documentation at the merge date provides development-build and test commands. It separates Rust unit tests from Ruby execution tests and explains why those tests need the project's build integration. CRuby's initialization constraints make the execution environment relevant even to compiler tests.
The immediate milestone is an upstream method compiler with a representation designed for separate optimization passes. Completing side exits and establishing correctness coverage are concrete tasks that remain. The new structure creates places to implement those tasks without treating the merge itself as proof of production readiness or a measured speed improvement.