Skip to content
Published on

ZJITのLightweight Frames — Ruby 4.1でメソッドフレームプッシュを消す方法とその代償

シェア
Authors

はじめに — Ruby 4.0はZJITを有効にしないまま出荷した

Ruby 4.0.0は2025年12月25日に出ました。バージョン番号が3.5ではなく4.0になったこと自体がニュースでしたが、ランタイム側で目を引いたのはZJITでした。公式リリースノートの表現をそのまま移すとこうです — ZJITはYJITの次世代として開発されている新しいJITコンパイラで、ビルドにはRust 1.85.0以上が必要であり、--zjitを指定して初めて有効になる。

そして同じ段落にこの一文が続きます。

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.

新しいコンパイラをリリースに載せながら「まだプロダクションには入れないでほしい」と書くリリースノートは珍しいものです。それだけ正直な一文であり、同時に次の一文は予告編です — Ruby 4.1のZJITにご期待ください。

今日は2026年7月16日です。4.0が出てから7か月弱が経ち、4.1はまだ出ていません。その間に4.1を狙ったZJITの作業で実際に何がマージされたのかをコミット単位で追っていくと、話の中心にLightweight Framesというものがあります。本稿はそれについての記事です。

なぜまた新しいコンパイラを作るのか — YJITとZJITの設計の違い

まず背景から。YJITはすでにうまく動いており、Shopifyをはじめ多くの現場がプロダクションで使っています。それなのに同じチームがなぜ新しいコンパイラを作るのか。ZJITのローンチ記事は理由を2つに整理しています。

第一に、性能の天井を上げるため。 YJITはLBBV(lazy basic block versioning)に基づき、小さなブロック単位を断片的にコンパイルします。この方式はウォームアップが速く実装も堅牢ですが、コンパイル単位が小さいとコンパイラが見渡せる文脈も小さくなります。ZJITは逆に、メソッド全体(あるいはより大きな単位)をSSA形式の高水準IR(HIR)に持ち上げてコンパイルします。単位が大きくなれば、定数畳み込み、分岐畳み込み、型推論といった伝統的な最適化が実際に効き始めます。

第二に、外部からの貢献を受けるため。 ローンチ記事の表現では「より伝統的なメソッドコンパイラになることで」貢献の敷居を下げるということです。LBBVは美しいものの、コンパイラの教科書に出てくる代物ではありません。SSAとlinear scanは出てきます。

この第二の理由はリップサービスではありません。以下で見るPR群を見ると、Lightweight Framesとスタックスピルはk0kubun(Shopify)、メソッドインライナーとバージョン数上限はnirvdrum、ブロックインライニングはluke-gruberが入れています。ローンチ記事の末尾にある貢献者一覧はgit log --pretty="%an" zjit | sort -uで抽出したもので、30名を超えています。

残るボトルネック — メソッドフレームのプッシュ

Rubyが遅い理由を一つだけ挙げるなら、メソッド呼び出しのオーバーヘッドです。YJITは複数の呼び出し形態に対して機械語を特化することでこのコストを大きく削りました。ところがRubyKaigi 2026でk0kubunが発表するセッションの概要(2026年4月22–24日、函館)は、まだ一つ残っていると述べています — Rubyのメソッドフレームをプッシュする過程です。セッションタイトルは「Lightning-Fast Method Calls with Ruby 4.1 ZJIT」で、概要には「YJITからZJITへ乗り換えるだけの説得力ある理由」を4.1で論じると書かれています。

フレームプッシュがなぜ高くつくのかを見るには、インタプリタが呼び出しごとに何を書き込んでいるかを見ればわかります。ec->cfpを一つ押し上げ、その新しいフレームにcfp->pc(プログラムカウンタ)、cfp->iseq(どの命令シーケンスか)、cfp->block_codecfp->sp、そしてローカル変数とスタックスロットを埋めていきます。JITが生成する機械語は、ほとんどの場合これらの値を書き込むだけで読み込みません。 読む側はインタプリタ、例外処理、バックトレース、デバッガです。

つまり呼び出しごとに律儀に埋めているメタデータの大半は、ほとんど誰も読まない保険なのです。ローンチ記事の「To do」項目はこの問題をこう書いています。

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がその「in the future」です。

Lightweight Frames — ポインタ一つだけ書いて、残りは先送りする

設計文書は公開されています。Shopify/ruby#909、k0kubunが2025年12月10日に開いたイシューです。核心となる一文は最初の行です。

