YJITとZJITの違いを「テスト対策」で例えてみる

目次
  1. はじめに
  2. そもそもJITとは?
  3. YJITについて
  4. ZJITについて
  5. YJITとZJITの違いを整理する
  6. ZJITの最適化を実際に見てみる
  7. YJITとZJITの速度比較
  8. 環境
  9. ベンチマーク結果(対インタプリタ倍率)
  10. おわりに
  11. 参考資料

はじめに

RubyKaigi 2026 がもうすぐ開催されます。セッションの内容を見たり予習したりしていると、YJITやZJITといった言葉を目にすることも多いと思います。

「そもそもJITって何?」「ZJITって最近よく見るけど、YJITと何が違うの?」と思っている方も多いのではないでしょうか。

自分もこれまでは理解が浅く、ぼんやりとしか把握できていませんでした。ですが、昨年に続いて2回目の参加となるRubyKaigiには、もう少し理解を深めてから参加したいと思い、調べてみました。

その中で、YJITとZJITの違いは、大学のテスト対策のアプローチの違いに例えるとイメージしやすいのではないかという発見がありました。この記事では、その例えをベースに違いを整理してみます。

大学でのテスト対策には、大きく2つのタイプがあると思います。

それは、過去問を入手して対策をするタイプと、教科書をしっかり読んで対策をするタイプです。実際には完全にどちらか一方のみの人は少ないと思いますが、どちらに重きを置くかは人によって分かれます。

この記事では、YJITを前者、ZJITを後者に例えてみます。

そもそもJITとは?

JITとは Just-In-Time の略です。ざっくり言うと、よく使うコードはコンパイルしておいて高速に実行できるようにしようという発想の仕組みです。

もう少し詳しく説明をする前に、Rubyのコードが実行される流れに少し触れておきます。

Rubyのコードを実行すると、まず内部でバイトコードという中間的な命令列に変換され、それをYARVという仮想マシン(VM)が実行します。

C言語のように最初から機械語にコンパイルするのではなく、バイトコードを経由して実行するため、Rubyはインタプリタ型の言語に分類されます。

JITは、このバイトコードの中でよく実行される部分を、実行時に機械語にコンパイルすることで高速化します。

最近はYJITやZJITを目にする機会が多いと思いますが、RubyにおけるJITの歴史にはこれらにMJITとRJITが加わった4つのJITが登場します。それぞれの歴史や違いを調べてみると面白いのですが、今回はYJITとZJITに絞って掘り下げていきます。

YJITについて

実行時の情報(型プロファイル)をもとに、よく現れるケースに合わせて局所的に最適化するJITです。

Ruby 3.1で試験的、Ruby 3.2で本格的に導入されたもので、Shopifyが中心となって開発されています。

Ruby単体のデフォルトでは無効ですが、Ruby 3.3で追加された RubyVM::YJIT.enable により、アプリケーション側から有効化できるようになりました。Rails 7.2以降ではRuby 3.3以上で自動的に有効化されるようになっています。

Ruby 4.0でもYJITとZJITの2つのJITがありますが、現状まだ本番利用にはYJITが推奨されています。

手元のコードでYJITを試したい場合、

ruby --yjit hoge.rb

のように実行できます。

簡単なサンプルコードを例にもう少し噛み砕いて説明します。

def total_price(count)
  price = 200
  fee   = 500

  sum = count * price
  sum + fee
end

このような、商品の個数を渡すと合計金額を返してくれるメソッドがあり、これが何度も呼ばれる場面を想定します。

YJITの場合、このメソッドが複数回実行されていく中で、「countの値としてInteger型のものばかりが来るな…」ということに気が付きます。そして、その場合「Integerが来る前提でコンパイルして、高速に処理できるようにしておこう!」と最適化を行います。つまり、実行時プロファイルに基づいて「このケースが多い」と判断し、そのケースに特化したネイティブコードを生成してくれます。

仮にStringなど想定外の型が来た場合は、生成しておいたネイティブコードではなく通常のインタプリタでの処理に戻るので、特別困ることはありません。

もう少し踏み込んだ説明をすると、YJITは Lazy Basic Block Versioning (LBBV) という手法を用いています。これは、メソッド全体を一度に分析するのではなく、分岐のない命令の繋がり(基本ブロック)ごとに、観測された型に合わせてコンパイルしていくもので、YARVバイトコードからLIR(Low-level Intermediate Representation、低レベルな中間表現)を経てネイティブコードを生成します。

これまでYJITについて説明してきましたが、YJITの方式には限界があります。

YJITは局所的な最適化には優れているものの、そのアーキテクチャからメソッド全体を見渡した最適化が難しいです。開発チームも「性能の天井を引き上げ、より大きなコンパイル単位とSSA(Static Single Assignment)IRを導入したい」(Rails at Scale)と述べており、この天井を突破することをモチベーションとして、これから説明するZJITが開発されました1。

