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¶
- Print one table from four children of
RbConfig.ruby— no flag,--yjit,--zjit,--parser=parse.y— each reportingRubyVM::YJIT.enabled?,RubyVM::ZJIT.enabled?and whetherRUBY_DESCRIPTIONcontains+PRISM, so that the flag is the only thing that changes between the rows. - Start a child with
--yjit --yjit-stats, run a hot loop that calls one small method a hundred thousand times, and print which keysRubyVM::YJIT.runtime_statshas (compiled_iseq_count,side_exit_count) and whethercompiled_iseq_countis 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.
See also¶
- Ruby shows you its bytecode and its syntax tree — the instruction sequences the interpreter runs and a JIT compiles
- The GC answers comparisons, not counts — the other subsystem
Process.warmuptouches - Threads and the GVL — the lock that
sys.getswitchintervalis the Python knob for - Ruby versions: what changed — YJIT in 3.1, Prism in 3.3 and 3.4, ZJIT and
Ruby::Boxin 4.0 - The command-line flags —
--yjit,--parserand--dump=insnsamong the rest - Compiled, interpreted, or something between ↗ — the Rust library on why "compiled language" is a category error: a JIT is an implementation's choice of when
-cis not the prompt ↗ — the Python library onpython3 -c, the way both twins start their childrenRubyVM::YJIT↗ andRubyVM↗ —enable,runtime_stats,INSTRUCTION_NAMESandstatsys.flags↗ — every flag a child can report