- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — Ruby 4.0 Shipped ZJIT Without Turning It On
- Why Build Another Compiler — The Design Difference Between YJIT and ZJIT
- The Bottleneck That's Left — Pushing the Method Frame
- Lightweight Frames — Write One Pointer, Defer the Rest
- This Idea Already Failed Once, in 2023
- The Actual Numbers — The First PR Barely Sped Anything Up
- The Next Four Months — Stack Spills, the Inliner, and the Version Cap
- The Honest State of Things — ZJIT Still Isn't the Default
- When Not to Use It
- This Story Has Nothing to Do with Parallelism
- Closing
- References
Introduction — Ruby 4.0 Shipped ZJIT Without Turning It On
Ruby 4.0.0 came out on December 25, 2025. The version number jumping to 4.0 instead of 3.5 was news in itself, but on the runtime side, what stood out was ZJIT. In the words of the official release notes: ZJIT is a new JIT compiler being developed as the next generation after YJIT, it requires Rust 1.85.0 or newer to build, and it's enabled only when you pass --zjit.
And the same paragraph carries this sentence.
ZJIT is faster than the interpreter, but not yet as fast as YJIT. We encourage you to experiment with ZJIT, but maybe hold off on deploying it in production for now. Stay tuned for Ruby 4.1 ZJIT.
It's unusual for release notes to ship a new compiler and, in the same breath, say "maybe don't put it into production yet." It's an unusually honest sentence, and at the same time the next sentence is a trailer — stay tuned for Ruby 4.1 ZJIT.
Today is July 16, 2026. It's been a little under seven months since 4.0 came out, and 4.1 hasn't shipped yet. If you trace, commit by commit, what actually got merged in the ZJIT work aimed at 4.1 during that time, there's one thing at the center of the story: Lightweight Frames. This post is about that thing.
Why Build Another Compiler — The Design Difference Between YJIT and ZJIT
First, some background. YJIT already works well, and plenty of places, Shopify included, run it in production. So why would the same team build a new compiler? The ZJIT launch post lays out two reasons.
First, to raise the performance ceiling. YJIT compiles small basic blocks piece by piece, based on LBBV (lazy basic block versioning). This approach warms up fast and is robust to implement, but when the compilation unit is small, the context the compiler can see is small too. ZJIT goes the other way: it lifts a whole method (or a larger unit) into an SSA-form high-level IR (HIR) and compiles that. Once the unit gets bigger, classic optimizations — constant folding, branch folding, type inference — actually start to pay off.
Second, to attract outside contributions. In the launch post's words, becoming "a more conventional method compiler" lowers the barrier to contributing. LBBV is elegant, but it's not the kind of thing that shows up in compiler textbooks. SSA and linear scan do.
That second reason isn't lip service. Looking at the PRs below, Lightweight Frames and the stack spill came from k0kubun (Shopify), the method inliner and the version-count cap from nirvdrum, and block inlining from luke-gruber. The contributor list at the end of the launch post was pulled with git log --pretty="%an" zjit | sort -u, and it runs past 30 names.
The Bottleneck That's Left — Pushing the Method Frame
If you had to name one reason Ruby is slow, it's method-call overhead. YJIT cut a lot of that cost by specializing machine code for various call shapes. But the abstract for k0kubun's session at RubyKaigi 2026 (April 22–24, 2026, Hakodate) says one thing is still left — the process of pushing a Ruby method frame. The session is titled "Lightning-Fast Method Calls with Ruby 4.1 ZJIT," and the abstract says it will discuss, for 4.1, "a compelling reason to switch from YJIT to ZJIT."
To see why a frame push is expensive, look at what the interpreter writes on every call. It pushes one ec->cfp, and fills the new frame with cfp->pc (the program counter), cfp->iseq (which instruction sequence it is), cfp->block_code, cfp->sp, plus local variables and stack slots. In most cases, the machine code the JIT generates only writes these values and never reads them. The readers are the interpreter, exception handling, backtraces, and the debugger.
In other words, most of the metadata dutifully filled in on every call is insurance that almost nobody reads. The launch post's "To do" section put the problem this way.
Right now we have a lot of traffic to the VM frame. JIT frame pushes are reasonably fast, but with every effectful operation, we have to flush our local variable state and stack state to the VM frame. The instances in which code might want to read this reified frame state are rare: frame unwinding due to exceptions,
Binding#local_variable_get, etc. In the future, we will instead defer writing this state until it needs to be read.
Lightweight Frames is that "in the future."
Lightweight Frames — Write One Pointer, Defer the Rest
The design doc is public. Shopify/ruby#909, an issue k0kubun opened on December 10, 2025. The core sentence is the first line.
When ZJIT pushes an interpreter frame, it should write only one metadata pointer, and others should be lazily materialized.
Here's how it works.
- Frame push.
ec->cfpis pushed as usual. But only one thing gets written into the frame — the address of the native stack slot holding the return address, written tocfp->jit_return. On x86_64, that's the address of the stack slot thecallinstruction pushed; on arm64, for JIT-to-JIT calls, it's the address of the slot where the callee's frame setup saved the link register. - Materialize. If
cfp->jit_returnis non-zero, the return address is used as a key to look up a return-address-to-metadata hash table, retrieving metadata built at compile time. The metadata holds the PC, the ISEQ, the stack size, the cme, env flags, the location of self, and the type and location of specval. It also carries the offset to the frame base pointer, which lets you work backward to find stack slots and locals on the native stack. - When it gets materialized. The design doc names three cases — when an exception fires and longjmp unwinds the JIT frame, handing execution back to the interpreter (OSR); when a backtrace is needed (
Kernel#caller, profilers usingrb_profile_frames); and when therb_debug_inspectorAPI dynamically accesses the Binding of a JIT frame.
The merged PR (ruby/ruby#16262, opened February 27, 2026, merged March 27, 34 files, +800/-192) covers the first part of this. In the PR description's terms, it eliminates writes to cfp->pc, cfp->iseq, and most writes to cfp->block_code on JIT-to-JIT calls, writing cfp->jit_return instead.
To spell out what's being erased one more time — three writes per call become one write. That's it. And why that "it" was so hard is the next section.
This Idea Already Failed Once, in 2023
The most valuable part of the design doc isn't the design — it's the Prior art section. The same team tried the same idea before, and it failed.
Frame outlining (2023). An approach Alan Wu and k0kubun experimented with. It marked "this frame is outlined" by putting a tagged pointer into cfp->pc, and if the tag was set, rematerialized the frame from the metadata that pointer referenced. The problem was the cost. Here's the design doc's sentence, verbatim.
Every read of
cfp->pc,cfp->sp, orcfp->iseqhad a branch on whethercfp->pcis tagged or not. If it's a tagged pointer, it points to frame metadata to materialize the outlined frame. Because we made every read ofpc/sp/iseqslower, the interpreter became slower. So we gave it up.
Trying to speed up the JIT ended up slowing the interpreter down. In Ruby, that's a death sentence — an optimization that slows down code running without the JIT turned on is not acceptable.
Lightweight Frames was designed specifically to avoid this trap. The difference is optimism. For most cfp reads, the cfp->jit_return check is optimistically skipped (with an assertion only in debug mode), and the check only happens in the three "must rematerialize" cases named above. In other words, the cost of the check is pushed onto the rare path instead of the common one.
Lazy frame push. Another piece of prior art is ruby/ruby#10080 ("YJIT: Lazily push a frame for specialized C funcs", k0kubun, merged February 23, 2024). This one succeeded, landed in YJIT, and is still in master today. But the approach differs — it doesn't push a frame at all (doesn't push ec->cfp), and only pushes one once the callee is about to raise an exception. Lightweight Frames does push ec->cfp, so the design doc's expectation is that it can eliminate the check overhead that lazy frame push used to pay instead.
What's worth learning here isn't the technique, it's the methodology. This team knew exactly what killed an idea three years ago, and drew the new design to avoid that cause of death. That's why the first thing in PR #16262's benchmark table is interpreter performance. That's where the idea died in 2023.
The Actual Numbers — The First PR Barely Sped Anything Up
This is where this post gets the most honest. The author wrote this, in their own words, in PR #16262's description.
As of this PR, we don't expect a speedup in ZJIT on most benchmarks because the increased overhead of exits outweighs the benefit.
"As of this PR, we don't expect a speedup on most benchmarks." That's a sentence the author wrote themselves while landing an 800-line PR.
Let's look at the numbers. Everything below is measured by k0kubun himself, under ruby-bench, x86_64-linux, ruby 4.1.0dev. The column is before/after, so anything above 1 is an improvement.
First, the interpreter. This is the exact spot where the idea died in 2023.
-------------- ------------- ------------- ------------
bench before (ms) after (ms) before/after
activerecord 140.8 ± 1.4% 138.8 ± 1.0% 1.015
chunky-png 408.4 ± 0.5% 402.4 ± 0.8% 1.015
hexapdf 1419.4 ± 0.8% 1392.1 ± 2.9% 1.020
liquid-c 32.9 ± 1.9% 33.5 ± 4.0% 0.982
railsbench 1331.8 ± 0.4% 1316.8 ± 0.5% 1.011
-------------- ------------- ------------- ------------
In the PR's words, "no significant impact on interpreter performance was observed." The ratios across all 15 benchmarks fall between 0.982 and 1.022, overlapping with the error bars. This isn't evidence of improvement — it's evidence of no regression, and that's exactly what this table is for.
Next, ZJIT.
-------------- ------------- ------------- ------------
bench before (ms) after (ms) before/after
chunky-png 242.0 ± 0.8% 237.3 ± 0.7% 1.020
psych-load 821.7 ± 1.8% 797.7 ± 0.5% 1.030
railsbench 1230.6 ± 0.2% 1232.7 ± 0.2% 0.998
erubi-rails 489.6 ± 2.1% 505.0 ± 1.9% 0.969
lobsters 458.7 ± 0.8% 465.9 ± 2.7% 0.985
shipit 1117.6 ± 2.0% 1160.8 ± 1.4% 0.963
-------------- ------------- ------------- ------------
The PR description sums up this table like this — the headline benchmarks, chunky-png and psych-load, have a high ratio_in_zjit of 96.9–99.8%, and show a stable 1–3% improvement. The rest, with lower ratio_in_zjit, show a slight regression or unstable results.
Translated: the gain showed up only on benchmarks where ZJIT compiled almost all the code, and everywhere else it actually got a bit slower. shipit slowed down 3.7% and erubi-rails 3.1%. railsbench is effectively flat. Summarizing this as "ZJIT got 2–3% faster" would be a lie.
In a microbenchmark that isolates only JIT-to-JIT calls, this is what comes out.
----- ----------- ----------- ------------
bench before (ms) after (ms) before/after
fib 26.6 ± 0.1% 25.4 ± 0.1% 1.049
----- ----------- ----------- ------------
A 4.9% improvement. But this is the best-case condition the author picked specifically to isolate this change — fib is a function that does almost nothing besides push a frame. Don't expect this number in a real application.
Memory — Not Free
And there's a cost. Here are the numbers the PR reports, based on lobsters.
before after delta
code_region_bytes 11,452,416 12,296,192 +843,776 (+7%)
zjit_alloc_bytes 18,331,996 19,605,972 +1,273,976 (+7%)
Code size grew 7% (843KB), and the Rust-side heap also grew 7% (1.27MB). The reason is obvious — since the values that weren't written into the frame have to be written on the way out, the side-exit code grows to match.
Following the PR's arithmetic exactly: there are 76,817 compiled side exits, and each needs 14 bytes of movabs + mov to write cfp->iseq, so 76,817 × 14 = 1,075,438 bytes should have been added. But what actually got added was 843,776 bytes. The PR makes an interesting inference here — since the code-size growth fell short of 1MB, the inline code for method calls must actually have gotten smaller. It grew about 230,000 bytes less than predicted, and that difference is the code saved on the frame push.
It's rare for a PR description to dissect its own numbers this thoroughly. And the arithmetic really does check out — down to the last comma-separated digit.
The Next Four Months — Stack Spills, the Inliner, and the Version Cap
PR #16262 was deliberately incomplete. The description says — reducing exits going forward will ease most of the slowdowns, and fixing the VM stack spill on top of Lightweight Frames will probably tip things into an overall improvement. But to avoid future diffs getting too large, the core lands separately first.
Those promised follow-ups actually landed. Here's what's visible in master.
- #17387 ZJIT: Avoid eager stack spills for inlined frame pushes (k0kubun, merged July 9, 2026, +862/-563). This is exactly the stack-spill work that was promised. The inlined
FrameState'sJITFramealso encodes the caller-side stack slots, so materializing just the most recentJITFramespills the stack slots of every CFP that function inlined. - #16966 ZJIT: Add method inliner (nirvdrum, merged June 13, 2026, +2564/-194). The method inliner. The author explicitly called it "fully functional, but more of a starting point than a production-grade inliner," and it landed disabled by default.
- #17484 ZJIT: Enable the inliner by default (nirvdrum, merged June 30, 2026). Flipped to enabled by default 12 days later.
- #17562 ZJIT: Bump the
--zjit-max-versionsdefault to 4 (nirvdrum, merged July 6, 2026). Raised the per-ISEQ compiled-version cap from 2 to 4. - #17653 (luke-gruber, merged July 10, 2026). Inlines
yieldblock-frame pushes and the JIT-to-JIT call to their iseq. A follow-up to the block inlining that #16966 called out as its "biggest limitation."
What the Inliner Actually Does
What's interesting about the inliner PR isn't inlining itself — it's what inlining opens up. In the author's words.
Simply eliminating method dispatch by inlining method bodies is interesting, but undersells what inlining enables. The real benefit is the extra context we have for making optimization decisions.
So ZJIT's optimize pipeline was wrapped in a loop — type specialization → inlining → follow-up optimization, repeated until inlining reaches a fixed point. A cap was added to prevent runaway iteration, defaulting to 10 (--zjit-inline-max-iterations).
Here too, the author caveats their own observation.
While working with ruby-bench I found we rarely looped more than 2 or 3 times, at least with the currently defined defaults for inline threshold and budget. As those defaults were chosen arbitrarily, that's not a very strong claim.
"Since the defaults were chosen arbitrarily, that's not a very strong claim." That same honesty is preserved right in the source. Here's the comment above the constant in zjit/src/options.rs.
/// Default --zjit-inline-threshold
/// TODO (nirvdrum 2026-06-25): 30 has proven to work well with ruby-bench, but we should finely
/// tune across more workloads.
pub const DEFAULT_INLINE_THRESHOLD: InlineThreshold = 30;
--zjit-inline-budget (default 200) carries the same shape of TODO. In other words, today's ZJIT inliner defaults are validated only against ruby-bench, and the authors documented exactly that.
And One Number to Watch Out For
Here are the numbers PR #17562, which bumped --zjit-max-versions from 2 to 4, offered as justification. Measured by nirvdrum himself, under a single optcarrot benchmark, x86_64-linux.
--zjit-max-versions=2: #1: 2315ms #2: 2299ms #3: 2288ms
--zjit-max-versions=4: #1: 829ms #2: 734ms #3: 732ms
From 2288ms to 732ms is 3.1x. That's a big number, which is exactly why the conditions need a close look. The measurement command was WARMUP_ITRS=0 MIN_BENCH_ITRS=3 MIN_BENCH_TIME=0 — no warmup, 3 iterations. And the only data the PR presents is optcarrot alone; there's no full ruby-bench table. The claim in the PR body goes exactly that far — "while digging into ZJIT together, we found that bumping --zjit-max-versions gives a substantial improvement."
What this number doesn't tell you: whether the same holds on other benchmarks, whether it holds in steady state after warmup, or how much memory it costs to compile twice as many versions. 3.1x is a fact, but the sentence "ZJIT got 3x faster" is not supported by this data.
The Honest State of Things — ZJIT Still Isn't the Default
This is the conclusion of this post. The RubyKaigi 2026 abstract said it would talk about "a reason to switch from YJIT to ZJIT," and over four months, all the PRs above landed. So has ZJIT become the default JIT for 4.1?
No. As of today (July 16, 2026), ruby.c in ruby/ruby master reads like this.
#if !USE_YJIT && USE_ZJIT
DEFINE_FEATURE(jit) = feature_zjit,
#else
DEFINE_FEATURE(jit) = feature_yjit,
#endif
#if USE_YJIT
# define DEFAULT_JIT_OPTION "--yjit"
#elif USE_ZJIT
# define DEFAULT_JIT_OPTION "--zjit"
#endif
Reading it: as long as YJIT is in the build, --jit means YJIT. ZJIT only becomes the default when YJIT isn't built in at all. And since #15368 made ZJIT compile in by default alongside YJIT whenever the build environment allows it, that condition doesn't hold for an ordinary build.
One more thing worth noting — to begin with, Ruby doesn't turn on any JIT automatically. You have to specify --yjit or --zjit explicitly. --jit means "turn on this build's default JIT," and ruby --help's description says exactly that: "Enable the default JIT for the build; same as --yjit." So the precise meaning of "ZJIT isn't the default" is this — when you ask for a JIT, what you still get back is YJIT.
4.1 hasn't shipped yet, and a plan is a plan. For the switchover to actually happen, there needs to be a commit that flips the #if above — and as of today, that commit doesn't exist.
When Not to Use It
To sum up.
Don't put ZJIT into production right now. This isn't my opinion — it's a sentence from the team that built it. The release notes wrote "maybe hold off on deploying it in production for now," and the launch post went a step further: "this is a very new compiler. Expect crashes and extreme performance degradation (or improvement)."
If you upgraded to 4.0 for performance, the answer is still YJIT. One thing worth being clear about here — the tables reproduced above are not a ZJIT-vs-YJIT comparison. They compare ZJIT before Lightweight Frames against ZJIT after, so those numbers cannot support a claim like "ZJIT is X% slower than YJIT." The basis for "ZJIT is not yet as fast as YJIT" isn't a table — it's the sentences in the release notes and the launch post themselves, both of which explicitly say "not yet as fast as YJIT." I could not find any published material that pins down the ZJIT-vs-YJIT gap as of 4.0 in numbers. The rubybench dashboard the launch post points to is a live graph whose values change over time, so I have not carried its numbers over here.
So when do you use it? Run your test suite once with --zjit — that's exactly what the launch post explicitly recommends. If you find a crash or a misbehavior, it asks you to report it to the Ruby issue tracker or GitHub. And this actually paid off — as of 4.0, ZJIT passed Ruby's own full test suite, Shopify's large app test suite plus shadow traffic, and GitHub.com's test suite.
If you want to turn the inliner off. #17484 spelled out the cost when it enabled the inliner by default — inlining increases compile time and memory. If it becomes a problem, you can disable it by setting the threshold to 0. But the PR body writes it as --zjit-inliner-threshold=0, while the option name the actual source (zjit/src/options.rs) parses is inline-threshold. So what actually works is --zjit-inline-threshold=0. This looks like a typo on the PR description's side.
This Story Has Nothing to Do with Parallelism
One easily-confused point to clear up before moving on. ZJIT is a story about single-threaded execution speed. Ruby's parallelism story is a separate one, and that's Ractor.
The Ruby 4.0 release notes carried some fairly large changes on the Ractor side too — Ractor::Port came in as a new synchronization mechanism, removing Ractor.yield and Ractor#take; Ractor#join and Ractor#value were added to mirror Thread#join/Thread#value; and a lot of internal data structures were improved to "significantly reduce contention on the global lock." And the release notes write that Ractor landed as an experimental feature in 3.0, and the goal is to drop that experimental label "next year."
If you're curious about dropping the global lock, the Python 3.15's abi3t piece takes that exact problem head-on — it covers the deployment problems left over after dropping the GIL. Ruby and Python's approaches differ (Ruby doesn't drop the GVL, opting instead for Ractor as isolated parallel execution units), but it's an interesting symmetry that the two languages hit the same kind of wall around the same time. I covered the broader Ruby ecosystem landscape separately in Modern Ruby & Rails 2026.
Either way, no matter how fast ZJIT gets, it doesn't intersect with the Ractor story. They're different axes.
Closing
To sum up. Ruby 4.0 shipped ZJIT but didn't turn it on, and the release notes themselves wrote "not yet as fast as YJIT." The center of the work aimed at 4.1 is Lightweight Frames, and the idea is simple — since almost nobody reads most of the metadata dutifully filled into the frame on every method call, write just one pointer and rematerialize the rest only when it's genuinely read, as with a backtrace, an exception, or a Binding.
What makes this idea valuable isn't that it's new — it's that it's the second attempt. 2023's frame outlining added a branch to every cfp read, which slowed the interpreter and got it abandoned. Lightweight Frames was designed knowing that cause of death, which is why the PR's first benchmark table is the one proving "the interpreter didn't get slower."
And that PR didn't make anything faster on most benchmarks. The author said so themselves. Code grew 7% and the heap grew 7%. The gain was only 1–3%, on the two benchmarks where ZJIT compiled more than 97% of the code. That was the honest state of things in March, and stack spills, the inliner, and block inlining piled on top of it over the next four months.
So today's answer is this — use YJIT in production. ZJIT is at the stage of running it once against your test suite and filing a report if it breaks. And the sentence "ZJIT becomes the default in Ruby 4.1" is still a plan, not a fact. It becomes a fact when the #if in ruby.c flips. That commit is this story's real ending, and we'll find out in about five months.
If groundwork looks boring, that's what groundwork is.
References
- Ruby 4.0.0 Released — Official Release Notes (2025-12-25)
- ZJIT is now available in Ruby 4.0 — Max Bernstein, Rails at Scale (2025-12-24)
- Shopify/ruby#909 — ZJIT: Lightweight Frames design doc (including prior art)
- ruby/ruby#16262 — ZJIT: Lightweight Frames (merged 2026-03-27, benchmark and memory numbers)
- ruby/ruby#16966 — ZJIT: Add method inliner (merged 2026-06-13)
- ruby/ruby#17484 — ZJIT: Enable the inliner by default (merged 2026-06-30)
- ruby/ruby#17562 — ZJIT: Bump the --zjit-max-versions default to 4 (merged 2026-07-06)
- ruby/ruby#17387 — ZJIT: Avoid eager stack spills for inlined frame pushes (merged 2026-07-09)
- ruby/ruby#17653 — ZJIT: inline yield block frame pushes (merged 2026-07-10)
- ruby/ruby#10080 — YJIT: Lazily push a frame for specialized C funcs (merged 2024-02-23, prior art)
- zjit/src/options.rs — source of the defaults and TODO comments
- Lightning-Fast Method Calls with Ruby 4.1 ZJIT — k0kubun, RubyKaigi 2026 (Hakodate, April 22–24)
- A new Register Allocator for ZJIT — Aaron Patterson, Rails at Scale (2026-05-27)
- Ruby — endoflife.date (release date cross-check)