Open-Source Wikis

/

Ruby

/

JIT compilers

/

ZJIT

ruby/ruby

ZJIT

ZJIT is CRuby's newer JIT, also written in Rust. It uses a higher-level IR (HIR) and a more conventional optimizer, in contrast to YJIT's incremental basic-block specialiser. ZJIT lives in zjit/ and is co-developed alongside YJIT (both are members of the same Cargo workspace).

As of this checkout (Ruby 4.1.0 dev), ZJIT is shipped opt-in via --enable-zjit at configure time and --zjit at runtime, and is under active development.

Files

Path Purpose
zjit/ Cargo workspace member.
zjit/Cargo.toml Crate manifest. ~70K lines of Rust total.
zjit/build.rs Build script (bindgen, codegen for snapshot tests).
zjit/zjit.mk Make rules to build and link the static lib.
zjit/src/lib.rs Crate root.
zjit/src/hir.rs High-level IR (close to bytecode but typed).
zjit/src/lir.rs Low-level IR (close to machine instructions).
zjit/src/hir_type.rs The type lattice used during HIR optimization.
zjit/src/codegen.rs LIR → machine code.
zjit/src/asm/ Assembler primitives (shared with YJIT in spirit, separate in code).
zjit/src/backend/ Per-architecture lowering.
zjit/src/options.rs CLI flags and stats knobs.
zjit/src/state.rs Per-iseq compile state, deopt entries.
zjit/src/profile.rs Inline profile collection (type observations).
zjit/src/cruby.rs Bindgen output for CRuby C interface.
zjit.c C entry point.
zjit.h Public C API.
zjit.rb RubyVM::ZJIT Ruby methods.
tool/zjit_bisect.rb Bisect a misbehaving compilation.
tool/zjit_diff.rb Diff IR between two compilations.
tool/zjit_iongraph.rb + .html Render the IR graph in a browser.

Architecture

graph LR
    iseq[Hot iseq] -->|profile| profile[Type profile]
    profile -->|build hir| hir[HIR: typed graph]
    hir -->|opt passes| hir2[Optimised HIR]
    hir2 -->|lower| lir[LIR: arch-near]
    lir -->|regalloc| lir2[Allocated LIR]
    lir2 -->|emit| code[Machine code]
    code -->|deopt on miss| vm[VM resume]

ZJIT compiles whole methods (or, more precisely, whole iseqs from entry through return). The pipeline is:

1. Profile

Before compiling, ZJIT collects an inline profile of how the iseq actually runs:

  • Receiver type at each call site.
  • Branch frequencies.
  • Hot loops vs cold paths.

The profile is recorded by lightweight instrumentation that the VM emits when ZJIT is enabled. After enough samples, compilation is triggered.

2. Build HIR

zjit/src/hir.rs defines a graph IR with explicit types. Each value flows through SSA-form nodes:

  • Const(VALUE) — a constant.
  • Param(idx) — a method parameter or local.
  • Add, Sub, Lt, Eq — typed arithmetic when the profile shows stable types.
  • Send(callsite, args) — a fully general call.
  • OptSend(method_entry, args) — a call with a known target.
  • GuardType(value, type) — assert a value's type or deopt.
  • Phi(...) — at merge points.
  • Return(value), Branch(cond, then, else) — control flow.

The HIR is typed via the lattice in hir_type.rs: types include Fixnum, Float, BasicArray, String, Object, Top, etc. Type information flows through optimization passes.

3. Optimise

ZJIT runs a sequence of passes over the HIR:

  • Constant folding: Add(Const 1, Const 2) → Const 3.
  • Type inference: propagate types through SSA edges.
  • Inline cache specialisation: rewrite Send to OptSend when the IC is hot.
  • Inlining: replace OptSend to a small Ruby method with the callee's HIR.
  • Loop-invariant code motion.
  • Dead code elimination.

Each pass produces a new HIR snapshot that can be dumped via --zjit-dump-hir-opt=foo.

4. Lower to LIR

zjit/src/lir.rs is a more concrete IR — closer to machine code, with explicit registers and stack slots, but still architecture-independent. Lowering inserts:

  • Spill/reload code.
  • Side-exit thunks (called "deopt entries" here).
  • Cache check sequences.

5. Code generation

zjit/src/codegen.rs walks the LIR and emits native code. The assembler is in zjit/src/asm/, with backend lowering per architecture in zjit/src/backend/<arch>.rs. Currently x86_64 and arm64.

Deoptimisation

When a compiled method's assumptions fail (a wrong type, an invalidated cache, an unexpected exception), ZJIT deopts:

  1. Save current register state into a frame the VM can read.
  2. Reconstruct the VM's interpreter state from the LIR's deopt metadata.
  3. Jump back into the VM's dispatch loop at the corresponding bytecode offset.

The deopt entries are kept compact via metadata tables instead of inlining full unwind code at each potential deopt site.

Tooling

ZJIT ships several debugging aids that go well beyond what YJIT exposes:

  • --zjit-dump-hir=name — print initial HIR.
  • --zjit-dump-hir-opt=name — print HIR after each pass.
  • --zjit-dump-lir=name — print LIR.
  • --zjit-dump-disasm=name — print final machine code.
  • --zjit-stats — runtime counters.
  • tool/zjit_iongraph.rb — render the IR graph in tool/zjit_iongraph.html.
  • tool/zjit_bisect.rb — bisect a misbehaving compilation by toggling individual iseqs.
  • tool/zjit_diff.rb — diff IR dumps between two builds.

Profile-guided strategy

ZJIT is profile-driven: it pays a cost up front to observe behaviour, then compiles with confident type assumptions. This contrasts with YJIT's immediate approach (compile on first call, specialise on the fly).

The trade-offs:

  • ZJIT can do whole-method optimisations (DCE, LICM, cross-call inlining) that YJIT can't.
  • ZJIT's profile cost means it's slower to warm up on short-running scripts.
  • ZJIT's deopt machinery is more complex but pays off when the type stability is perfect.

Maturity

As of Ruby 4.1.0 dev (this checkout), ZJIT is:

  • Shipped behind --enable-zjit.
  • Opt-in at runtime via --zjit.
  • Tested in CI (.github/workflows/zjit-ubuntu.yml).
  • Excluded from some specs (spec/.excludes-zjit/) where its semantics differ.
  • Not yet a default. YJIT remains the recommended JIT for production.

Common pitfalls

  • exec/fork interactions: JIT-allocated executable memory survives fork but the JIT state machine doesn't relocate cleanly. Validate after fork-heavy patterns.
  • Frequent class redefinition: every redefinition mass-deopts. Don't redefine core methods on warm classes.
  • TracePoint: any active TracePoint suppresses most ZJIT optimisations.
  • Kernel#eval / binding: methods that capture bindings aren't compiled (or are compiled with conservative assumptions).

Entry points for modification

  • Add a new HIR opcode: extend the Insn enum in zjit/src/hir.rs, teach each pass to handle it, lower it in zjit/src/lir.rs, emit it in zjit/src/codegen.rs.
  • Add an optimization pass: implement a function over Hir, register it in the pass pipeline.
  • Backend support for a new architecture: implement zjit/src/backend/<arch>.rs mirroring x86_64.
  • Profile a new behaviour: extend zjit/src/profile.rs and consume the profile in the optimizer.

See yjit.md for the production JIT and systems/vm.md for the interpreter both JITs accelerate.

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

ZJIT – Ruby wiki | Factory