Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby 4.0に導入されたZJITの強化点を詳しく解説(翻訳)

概要

CC BY-NC-SA 4.0 International Deedに基づいて翻訳・公開いたします。

CC BY-NC-SA 4.0 Deed | 表示 - 非営利 - 継承 4.0 国際 | Creative Commons

日本語タイトルは内容に即したものにしました。

ZJITとYJITは同時に有効にできないので、ZJITを有効にする場合はYJITを無効にしておく必要があります(RUBY_YJIT_ENABLE=0環境変数を設定するなど)。

Ruby 4.0に導入されたZJITの強化点を詳しく解説(翻訳)

ZJITはRubyの新しいjust-in-time(JIT)コンパイラであり、Rubyのリファレンス実装であるYARVというVMに組み込まれています。ZJITは、YJITと同じコンパイラグループによって開発されました。私たち(Aaron Patterson、Aiden Fox Ivey、Alan Wu、Jacob Denbeaux、Kevin Menard、Max Bernstein、Maxime Chevalier-Boisvert、Randy Stauner、Stan Lo、Takashi Kokubun)は2025年初頭からZJIT開発を手がけてきました。

前回の記事をお読みでない方向けに補足すると、私たちはRubyで使う新しいコンパイラを構築しています。パフォーマンスの上限をもっと引き上げる(コンパイル単位のサイズを拡大して静的単一代入(SSA: Static Single Assignment)中間表現(IR: Intermediate Representation)を増やす)のと、YJITよりも伝統的なメソッドコンパイラにすることで外部からの貢献を促進するのが目的です。

ZJITの公式アップデートが発表されてだいぶ時間が経ち、順調に進んでいます。このたび進捗を発表できて嬉しく思います。2025年5月に以下の記事を公開して以来、多くの作業が行われてきました。

RubyにマージされたZJITの概要を理解する(翻訳)

🔗 要旨

ZJITは、Ruby 4.0をビルドするときにデフォルトでビルドされますが、デフォルトでは有効になりません。ZJITを有効にするには以下のいずれかの方法が必要です。

  • Ruby起動時に--zjitフラグを渡す
  • RUBY_ZJIT_ENABLE環境変数に1を設定する
  • Rubyアプリケーション起動後にRubyVM::ZJIT.enableを呼び出す

ZJITはインタプリタより高速ですが、今のところはYJITほど高速ではありません。しかし既に高速化の計画はできており、いくつかの具体的な数値については後述します。要するに、高速化のための素晴らしい新基盤ができたので、Ruby固有のあらゆる手段を駆使してYJITと互角にする必要が生じたということです。

皆さんにもぜひZJITをお試しいただきたいのですが、現時点ではproductionでZJITを有効にするのはお控えください。ZJITは非常に新しいコンパイラなので、クラッシュやパフォーマンスの激落ち(場合によっては爆上がり)の可能性を考慮しておくべきです。

ZJITをローカルコンピュータやCIなどで試してみて、問題が生じたらRuby issue trackerまでお知らせください(bugs.ruby-lang.orgにアカウントを作りたくない方はGitHub issuesで報告することも可能です)1

🔗 コンパイラの開発状況

ZJITがCRubyにマージされて以来、どれほど作業が行われたかを強調するために、以下にひと通りの比較を掲載します。

🔗 1: side-exit機能の導入

2025年5月の時点では、JITコードからインタプリタへのside-exitは不可能でした。つまり、実行中のコードは事前条件(期待される型、メソッドが再定義されていないことなど)を維持しなければならず、維持できない場合はJITは安全にabortしていたのです。
しかし現在は、side-exit機能が有効になり、自由に使えるようになりました。

side-exitによって、たとえば以下のようにIntegerからStringにスムーズに移行できるようになりました。型が変わってガード用のインストラクションが失敗すると、制御がインタプリタに渡されます。

def add x, y
  x + y
end

add 3, 4
add 3, 4
add 3, 4
add "three", "four"

これにより、実行可能なコードが大幅に増えました。

🔗 2: 実行可能なコードが増えた

