11月18日、Pat Shaughnessy氏が「[Compiling Ruby To Machine Language]([https://patshaughnessy.net/2025/11/17/compiling-ruby-to-machine-language)」と題した記事を公開し、話題となっている。この記事では、Ruby 3系で導入・発展しているJITコンパイラ YJIT と ZJIT が、Rubyコードをどのように機械語へとコンパイルし、高速に実行しているのかについて詳しく紹介されている。
以下に、その内容を紹介する。
本記事の位置づけと章構成
今回の投稿は、著者の代表作である『Ruby Under a Microscope』の新章(第4章)の抜粋であり、Ruby 3.x のJITコンパイル処理をテーマとしている。章全体では、Rubyのバイトコード実行基盤であるYARVと、そこに統合されたYJIT・ZJITがどのように協調して動くかを段階的に解説している。
章の構成は次のようになっている。
| セクション名 | ページ |
|---|---|
| Interpreting vs. Compiling Ruby Code(インタプリタ vs コンパイル) | 4 |
| Yet Another JIT (YJIT) | 6 |
| Virtual Machines and Actual Machines | 6 |
| Counting Method and Block Calls | 8 |
| YJIT Blocks | 8 |
| YJIT Branch Stubs | 10 |
| Executing YJIT Blocks and Branches | 11 |
| Deferred Compilation | 12 |
| Regenerating a YJIT Branch | 12 |
| YJIT Guards | 14 |
| Adding Two Integers Using Machine Language | 15 |
| Experiment 4-1: Which Code Does YJIT Optimize? | 18 |
| How YJIT Recompiles Code | 22 |
| Finding a Block Version | 22 |
| Saving Multiple Block Versions | 24 |
| ZJIT, Ruby’s Next Generation JIT | 26 |
| Counting Method and Block Calls(ZJIT版) | 27 |
| ZJIT Blocks | 29 |
| Method Based JIT | 31 |
| Rust Inside of Ruby | 33 |
| Experiment 4-2: Reading ZJIT HIR and LIR | 35 |
| Summary | 37 |
今回公開されたのは、このうち 「Counting Method and Block Calls」「YJIT Blocks」「YJIT Branch Stubs」 に相当する部分である。
ホットスポット検出:メソッドとブロック呼び出し回数のカウント
YJITは、どのRubyコードを機械語にコンパイルすべきかを判断するために、「メソッドやブロックが何回呼び出されたか」 をカウントする。JITコンパイラ全般に共通する考え方だが、「よく実行される部分(ホットスポット)」だけをコンパイルすることで、コンパイルコストとメモリ消費を抑えつつ、実行速度を向上させる狙いがある。
記事では、sum += i という単純なブロックを例に、Rubyのメインコンパイラが生成したYARV命令列と、その周辺にYJITが保持するカウンタ情報がどのように配置されるかを図示している。
図4-5:YJITは各YARV命令セットの隣に情報を保存する
図4-5では、YARV命令列の上部に、YJITが管理する2つの値が描かれている。
jit_entry- 初期状態では
null。 - 後で、このRubyブロックに対応する機械語コードへのポインタが格納される。
- 初期状態では
jit_entry_calls- このブロックが呼び出された回数を数えるための、YJIT内部のカウンタである。
Rubyプログラムがブロックを実行するたびに、YJITは jit_entry_calls をインクリメントしていく。記事中の例では、1..40 の範囲を each で繰り返し、そのたびに sum += i ブロックが呼ばれるため、このカウンタは0から始まり、40回まで増加する。
一定回数を超えると、YJITはそのブロックを「ホット」とみなし、YARV命令を機械語にコンパイルする。Ruby 3.5のYJITでは、デフォルトのしきい値は以下のように設定されている。
- 小さなRubyプログラム: 30回
- 大きなプログラム(Railsアプリケーションなど): 120回
この閾値は、Ruby実行時に --yjit-call-threshold オプションを指定することで変更できる。
YJITブロック:YARV命令列から機械語への変換単位
次に記事は、YJITがどのような単位で機械語コードを生成するのかを説明している。
YJITは、Rubyのメソッドやブロック全体を一度にコンパイルするのではなく、「YJITブロック(YJIT block)」 と呼ばれる小さな単位に分割して機械語を生成する。
- YJITブロックは、Rubyの「ブロック」オブジェクトとは別物である。
- 1つのYJITブロックは、1つまたは少数のYARV命令に対応する。
- 1つのRubyメソッド/ブロックは、複数のYJITブロックから構成される。
この構造により、YJITは本当に必要な部分だけを細かくコンパイルし、不要なコードはコンパイルしないという柔軟な最適化が可能になる。
最初のYJITブロックの生成
例として、sum += i ブロックのYARV命令列から、最初のYJITブロックが生成される様子が図4-6に示されている。
図4-6:YJITブロックの生成
- 左側:
sum += iブロックに対応するYARV命令列 - 右側: そのうち最初の命令
getlocal_WC_1から生成された、新しいYJITブロック
YJITは、まず getlocal_WC_1 命令を機械語にコンパイルし、その機械語命令列を1つのYJITブロックとして確保する。
ブロックへの命令追加
続いてYJITは、次のYARV命令 getlocal_WC_0 を同じブロックに追加でコンパイルする。これを表したのが図4-7である。
図4-7:YJITブロックへの命令追加
ここでは、左側に前と同じYARV命令列、右側にYJITブロックが描かれ、そのブロックが getlocal_WC_1 と getlocal_WC_0 の両方に対応する機械語命令を含んでいることが示されている。
YJITブロック内部の機械語命令
記事ではさらに、生成されたYJITブロック内部の様子が図4-8として示されている。
図4-8:1つのYJITブロックの内容
左側: 2つのYARV命令
getlocal_WC_1- ひとつ前のスタックフレーム(呼び出し元)にあるローカル変数を取得し、YARVスタックに積む。
getlocal_WC_0- 現在のスタックフレームのローカル変数を取得し、同様にYARVスタックに積む。
右側: それに対応する ARM64 の機械語命令(アセンブリ言語のニーモニック)
- M1プロセッサ上では、これらの命令がレジスタ
x1やx9に値をロードする役割を担う。
- M1プロセッサ上では、これらの命令がレジスタ
つまりYJITは、YARVスタック上で行われる操作を、実際のCPUレジスタを使った命令列に置き換えていることがわかる。「YARVの仮想スタック」と「実CPUのレジスタ」を結びつけるのが、YJITブロックの中心的な役割である。
なお、このブロックに含まれる機械語命令の意味や動きについては、章の別節「Adding Two Integers Using Machine Language」でより詳細に説明される予定だと記されている。
YJIT Branch Stubs:型を「あとから知る」ための仕組み
次のセクションでは、YJITが演算のオペランド型を事前には知らないという問題にどのように対処するかを扱っている。例として、opt_plus というYARV命令(足し算を表す命令)を機械語に変換する場面が取り上げられる。
opt_plus の引数の型は、Rubyコードの書き方によって次のように変化し得る。
- 整数同士の加算(例:
1 + 2) - 浮動小数点数の加算
- 文字列同士の結合(
"a" + "b") - その他、ユーザー定義クラスに
+が定義されている場合 など
機械語は型に対して非常に厳格であり、同じ + でもオペランド型によって命令セットが大きく変わる。
- M1プロセッサで2つの64bit整数を加算する場合:
adds命令などを使う。 - 浮動小数点数同士の場合: 別の浮動小数点専用命令が必要となる。
- 文字列の結合は、全く異なるロジックとメモリ操作が必要になる。
人間の目からは、例の sum += i が常に整数同士の加算であることは明らかである。しかし、YJITはRubyコードをそのように「理解」しているわけではないため、コンパイル時点では型を決め打ちできない。
「待ってから見る」戦略とブランチスタブ
この問題を解決するために、YJITは「すべての可能性を事前解析する」のではなく、「ブロックが実際に実行されるまで待ち、実際に渡される値の型を観測する」という戦略を採用している。
記事では、これを実現する仕組みとして branch stubs(ブランチスタブ) を紹介している。
図4-9:YJITブロックとブランチ、スタブ
- 左側: YARV命令列
- 右側: インデックス 0000〜0002 に対応するYJITブロック
- ブロックの右下から下方向に伸びる矢印: YJITブランチ
- 矢印の先にある小さな箱: ブランチスタブ(stub)
ここで重要なのは、このブランチがまだどのYJITブロックにも接続されていない点である。YJITは、opt_plus のような型依存の強い命令について、
とりあえずブランチ先として「スタブ」を用意する
実際にブロックが実行され、オペランドの型が分かった時点で
- 適切な機械語命令を生成し
- ブランチ先を、その新しいYJITブロックに張り替える
という「遅延コンパイル」の準備を行っている。
このような 「一旦スタブに飛ばし、必要になったときに本物のブロックを生成・差し替える」 というパターンは、多くのJITコンパイラで見られるテクニックであり、RubyのYJITでも採用されていることが示されている。
まとめ
本記事で紹介された抜粋部分では、Ruby 3系のJITであるYJITがどのようにして
- メソッド/ブロックの呼び出し回数をカウントし、ホットスポットを検出するか
- YARV命令から小さな単位のYJITブロックを作り、機械語に変換するか
- 型情報が不明な演算(
opt_plusなど)に対して、ブランチスタブを使って「待ってから型を見る」コンパイル戦略を実現しているか
といった点が、ARM64の実例や図解とともに丁寧に説明されている。
この先の章では、ここで導入された概念を土台に、
- YJITがどのようにブロックを再コンパイルするか
- 複数バージョンのブロックを型ごとに保持する方法
- 次世代JITであるZJITの設計と、Rustによる実装
など、Ruby実装内部のより踏み込んだトピックが展開される予定である。
詳細はCompiling Ruby To Machine Languageを参照していただきたい。