ZJITについて

メソッド全体を中間表現に変換し、コード全体を見渡して構造的に最適化するJITです。

Ruby 4.0で実験的に導入されたもので、リリースノートではYJITの次世代として位置づけられています。YJITと同様、Shopifyが中心となって開発しています。なお、現時点ではまだ本番環境への導入は推奨されていません2。

手元のコードでZJITを試したい場合、

ruby --zjit hoge.rb

のように実行できます。

先ほどと同じ total_price メソッドで考えてみます。

def total_price(count)
  price = 200
  fee   = 500

  sum = count * price
  sum + fee
end

ZJITの場合、メソッド全体を見渡して分析します。「price は常に200、fee は常に500だな。それなら count * 200 + 500 と同じかな?」というように、コード全体の構造に基づいて最適化を行います。

もう少し踏み込んだ説明をすると、ZJITはメソッド全体を一度にコンパイルし、SSA形式のHIR(High-level Intermediate Representation)という高レベルな中間表現に変換してから最適化を行います(Rails at Scale)。HIR上で定数畳み込みや型の特殊化、デッドコード除去(DCE)、冗長ロード除去といった最適化パスを適用し、LIRを経てネイティブコードを生成します。

ZJITの設計にはもう一つ特徴があります。開発者のMaxime氏はFeature #21221で「教科書的なアーキテクチャのコンパイラを作ることで、新しいコントリビューターが参加しやすいプロジェクトにしたい」と述べています3。コンパイラの教科書に載っているような標準的な構造を採用することで、コミュニティからの貢献を受けやすくする意図があります。

ZJITはまだ実験段階ではありますが、開発は活発に進んでいます。実際にどの程度の速度が出るのかは、後ほど見ていきます。

YJITとZJITの違いを整理する

ここまでの内容を、冒頭のテスト対策の例えを使って整理してみます。

YJITは過去問を使って勉強するタイプでした。過去問だと、頻出の範囲をピンポイントで学習するので短い時間でテスト対策ができます。しかし出題傾向が変わると対応しづらいという特徴もあります。

それに対して、ZJITは教科書を使って勉強するタイプでした。教科書を読んで対策をすると、全体を見渡した上でより整理された知識が身につきます。

我ながらなかなか良い例えだと思っているのですが、この例えだとZJITの方が優等生であるように感じてしまいます。

しかし実際には、どちらが上ということではなく、得意な方向性が違います。過去問型にも「即効性がある」「実戦に強い」という強みがあり、教科書型にも「全体を見て分析するぶんコストがかかる」「まだ発展途上」という面があります。YJITの方式での性能向上に天井が見えてきたからこそZJITが生まれましたが、現時点では、本番利用にはYJITが推奨されています。

ここまでの内容を表にまとめると以下のようになります。

YJIT ZJIT
例え 過去問型 教科書型
プロファイルの使い方 実行中に観測した型や経路に合わせて特化 インタプリタで集めたプロファイルを使って最適化
コンパイル単位 基本ブロック メソッド全体
主な強み 速く温まりやすく、実戦投入しやすい メソッド全体を見た最適化をしやすい
現状 本番向けの主力 実験段階

ここまでのイメージを持つことができれば、RubyKaigiでJITの話が出てきても、さっぱりわからないということにはならないと思います!

ZJITの最適化を実際に見てみる

ZJITでは、HIRをダンプして最適化の過程を確認できます。ここでは、先ほどの total_price メソッドを使って、メソッド呼び出しがより直接的な命令に置き換わる様子を見てみます。

# 最適化前のHIR
ruby --zjit --zjit-dump-hir-init total_price.rb

# 最適化後のHIR
ruby --zjit --zjit-dump-hir total_price.rb

JITはメソッドが複数回実行されてからコンパイルを行うため、total_price を複数回呼び出すコードを用意して実行します。

以下は最適化前のHIRの一部です。

v34:BasicObject = Send v16, :*, v23
v41:BasicObject = Send v34, :+, v27

Rubyでは * や + もメソッド呼び出しなので、最適化前はそのまま Send(メソッド呼び出し)として扱われています。

一方、以下は最適化後のHIRの同じ部分です。

v50:Fixnum = FixnumMult v49, v23
v53:Fixnum = FixnumAdd v50, v27

最適化前にメソッド呼び出しとして扱われていたものが、FixnumMult と FixnumAdd という整数演算の命令に置き換わっています。ZJITが型情報をもとに、汎用的なメソッド呼び出しを直接的な整数演算に特殊化してくれたことがわかります。

ただし、前提が崩れた場合に備えて GuardType などのガードは残っており、想定外の型が来た場合はインタプリタ側へ戻れるようになっています。

ZJITでは、以下のような最適化パスが順に適用されます(HEAD / 2026-04時点の構成。Ruby 4.0の初期リリースからパスが追加されています)。