2025年5月の時点では、実行可能なベンチマークはほんの一握りしかありませんでした。
しかし現在は、あらゆる種類のコードを実行できるようになり、Ruby言語の全テストスイートも、Shopifyの大規模アプリケーションのテストスイートやシャドウトラフィックも、GitHub.comのテストスイートもすべてパスします!この調子なら銀行システムもいけるのでは?😆


5月の時点では最適化が不十分でした。実際に最適化できていたのは、fixnum(小さな整数)の演算や、mainオブジェクトへのメソッド送信(=いわゆるメソッド呼び出し)だけでした。
しかし現在は、多くの部分が最適化され、あらゆる種類の「メソッド送信」「インスタンス変数の読み書き」「属性アクセサ・リーダー・ライターの利用」「structの読み書き」「オブジェクトのアロケーション」「特定の文字列操作」「オプショナルパラメータ」などさまざまな最適化が完了しました。

たとえば、数値演算の定数畳み込み(constant-fold)が可能になりました。YJITから借用した(小規模かつ限定的な)インライナーも使えるので、以下のaddメソッド全体が3に定数畳み込みされます。しかも、onetwoInteger#+の再定義も引き続き有効になります。

def one
  1
end

def two
  2
end

def add
  one + two
end

🔗 3: レジスタあふれ

5月の時点では、YJITから借用したバックエンドの(レジスタあふれ処理に関する)制約が原因で、巨大な関数をコンパイルできないケースが多発しました。
しかし現在は、極めて巨大な関数でも問題なくコンパイルできます。私たちはコンパイラのパフォーマンスばかりを重視してきたわけではありませんが、巨大なメソッドでもミリ秒以下でコンパイルが完了します。

🔗 4: Cで書かれたメソッドの呼び出し

5月の時点では、C言語で書かれた組み込みメソッドの呼び出しを最適化できていませんでした。
しかし現在は、JavaScriptCoreのDOMJITに似た機能が導入され、よく使われる特定のCメソッドについてインラインバージョンのHIR(高水準中間表現: High-level Intermediate Representation)を生成できるようになりました。これによって、オプティマイザはこれらのCメソッドとその効果(詳しくは今後の記事に譲ります)をより効率的に推論可能になりました。

たとえば、Cで書かれたInteger#succメソッドは、integerに1を足す形で定義されています。このInteger#succメソッドは、whileループを回すInteger#timesで使われています。Cのメソッド「インライナー」は、このInteger#succメソッド呼び出しを既存のFixnumAddインストラクションに置き換えて、以後の型推論や定数畳み込みで利用します。

fn inline_integer_succ(fun: &mut hir::Function,
                       block: hir::BlockId,
                       recv: hir::InsnId,
                       args: &[hir::InsnId],
                       state: hir::InsnId) -> Option<hir::InsnId> {
    if !args.is_empty() { return None; }
    if fun.likely_a(recv, types::Fixnum, state) {
        let left = fun.coerce_to(block, recv, types::Fixnum, state);
        let right = fun.push_insn(block, hir::Insn::Const { val: hir::Const::Value(VALUE::fixnum_from_usize(1)) });
        let result = fun.push_insn(block, hir::Insn::FixnumAdd { left, right, state });
        return Some(result);
    }
    None
}

🔗 5: C関数呼び出しの削減

5月の時点では、ZJITのHIRインストラクションをLIR(低水準中間表現)で実装するために、ZJITから生成されるマシンコードがCRubyランタイムのC関数が大量に呼び出していました。
しかし現在は、この呼び出しが大幅に削減され、LIR実装が「オープンコード化」しました。

たとえば、従来のGuardNotFrozenrb_obj_frozen_pを呼び出していました。現在は、入力にはヒープ領域にアロケーションされたオブジェクトを渡すことが必須になり、そのおかげでロード、テスト、条件付きジャンプを実行できるようになりました。

fn gen_guard_not_frozen(jit: &JITState,
                        asm: &mut Assembler,
                        recv: Opnd,
                        state: &FrameState) -> Opnd {
    let recv = asm.load(recv);
    // これはヒープオブジェクトなのでfrozenフラグをチェックする
    let flags = asm.load(Opnd::mem(64, recv, RUBY_OFFSET_RBASIC_FLAGS));
    asm.test(flags, (RUBY_FL_FREEZE as u64).into());
    // frozenの場合はside-exitする
    asm.jnz(side_exit(jit, state, GuardNotFrozen));
    recv
}