When ZJIT pushes an interpreter frame, it should write only one metadata pointer, and others should be lazily materialized.

動作はこうです。

  1. フレームプッシュ。 ec->cfpは通常どおり押し上げる。ただしフレームに書き込むのはたった一つ — 戻りアドレスを保持しているネイティブスタックスロットのアドレスをcfp->jit_returnに書く。x86_64ではcall命令が押し込んだスタックスロットのアドレスがそれであり、arm64ではJIT-to-JIT呼び出しの場合、コーリー側のフレームセットアップがリンクレジスタを保存したスロットのアドレスを使う。
  2. 復元(materialize)。 cfp->jit_returnが0でなければ、その戻りアドレスをキーにして「戻りアドレスからメタデータへ」のハッシュテーブルを引き、コンパイル時に作っておいたメタデータを得る。メタデータにはPC、ISEQ、スタックサイズ、cme、envフラグ、selfの位置、specvalの型と位置が入っている。ここにフレームベースポインタまでのオフセットも一緒に入っており、ネイティブスタックからスタックスロットとローカルを逆算できる。
  3. いつ復元するか。 設計文書は3つを挙げています — 例外が発生してlongjmpがJITフレームを巻き戻し、インタプリタが実行を引き継ぐとき(OSR)、バックトレースが必要なとき(Kernel#callerrb_profile_framesを使うプロファイラ)、そしてrb_debug_inspector APIでJITフレームのBindingに動的にアクセスするとき。

マージされたPR(ruby/ruby#16262、2026年2月27日作成、3月27日マージ、34ファイル+800/-192)の範囲はこのうちの前半です。PR説明の表現では、JIT-to-JIT呼び出しにおいてcfp->pccfp->iseq、そして大半のcfp->block_codeへの書き込みをなくし、代わりにcfp->jit_returnを書くというものです。

消えるものをもう一度確認すると — 呼び出しごとに3回書いていたのを1回書くだけ。それだけです。そしてその「それだけ」がなぜそんなに難しいのかが次節です。

このアイデアは2023年にすでに一度失敗している

設計文書でもっとも価値があるのは設計そのものではなく、Prior artの節です。同じチームが同じアイデアを以前に試し、失敗していました。

Frame outlining(2023年)。 Alan Wuとk0kubunが実験した方式です。cfp->pcにタグ付きポインタを入れて「このフレームはアウトラインされている」と示し、タグが立っていればそのポインタが指すメタデータからフレームを復元する構造でした。問題はその代償でした。設計文書の文をそのまま移すとこうです。

Every read of cfp->pc, cfp->sp, or cfp->iseq had a branch on whether cfp->pc is tagged or not. If it's a tagged pointer, it points to frame metadata to materialize the outlined frame. Because we made every read of pc/sp/iseq slower, the interpreter became slower. So we gave it up.

JITを速くしようとしてインタプリタを遅くしてしまったのです。Rubyにおいてこれは死刑宣告に等しいものです — JITを有効にしていないコードが遅くなる最適化は受け入れられません。

Lightweight Framesはまさにこの罠を避けるために設計されました。違いは楽観性です。ほとんどのcfp読み込みではcfp->jit_returnのチェックを楽観的にスキップし(デバッグモードでのみアサーションを入れ)、上で挙げた「復元が必要な3つの条件」でだけチェックします。つまりチェックのコストを、よくある経路ではなく稀な経路に押し付けているわけです。

Lazy frame push。 もう一つの先行作業はruby/ruby#10080(「YJIT: Lazily push a frame for specialized C funcs」、k0kubun、2024年2月23日マージ)です。これは成功してYJITに入り、今もmasterにあります。ただしアプローチが異なります — そもそもフレームをプッシュせず(ec->cfpを押さず)、コーリーが例外を投げようとして初めてプッシュします。Lightweight Framesはec->cfpを押すので、その代わりにlazy frame pushが払っていたチェックのオーバーヘッドをなくせるはずだ、というのが設計文書の見立てです。

ここで学ぶべきは技術ではなく方法論です。このチームは3年前に死んだアイデアの死因を正確に把握しており、その死因を避けるように新しい設計を描きました。だからこそPR #16262のベンチマーク表で最初に出てくるのがインタプリタ性能なのです。それが2023年に死んだ場所だからです。

実際の数字 — 最初のPRはほとんど速くならなかった

ここからが本稿でもっとも正直になる部分です。PR #16262の説明を著者本人がこう書いています。

As of this PR, we don't expect a speedup in ZJIT on most benchmarks because the increased overhead of exits outweighs the benefit.

「このPRの段階では、ほとんどのベンチマークで速度向上は期待していない」ということです。800行のPRを入れながら著者自身が書いた一文です。

数字を見てみましょう。以下はすべてk0kubun本人が計測した数値であり、条件はruby-bench、x86_64-linux、ruby 4.1.0devです。列の意味はbefore/afterなので、1より大きければ改善です。

まずインタプリタから。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
--------------  -------------  -------------  ------------

PRの表現では「インタプリタ性能に有意な影響は観察されなかった」。全15ベンチマークの比率は0.982から1.022の間に収まっており、誤差範囲と重なっています。これは改善ではなく回帰がないことの証拠であり、まさにそれがこの表の目的です。

次は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
--------------  -------------  -------------  ------------

PR説明はこの表をこうまとめています — 目玉ベンチマークであるchunky-pngとpsych-loadはratio_in_zjitが96.9–99.8%と高く、そこでは1–3%程度の改善が安定して出る。ratio_in_zjitが低い残りは、わずかな回帰を示すか結果が不安定である。

言い換えるとこうです。ZJITがコードのほぼ全部をコンパイルしたベンチマークだけで利得が出て、それ以外はむしろ少し遅くなりました。 shipitは3.7%遅くなり、erubi-railsは3.1%遅くなりました。railsbenchは事実上横ばいです。これを「ZJITが2〜3%速くなった」とまとめるのは嘘になります。

JIT-to-JIT呼び出しだけを残したマイクロベンチマークでは、こう出ます。

-----  -----------  -----------  ------------
bench  before (ms)   after (ms)  before/after
fib    26.6 ± 0.1%  25.4 ± 0.1%   1.049
-----  -----------  -----------  ------------

4.9%の改善です。ただしこれは著者がこの変更を切り分けて見るために選んだ最良の条件です — fibはフレームプッシュ以外ほとんど何もしない関数です。実際のアプリケーションでこの数字を期待してはいけません。

メモリ — タダではない

そして代償があります。lobsters基準でPRが報告した数値です。

                     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%)

コードサイズが7%増え(843KB)、Rust側のヒープも7%増えました(1.27MB)。理由は明白です — フレームに書かなかった値は抜けるときに書かなければならないので、サイドイグジットのコードがその分大きくなります。

PRの計算をそのままたどるとこうです。コンパイルされたサイドイグジットが76,817個あり、それぞれcfp->iseqを書くためにmovabs + movで14バイトが必要なので、76,817 × 14 = 1,075,438バイトが増えるはずです。ところが実際に増えたのは843,776バイトでした。PRはここで興味深い推論をしています — コードサイズの増加が1MBに満たなかったのだから、メソッド呼び出しのインラインコードはむしろ小さくなったようだと。予測より23万バイトほど少なく増えており、その差額がフレームプッシュで節約されたコードだ、というわけです。

これほど自分の数字を掘り下げて説明するPR説明は珍しいものです。そしてこの計算は実際に合っています — 3桁区切りのカンマまで検算が取れます。

その後の4か月 — スタックスピル、インライナー、バージョン数

PR #16262は意図的に未完成でした。説明にはこう書かれています — 「今後イグジットを減らせば大半のスローダウンは緩和され、Lightweight Framesの上にVMスタックスピルを直せば、おそらく全体として改善に転じるだろう。ただし将来のdiffが大きくなりすぎるのを避けるため、コア部分を先に単独でランディングする」。

その予告された後続作業が実際に入りました。masterで確認できるものです。

  • #17387 ZJIT: Avoid eager stack spills for inlined frame pushes(k0kubun、2026年7月9日マージ、+862/-563)。予告どおりのスタックスピル作業です。インライン化されたFrameStateJITFrameがコーラー側のスタックスロットまでエンコードするため、直近のJITFrame一つだけを復元すれば、その関数がインライン化したすべてのCFPのスタックスロットが一緒にスピルされます。
  • #16966 ZJIT: Add method inliner(nirvdrum、2026年6月13日マージ、+2564/-194)。メソッドインライナーです。著者は「完全に動作するが、プロダクション級のインライナーというより出発点だ」と明記しており、デフォルト無効で入りました。
  • #17484 ZJIT: Enable the inliner by default(nirvdrum、2026年6月30日マージ)。12日後にデフォルト有効へと切り替わりました。
  • #17562 ZJIT: Bump the --zjit-max-versions default to 4(nirvdrum、2026年7月6日マージ)。ISEQごとにコンパイルするバージョン数の上限を2から4へ。
  • #17653(luke-gruber、2026年7月10日マージ)。yieldブロックフレームのプッシュと、そのiseqへのJIT-to-JIT呼び出しをインライン化。#16966が「最大の限界」として挙げていたブロックインライニングの後続です。

インライナーが本当にやっていること

インライナーPRで面白いのはインライニング自体ではなく、それが開くものです。著者の一文です。

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.

そこでZJITのoptimizeパイプラインをループで包みました — 型特化 → インライニング → 後続最適化を、インライニングが不動点(fixed-point)に到達するまで繰り返します。暴走を防ぐために上限を設けており、デフォルトは10回(--zjit-inline-max-iterations)です。

ここでも著者は自分の観察にキャビエットを付けています。

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.

「デフォルト値は恣意的に選んだので、強い主張ではない」。この正直さはソースコードにもそのまま残っています。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(デフォルト200)にも同じ形のTODOが付いています。つまり今日のZJITインライナーのデフォルト値はruby-benchでしか検証されていない値であり、著者らはそれをそのまま文書化しているのです。

そして注意すべき数字が一つ

--zjit-max-versionsを2から4へ上げたPR #17562が根拠として示した数値です。nirvdrum本人の計測であり、条件はoptcarrot単一ベンチマーク、x86_64-linuxです。

--zjit-max-versions=2:  #1: 2315ms  #2: 2299ms  #3: 2288ms
--zjit-max-versions=4:  #1:  829ms  #2:  734ms  #3:  732ms

2288msから732msなら3.1倍です。大きな数字であり、だからこそ条件を正確に見る必要があります。計測コマンドはWARMUP_ITRS=0 MIN_BENCH_ITRS=3 MIN_BENCH_TIME=0です — ウォームアップなし、3回反復です。そしてPRが示したデータはoptcarrot一つだけで、ruby-bench全体の表はありません。PR本文の主張もそこまでです — 「ZJITを一緒に調べていて、--zjit-max-versionsの値を上げるとかなりの改善があることを発見した」。

この数値が語っていないこと — 他のベンチマークでも同じか、ウォームアップ後の定常状態でも同じか、バージョンを2倍作るメモリの代償がいくらか。3.1倍は事実ですが、「ZJITが3倍速くなった」という文はこのデータが支持しません。

正直な現状 — ZJITはまだデフォルトではない

ここが本稿の結論です。RubyKaigi 2026の概要は「YJITからZJITへ乗り換える理由」を語ると述べ、4か月の間に上記のPRがすべて入りました。それでZJITは4.1のデフォルトJITになったのでしょうか。

なっていません。今日(2026年7月16日)時点のruby/ruby masterのruby.cはこうなっています。

#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

読むとこうです — YJITがビルドに含まれている限り、--jitはYJITです。 ZJITがデフォルトになるのは、YJITがそもそもビルドされていない場合だけです。そして#15368により、ZJITはビルド環境が許せばYJITと一緒にデフォルトでコンパイルされるようになったので、通常のビルドではこの条件は成立しません。

もう一点付け加えると — RubyはそもそもどのJITも自動では有効にしません。 --yjitであれ--zjitであれ、明示する必要があります。--jitは「このビルドのデフォルトJITを有効にせよ」という意味で、ruby --helpの説明もそのまま「Enable the default JIT for the build; same as --yjit」です。つまり「ZJITがデフォルトではない」という言葉の正確な意味はこうです — JITをくれと言ったときに返ってくるのは依然としてYJITだ、ということです。

4.1はまだリリースされておらず、計画は計画です。乗り換えが実際に起こるには上の#ifが反転するコミットが必要ですが、今日時点でそのコミットはありません。

いつ使うべきでないか

まとめるとこうです。

今、ZJITをプロダクションに入れないでください。 これは私の意見ではなく、作ったチーム自身の言葉です — リリースノートは「maybe hold off on deploying it in production for now」と書き、ローンチ記事はさらに踏み込んで「これはとても新しいコンパイラだ。クラッシュや極端な性能低下(あるいは向上)を予想すべきだ」と書いています。

性能のために4.0へ上げたのなら、答えは依然としてYJITです。 ここで一つはっきりさせておくべき点があります — 上に載せた表はZJIT対YJITの比較ではありません。Lightweight Framesを入れる前のZJITと入れた後のZJITを比較したものであり、だからあの数字で「ZJITはYJITより何%遅い」とは言えません。ZJITがまだYJITほど速くないという根拠は表ではなく、リリースノートとローンチ記事の文そのものです — どちらも「not yet as fast as YJIT」と明記しています。4.0時点でのZJIT対YJITの差を数値で示した公開資料は私には見つけられませんでした。ローンチ記事が指すrubybenchダッシュボードは時点によって値が変わる生きたグラフなので、ここに数字として移していません。

では、いつ使うのか。 テストスイートを--zjitで一度回してみること — ローンチ記事が明示的に勧めているのはそれです。クラッシュや誤動作を見つけたらRubyのイシュートラッカーかGitHubに報告してほしいとされています。そして実際にこれは価値がありました — 4.0時点でZJITは、Ruby本体の全テストスイート、Shopifyの大規模アプリのテストスイートとシャドウトラフィック、GitHub.comのテストスイートを通過しています。

インライナーを切りたい場合。 #17484がインライナーをデフォルトで有効にする際に代償を明記しています — インライニングはコンパイル時間とメモリを増やします。問題になる場合は閾値を0にすることで切れます。ただしPR本文には--zjit-inliner-threshold=0と書かれていますが、実際のソース(zjit/src/options.rs)がパースするオプション名はinline-thresholdです。つまり実際に動くのは--zjit-inline-threshold=0です。PR説明側のタイプミスと思われます。

この話は並列性とは無関係

ここで混同しやすい点を一つ整理しておきます。ZJITは単一スレッドの実行速度の話です。Rubyの並列性の話は別物であり、それはRactorです。

Ruby 4.0のリリースノートはRactor側にもかなり大きな変更を載せています — Ractor::Portが新しい同期メカニズムとして入り、Ractor.yieldRactor#takeが廃止され、Ractor#joinRactor#valueThread#join/Thread#valueに対応する形で追加され、「グローバルロックへの競合を大きく減らすように」内部データ構造が多く改善されました。そしてリリースノートはこう書いています — Ractorは3.0で実験的機能として入り、「来年」その実験というラベルを外すことを目標とする。

グローバルロックを外す話に興味があれば、同じ問題を正面から扱ったPython 3.15のabi3tの回をご覧ください — GILを外した後に残るデプロイの問題を扱っています。RubyとPythonのアプローチは異なりますが(Rubyは GVLを外す代わりにRactorで隔離された並列実行単位を置く方向)、2つの言語が同じ時期に同種の壁にぶつかったというのは興味深い対称性です。Rubyエコシステム全体の地形はモダン Ruby & Rails 2026の回で別途整理しています。

いずれにせよ、ZJITがどれだけ速くなろうとも、それはRactorの話とは交わりません。両者は別の軸です。

おわりに

まとめるとこうです。Ruby 4.0はZJITを搭載しましたが有効にせず、リリースノートは自ら「まだYJITほど速くない」と書きました。4.1を狙った作業の中心はLightweight Framesであり、アイデアはシンプルです — メソッド呼び出しのたびにフレームへ律儀に埋めていたメタデータの大半をほとんど誰も読まないのなら、ポインタを一つだけ書いて、残りはバックトレース・例外・Bindingのように本当に読まれるときに復元しよう、というものです。

このアイデアが価値を持つのは新しいからではなく、2度目の挑戦だからです。2023年のframe outliningはcfp読み込みのたびに分岐を入れてしまい、インタプリタを遅くして放棄されました。Lightweight Framesはその死因を知ったうえで設計されており、だからこそPRの最初のベンチマーク表は「インタプリタが遅くならなかった」ことを証明する表なのです。

そしてそのPRは、ほとんどのベンチマークで何も速くしませんでした。著者本人がそう書いています。コードは7%大きくなり、ヒープも7%大きくなりました。利得はZJITがコードの97%以上をコンパイルした2つのベンチマークでの1–3%だけでした。それが正直な3月の姿であり、その上にスタックスピルとインライナーとブロックインライニングが4か月かけて積み上がりました。

だから今日の答えはこうです — プロダクションならYJITを使ってください。ZJITはテストスイートで一度回してみて、壊れたら報告する段階です。そして「Ruby 4.1でZJITがデフォルトになる」という文はまだ計画であって事実ではありません。ruby.c#ifが反転すれば、そのとき事実になります。そのコミットこそがこの話の本当の結末であり、5か月ほど先に分かることになります。

基礎工事が退屈に見えるなら、それがもともと基礎工事というものです。

参考資料