Skip to content

YJIT and ZJIT are switches, not defaults

Level: 301 · someone who has heard "Ruby has a JIT now" and wants to see which switch turns it on

One line: MRI (RUBY_ENGINE == "ruby") always runs YARV bytecode through its interpreter; YJIT (since 3.1) and ZJIT (new in 4.0) are compiled in but off until --yjit, --zjit or RubyVM::YJIT.enable — and CPython likewise interprets bytecode, with an experimental JIT and a free-threaded build that are build-time and flag-time choices with no switch the two CI Pythons share.

The reference implementation has three layers a program can see. A parser turns source into a syntax tree — Prism since 3.4, with the old parse.y parser still selectable by --parser=parse.y (row 10: RUBY_DESCRIPTION says +PRISM here and not in that child). A compiler turns the tree into YARV instruction sequences, whose vocabulary is RubyVM::INSTRUCTION_NAMES (row 8; opt_new is new in 4.0, opt_plus and leave are old). The interpreter then executes them, and that is where every Ruby program runs unless a JIT is switched on. RUBY_ENGINE is "ruby" for this implementation — JRuby and TruffleRuby report their own names (Not machine-checked here.) — and RUBY_VERSION starts with 4. (rows 1–2).

YJIT is Shopify's just-in-time compiler, in Ruby since 3.1 and written in Rust since 3.2; it compiles hot instruction sequences to machine code lazily, one basic block at a time. It is off by default: row 3 is false in this process, row 4 is a child started with --yjit printing true, and row 5 shows that ruby --version mentions +YJIT only when it is enabled. Since 3.3 a program can turn it on at runtime (row 6) — RubyVM::YJIT.enable returns true, enabled? flips, and runtime_stats, nil a moment before, becomes a Hash — which is what a web application does after it has finished booting. ZJIT is 4.0's second, method-based JIT, experimental and also off (row 7: the constant exists, enabled? is false, a --zjit child says true). Both need a Rust toolchain when Ruby is built; a Ruby built without one accepts --yjit with a warning and keeps enabled? false, so the rows above also certify that this Ruby has them. RubyVM.stat (row 9) exposes the counters a JIT lives by, such as constant-cache invalidations, and two smaller 4.0-era additions round the page off: Process.warmup (3.3, row 11) runs the GC and compacts the heap before a server forks, and Ruby::Box (4.0, row 12) is an experimental isolated namespace, enabled by an environment variable this page does not set (Not machine-checked here.). How much faster YJIT makes a program is measured elsewhere; nothing here prints a timing. (Not machine-checked here.)

Verified output of jits_and_the_interpreter_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. RUBY_ENGINE                                          "ruby"
 2. RUBY_VERSION.start_with?("4.")                       true
 3. RubyVM::YJIT.enabled? in this process (no flag)      false
 4. child --yjit -e 'puts RubyVM::YJIT.enabled?'         ["true", true]
 5. child --version says +YJIT: without / with --yjit    [false, true]
 6. RubyVM::YJIT.enable at runtime; enabled?; stats class [true, true, Hash]
 7. ZJIT: defined?, enabled? here, child --zjit          ["constant", false, ["true", true]]
 8. INSTRUCTION_NAMES has opt_plus, opt_new, leave       [true, true, true]
 9. RubyVM.stat(:constant_cache_invalidations): key?, class [true, Integer]
10. +PRISM here; child --parser=parse.y says +PRISM      [true, ["false", true]]
11. Process.respond_to?(:warmup) (3.3: tidy up before fork) true
12. defined?(Ruby::Box) (4.0: an isolated namespace)     "constant"

Reading the output