🔗 6: チームメイトが増えた

5月の時点では、フルタイムのコンパイラ開発者は4人でした。
しかし現在は、Shopify社内のメンバーが増員され、コミュニティからの参加者も増えました。ZJITに興味を抱いた何人もの方から情報をいただいたおかげで複雑な変更に成功しました。ZJITの議論や改善の場としてzjit.zulipchat.comもオープンしました。

🔗 7: クールなグラフ化ツール

インターン生のAidenがIongraphというグラフ化ツールをZJITに統合した記事必読です。彼のおかげで、あらゆる関数、あらゆる最適化をグラフ表示して、自由にクリック・ズーム・スクロールできるようになりました。見事です!

参考: iongraph

上のデモページを開いてIongraphをお試しください、Ctrlキーを押しながらスクロールするとズームできます。左サイドバーにあるさまざまな最適化パスをクリックしてグラフを表示したり、定義や利用法を表す基本ブロック内に表示されているv1などのインストラクションIDをクリックすることで、以下のRubyコードの中間表現が時間とともに変化する様子を確認できます。

class Point
  attr_accessor :x, :y
  def initialize x, y
    @x = x
    @y = y
  end
end

P = Point.new(3, 4).freeze

def test = P.x + P.y

🔗 8: その他

ガベージコレクションも多数修正されました。

とは言うものの、まだまだ課題は山積みです。

🔗 今後の課題

今後、利用頻度の高いinvokeblockインストラクション(yield用)とinvokesuperインストラクション(super用)を最適化する予定です。これらの振る舞いは通常のsendインストラクションと似ていますが、完全に同じではありません。


setinstancevariableインストラクションで、オブジェクトのシェイプを遷移させる必要が生じた場合の処理を最適化する予定です。これは、よくある@a = bの最適化に有用です。また、メモ化@a ||= bの最適化にも有用ですが、これについては値に何らかの番号を振ることでさらに最適化できるかもしれないと見込んでいます。


現時点で最適化されているメソッド呼び出しは、モノモーフィック(monomorphic)な呼び出しのみです。つまり、メソッド送信がプロファイリング中に参照するレシーバークラスが1個だけの場合です。今後はポリモーフィックなメソッド送信も最適化する予定です。
現時点では、この作業をやりやすくするための基礎工事を進めているところです(後述の新しいレジスタアロケータ)。ただし、メソッド送信のほとんど(80%台後半〜90%台前半)はモノモーフィックなので、当面の重点課題ではありません。


線形スキャンの歴史に関するいくつかの論文と実装に目を通した後、現在レジスタアロケータを書き直しているところです。この改修によってパフォーマンスが向上し、中間表現の使いやすさも改善されます。


フェーズ遷移の処理はまだ不十分です。コードのコンパイル後にメソッド呼び出しのパターンが大きく変わると、現在はインタプリタへのside-exitが頻発します。これを変更して、side-exitを追加のプロファイル情報として関数の再コンパイルに活用したいと考えています。