YARV バイトコード
  ↓
Initial HIR(SSA形式、最適化前)
  ↓ type_specialize                 … 型プロファイルに基づく特殊化
  ↓ inline                          … 軽量メソッドのインライン化
  ↓ optimize_getivar                … ivar読み込みの最適化
  ↓ optimize_c_calls                … CメソッドをHIR命令に変換
  ↓ optimize_load_store             … 冗長な LoadField/StoreField の除去
  ↓ fold_constants                  … 定数畳み込み
  ↓ clean_cfg                       … 制御フローグラフ整理
  ↓ remove_redundant_patch_points   … 冗長な監視地点の除去
  ↓ eliminate_dead_code             … デッドコード除去
  ↓
Optimized HIR
  ↓
LIR(線形低レベルIR + レジスタ割り当て)
  ↓
ネイティブコード(x86-64 / ARM64)

先ほど見た Send → FixnumMult / FixnumAdd の変換は、type_specialize と optimize_c_calls のパスによるものです。

YJITとZJITの速度比較

LTでは尺の都合でカットしましたが、折角なのでYJITとZJITの速度比較も載せておきます。

Ruby 4.0.2とRuby HEAD(4.1.0dev)の2つのバージョンで、各最適化の効果を見るための自作マイクロベンチマークをClaudeに書いてもらい実行しました。この結果は一般的なアプリケーション全体の傾向を示すものではない点にご注意ください。

環境

Ruby 4.0.2 Ruby HEAD (4.1.0dev)
バージョン 4.0.2 (2026-03-17) 4.1.0dev (2026-04-06)
プラットフォーム arm64-darwin25 arm64-darwin25

ベンチマーク結果(対インタプリタ倍率)

以下はインタプリタ実行時の速度を1.0xとしたときの倍率です(5回計測の中央値、2026-04-15計測)。

Benchmark 4.0.2 YJIT HEAD YJIT 4.0.2 ZJIT HEAD ZJIT
Recursive (fib30) 4.9x 6.0x 0.8x 4.5x
Constant fold+DCE 2.8x 3.5x 1.8x 2.4x
Ivar load-store 2.1x 2.5x 1.2x 1.5x
Polymorphic 2.1x 2.7x 0.9x 1.0x
Frozen const 2.9x 3.1x 1.6x 1.8x
Setivar 2.5x 2.8x 1.6x 1.8x
Type-stable 1.8x 2.2x 1.2x 1.4x
C method inline 1.8x 1.8x 0.9x 1.1x

最も印象的なのは Recursive (fib30) の結果です。ZJITは4.0.2ではインタプリタより遅い0.8xでしたが、HEADでは4.5xと大幅に改善しています。これは Lightweight Frames(PR #16262)をはじめとする複数の改善により、再帰呼び出しのオーバーヘッドが削減されたためです。

一方で、YJITもHEADでは多くの項目で性能が改善しています。特にRecursive(4.9x → 6.0x)やConstant fold+DCE(2.8x → 3.5x)の改善幅が大きく、YJITも着実に進化しています。

今回のベンチマークでは全体的にYJITがリードしていますが、別のベンチマークではZJITがYJITを上回る事例も報告されています。例えば、Rails at Scaleの記事では、インスタンス変数の繰り返し代入においてZJITがYJITの2倍以上の速度を出したケースが紹介されています。ZJITのメソッド全体を見渡す最適化が効く場面では、既にYJITを上回る性能を発揮するケースがあるということです。

おわりに

本記事では、YJITとZJITの違いをテスト対策の例えを使って整理してきました。

YJIT(過去問型)の性能向上に限界が見えてきたことで、ZJIT(教科書型)の開発が始まり、急速に進化しています。

Ruby 4.0のリリースノートでは、「Ruby 4.1でZJITをYJITより速くし、本番環境でも使えるようにすること」が目標として掲げられています4。

先ほどのベンチマークでの改善ペースや、Dead Store Elimination(PR #16507)など、まだマージされていない最適化の存在を考えると、この目標にも現実味が出てきています。

JITはRuby界隈のホットトピックであり、RubyKaigi 2026ではJIT関連のセッションもたくさん予定されています。本記事でイメージを掴んだ上で参加すれば、セッションの理解しやすさはかなり変わるはずです。

それでは、函館でお会いしましょう!

参考資料

Footnotes

  1. 原文: “we want to both raise the performance ceiling (bigger compilation unit size and SSA IR) and encourage more outside contribution” ↩

  2. 原文: “We encourage you to experiment with ZJIT, but maybe hold off on deploying it in production for now.” ↩

  3. 原文: “if we build a compiler with an architecture that is more textbook-like, we could have a project that is more approachable for new contributors” ↩

  4. 原文: “Our goal is to make ZJIT faster than YJIT and production-ready in Ruby 4.1.” ↩