Rows 1–2 name the engine and the major version. Rows 3–5 are YJIT in three states — off here, on in a --yjit child (the pair is the child's stdout and whether it exited 0), and visible in --version only when on. Row 6 turns it on in this process. Row 7 is ZJIT: defined? says "constant", it is off, and a --zjit child says true. Row 8 checks three instruction names, row 9 a counter's presence and type, row 10 the parser switch, rows 11–12 two newer APIs' presence.

Compared with Python

CPython reports itself as cpython (sys.implementation.name) and CPython (platform.python_implementation(), row 1); PyPy, the implementation with a JIT, reports pypy (Not machine-checked here.). Its interpreter runs the bytecode dis shows, whose vocabulary is dis.opmap (row 8, the twin of INSTRUCTION_NAMES). The closest stable counterpart to "a child with a flag that changes the runtime" is -O: row 3 is sys.flags.optimize == 0 here, row 4 a child with -O printing 1 and __debug__ == False, and row 5 the flags that -I sets for every twin in this library. Where Ruby lets a program flip YJIT at runtime, sys.flags is read-only (row 6, AttributeError): what the interpreter was started with is what it is. Python 3.13 added an experimental copy-and-patch JIT, compiled in only with --enable-experimental-jit and switched by PYTHON_JIT=1, and 3.14 exposes sys._jit.is_enabled() — 3.12 has none of that, so row 7 records the one thing both versions agree on: no jit field in sys.flags. The free-threaded build (PEP 703, python3.13t) and sys._is_gil_enabled() are in the same position (Not machine-checked here.). Row 9 is two interpreter knobs — the recursion limit and the GIL switch interval, the timer Ruby's GVL also has but does not expose as a setter. Row 10: the PEG parser replaced the LL(1) one in 3.9, and -X oldparser is accepted and ignored since 3.10. gc.freeze (row 11) is the pre-fork tidy-up Process.warmup performs, and sys.monitoring (row 12) is the 3.12 event API — the newest under-the-hood surface both CI Pythons share. The children are started with -c; the Python library's page on -c explains why that is not the same as typing at the prompt.

Verified output of jits_and_the_interpreter_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. sys.implementation.name, platform.python_implementation() ['cpython', 'CPython']
 2. sys.version_info >= (3, 12)                          True
 3. sys.flags.optimize in this process (no flag)         0
 4. child -O -c 'print(sys.flags.optimize, __debug__)'   ['1 False', True]
 5. flags under -I: isolated, ignore_environment, safe_path [1, 1, True]
 6. sys.flags.optimize = 1 at runtime raises             'AttributeError'
 7. a JIT switch in sys.flags? hasattr(sys.flags, 'jit') False
 8. dis.opmap has BINARY_OP, LOAD_CONST, RETURN_VALUE    [True, True, True]
 9. sys.getrecursionlimit(), sys.getswitchinterval()     [1000, 0.005]
10. one parser: ast root here; child -X oldparser (ignored) ['Module', ['Module', True]]
11. callable(gc.freeze) (3.7: tidy up before fork)       True
12. hasattr(sys, 'monitoring') (3.12: the event API)     True

Try it

  1. Print one table from four children of RbConfig.ruby — no flag, --yjit, --zjit, --parser=parse.y — each reporting RubyVM::YJIT.enabled?, RubyVM::ZJIT.enabled? and whether RUBY_DESCRIPTION contains +PRISM, so that the flag is the only thing that changes between the rows.
  2. Start a child with --yjit --yjit-stats, run a hot loop that calls one small method a hundred thousand times, and print which keys RubyVM::YJIT.runtime_stats has (compiled_iseq_count, side_exit_count) and whether compiled_iseq_count is greater than zero after the loop — but not the numbers themselves.
Solution to 1

jits_and_the_interpreter_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: one table, four children. Each child of RbConfig.ruby reports whether
# YJIT and ZJIT are enabled and whether it was parsed by Prism; the flag is
# the only thing that changes between the rows.

require "open3"
require "rbconfig"

REPORT = "print [RubyVM::YJIT.enabled?, RubyVM::ZJIT.enabled?, RUBY_DESCRIPTION.include?('+PRISM')].join(' ')"

puts format("%-18s %-6s %-6s %s", "flag", "YJIT", "ZJIT", "PRISM")
[[], ["--yjit"], ["--zjit"], ["--parser=parse.y"]].each do |flags|
  out, _err, _status = Open3.capture3(RbConfig.ruby, *flags, "-e", REPORT)
  yjit, zjit, prism = out.split
  puts format("%-18s %-6s %-6s %s", flags.first || "(none)", yjit, zjit, prism)
end

Verified output of jits_and_the_interpreter_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

flag               YJIT   ZJIT   PRISM
(none)             false  false  true
--yjit             true   false  true
--zjit             false  true   true
--parser=parse.y   false  false  false

See also