現時点では、VMフレームへのトラフィックが大量に発生しています。JITからのフレームプッシュは十分高速ですが、副作用を伴う処理を行うたびにローカル変数のステートとスタックのステートをVMフレームに反映する必要があります。しかし、実体化されたフレームのステートをコードで読み出す必要性はほぼありません(例外発生時にフレームを遡る場合やBinding#local_variable_getなどぐらい)。
将来的には、このステート書き込みは読み取りの必要が生じるまで先送りする予定です。


現在のインライナーでインライン化できるのは、「定数」「self」「パラメータ」だけです。
将来的には、汎用のメソッドインライン化機能を追加する予定です。これによってポリモーフィックなメソッド送信の頻度を削減し、分岐の畳み込みを行って、メソッド送信全体の頻度を削減できるようになります。

パラメータの最適化については、現時点では「位置パラメータ」「必須のキーワードパラメータ」「オプショナルパラメータ」のみサポートされていますが、今後はオプショナルのキーワード引数の最適化にも取り組む予定です。この作業の大半は、Rubyの複雑極まる呼び出し規約をJITが理解できる一貫した形式へマーシャリングする作業です。

🔗 パフォーマンス

マクロなベンチマークやマイクロベンチマークのパフォーマンスデータについては以下のRubybenchで公開しています↓。

参考: Rubybench: rubybench.github.io

ベンチマークごとにグラフのスクリーンショットを以下に貼っておきます。Y軸はインタプリタを1としたときの速度向上率、X軸は時間で、大きいほど良い値です。

A line chart of ZJIT performance on railsbench improving over time, passing
interpreter performance, catching up to YJIT

ほぼすべてのベンチマークで、時間とともにZJITのパフォーマンスが向上しているのがわかります。この成果は、現在のYJITと同様の最適化(インスタンス変数の読み書きの特殊化)によってもたらされたものもあれば、ZJITならではの高水準中間表現(定数畳み込み、分岐畳み込み、型推論の精度向上など)によってもたらされたものもあります。

私たちは最適化を推し進めるために、生の時間の数値と、内部のパフォーマンスカウンタ(生成コードからのC関数の呼び出し回数など)の両方を利用しています。

🔗 ぜひお試しください

現在のRubyでは、デフォルトでZJITがバイナリにコンパイル済みの状態でリリースされていますが、実行時のZJITはデフォルトでは「有効になっていません」。パフォーマンスと安定性のため、Ruby 4.0でもYJITが引き続きデフォルトのコンパイラとして選択されます。

ZJITを有効にするとテストスイートで何が起きるかを試すのは、もちろん可能です。起動時に--zjitフラグを渡す、RUBY_ZJIT_ENABLE環境変数に1を設定する、またはアプリケーションの起動後にRubyVM::ZJIT.enableを呼び出すことでZJITを有効にできます。

🔗 YJITについて

私たちはZJITの開発に多くのリソースを投入しています。
YJITに割り当てられている開発時間はさほど多くありませんが(アロケーション速度を向上させた件は別として↓)、YJITは当面の間なくなることはありません。

Ruby 3.5でClass#newのアロケーションが6倍高速化される(翻訳)

🔗 謝辞

このコンパイラの開発は、皆さまからの視聴料皆さまのようなプログラマーたちによるオープンソースプロジェクトへの貢献によって成り立っています。深く感謝申し上げます!

  • Aaron Patterson
  • Abrar Habib
  • Aiden Fox Ivey
  • Alan Wu
  • Alex Rocha
  • André Luiz Tiago Soares
  • Benoit Daloze
  • Charlotte Wen
  • Daniel Colson
  • Donghee Na
  • Eileen Uchitelle
  • Étienne Barrié
  • Godfrey Chan
  • Goshanraj Govindaraj
  • Hiroshi SHIBATA
  • Hoa Nguyen
  • Jacob Denbeaux
  • Jean Boussier
  • Jeremy Evans
  • John Hawthorn
  • Ken Jin
  • Kevin Menard
  • Max Bernstein
  • Max Leopold
  • Maxime Chevalier-Boisvert
  • Nobuyoshi Nakada
  • Peter Zhu
  • Randy Stauner
  • Satoshi Tagomori
  • Shannon Skipper
  • Stan Lo
  • Takashi Kokubun
  • Tavian Barnes
  • Tobias Lütke

(上のリストはgit log --pretty="%an" zjit | sort -uでさくっと取り出しました)

関連記事

RubyにマージされたZJITの概要を理解する(翻訳)

Ruby: Shopifyによる新しい高速な静的型分析の実験(翻訳)

Rubyオブジェクトの未来をつくる「シェイプ」とは(翻訳)

YJIT: CRuby向けの新しいJITコンパイラを構築する(翻訳)


  1. 訳者もZJITのバグを踏んだのでissueをオープンしました: ZJIT: undefined method for nil on Rails 8.1.2 app · Issue #943 · Shopify/ruby 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。