JITコンパイラが動的言語を速くする仕組み
JITコンパイラが冗長な処理を取り除き、Ruby・Python・JavaScriptを高速化する仕組みを、YJITとZJITの実例とともに解説します。

Rubyは遅いと言われてきました。Pythonもそうですし、JavaScriptもそうでした。少なくとも、V8がサーバーサイドの処理に耐えられるほど速くするまでは。動的言語がどうやって速くなったのかという話は、突き詰めるとJIT(Just-In-Time)コンパイルの物語であり、実用的なコンピュータサイエンスの中でも特に面白い分野のひとつです。最新の章はこうです。RubyのZJITが、中間表現のレベルで冗長なオブジェクトのロードとストアを取り除いています。V8のTurboFanを効果的にしたのと同じ種類の最適化です。
なぜRubyのコードがCの何分の一かの速度でしか動かないのか、あるいはJavaScriptがVS Codeを動かせるほど速くなったのはなぜか、疑問に思ったことがあるなら、その答えはJITコンパイラが実際に何をしているのか、そして静的言語より動的言語の最適化がなぜ根本的に難しいのかを理解することにあります。
動的言語に共通する根本的な問題
Cコンパイラはa + bを見ると、コンパイル時点でaとbの型が分かっています。両方が整数なら単一のADD命令を出力しますし、浮動小数点数なら浮動小数点加算を出力します。CPUはその命令を1サイクルで実行します。曖昧さもなければ、実行時に判断する必要もありません。
RubyインタプリタがCのa + bに相当するコードを見ても、ほとんど何も分かりません。aは整数かもしれませんし、浮動小数点数、文字列、配列、あるいは+メソッドを定義した任意のオブジェクトかもしれません。インタプリタは、aの型を調べ、その型に対応する+メソッドを探し、bの型を調べ、必要なら型変換を行い、オーバーフローや凍結(frozen)されたオブジェクトといったエッジケースを処理し、最後にようやく演算を実行しなければなりません。この1つの+に、数十の命令、メモリ参照、分岐判断が絡むこともあります。
# What the programmer writes:
def sum(a, b)
a + b
end
# What the interpreter actually has to do (pseudocode):
def sum(a, b)
method = lookup_method(a.class, :+) # Hash lookup
raise NoMethodError unless method # Branch
if a.is_a?(Integer) && b.is_a?(Integer) # Type checks
result = integer_add(a.value, b.value) # Actual math
if overflow?(result) # Overflow check
result = promote_to_bignum(result) # Bignum promotion
end
elsif a.is_a?(Float) || b.is_a?(Float)
result = float_add(to_float(a), to_float(b))
elsif a.is_a?(String)
result = string_concat(a, b.to_s)
else
result = call_method(method, a, [b]) # Generic dispatch
end
result
end
型チェック、メソッド探索、ディスパッチといったこのオーバーヘッドは、動的であることの代償です。a + bを書くだけで、整数でも浮動小数点数でも文字列でもカスタムオブジェクトでも動くという柔軟性は、実行時にはコストが高くつきます。
JITコンパイルによる反撃
JITコンパイラの仕事は、プログラムが実行時に実際に何をしているかを観察し、その観察結果に基づいて最適化された機械語を生成することです。重要な発想は、Rubyのコードは理屈の上ではどんな型にも対応できるものの、実際には、ある呼び出しサイトがほぼ常に同じ型を受け取る、というものです。
sum(a, b)が1万回呼ばれ、aとbがずっと整数だったとしましょう。するとJITは、その型が今後も整数であるという前提に立った特殊化された機械語を生成できます。整数加算命令を1つ出力し、そこに「ガード」、つまり前提が崩れたときに低速パスへ抜けるための素早い型チェックを付けます。
Interpreter execution of sum(a, b) where a and b are Integers:
1. Load a from stack
2. Check if a is an object reference
3. Load a's class pointer
4. Look up :+ in method table (hash lookup)
5. Check method visibility
6. Load b from stack
7. Check if b is an object reference
8. Check if b is compatible type
9. Unbox a's integer value
10. Unbox b's integer value
11. Perform addition
12. Check for overflow
13. Box result as new Integer object
14. Return result
JIT-compiled version (after profiling shows a,b are always Integers):
1. Guard: check a is Integer (bail if not)
2. Guard: check b is Integer (bail if not)
3. ADD instruction
4. Guard: check no overflow (bail if overflow)
5. Return result
これは14ステップの処理が5ステップに減るということです。そのうち1、2、4番目のステップは単一の比較命令です。実際の計算であるADDは1 CPUサイクルです。JavaScriptが「本格的な用途には遅すぎる」から「フルスペックのIDEを動かせるほど速い」へと変わったのは、このような仕組みによるものです。
RubyのJIT史:MJITからYJIT、そしてZJITへ
RubyのJITコンパイルの歴史は、この問題がいかに難しいかを示す事例です。Rubyは、それぞれ異なるアプローチを取る複数のJIT実装を経てきました。
MJIT(Ruby 2.6、2018年)は、Rubyのバイトコードを Cコード に変換し、それをGCCやClangでコンパイルする方式を取りました。これは最適化の行き届いたコードを生成しましたが、ウォームアップに非常に時間がかかりました。Cのコンパイルには数ミリ秒ではなく数秒かかります。JITでコンパイルされたコードが準備できた頃には、プログラムがすでに実行を終えていることもあったのです。
YJIT(Ruby 3.1、2022年)はShopifyの貢献で、最初はC言語で書かれ、後にRustで書き直されました。YJITは「遅延基本ブロックバージョニング(lazy basic block versioning)」と呼ばれる手法を使います。コードを基本ブロック単位で、実際に実行されたときにだけコンパイルし、観察した型に基づいて特殊化されたバージョンを作ります。これにより、ウォームアップは速く(数秒ではなく数ミリ秒)、ピーク時の性能も十分に出ます。YJITは一般的に、Railsアプリケーションのような実際のワークロードでRubyの性能を15〜30%向上させます。
ZJITは次の進化形で、ここから本当に面白くなります。ZJITは中間表現(IR)を導入しています。IRとは、バイトコードと機械語の間にあるプログラムの構造化された表現のことです。このIRによって、YJITのバイトコードから機械語への直接変換では簡単にはできなかった、古典的なコンパイラ最適化が可能になります。
冗長なロードとストアの除去
ZJITが最近取り込んだ具体的な最適化、つまり冗長なオブジェクトのロードとストアの除去は、一見難解に聞こえますが、影響は非常に大きいものです。その理由を説明しましょう。
Rubyのオブジェクトは、インスタンス変数をプロパティテーブルに格納しています。@nameを読むたびに、インタプリタはオブジェクトのプロパティテーブルからメモリ上の値をロードします。@name = valueと書くたびに、そのテーブルへストアします。同じインスタンス変数に何度もアクセスするメソッドでは、インタプリタは毎回メモリからロードし直します。一般的なケースでは、読み取りの間に誰かが値を変えているかもしれないからです。
class Rectangle
def area
@width * @height
end
def perimeter
# @width and @height are each loaded TWICE from memory
2 * (@width + @height)
end
def scale(factor)
@width = @width * factor # Load @width, multiply, store @width
@height = @height * factor # Load @height, multiply, store @height
self
end
end
# In a tight loop, these redundant loads add up:
rects.each { |r| r.scale(2).perimeter }
IRがあれば、ZJITはロードの除去を行えます。@widthがすでにロード済みで、その後一度も変更されていなければ、メモリから再度ロードする代わりにレジスタ上の値を再利用できます。またストアの除去も行えます。読み取りが挟まれずに@widthへ2回連続で書き込む場合、最初のストアは削除できます。
これらの最適化は、静的言語のコンパイラでは当たり前の機能で、GCCやLLVMは何十年も前から実装しています。しかし動的言語でこれを行うのは、エイリアシング(別名参照)のせいではるかに難しくなります。Rubyでは、どのメソッドを呼び出しても、どのオブジェクトのインスタンス変数でも変更される可能性があります(instance_variable_set、method_missing、トレースフックなどを介して)。JITは、@widthの2回の読み取りの間に、何も値を変えられなかったことを証明しなければなりません。そのためには、間に挟まる処理のひとつひとつが何をしうるかを解析する必要があります。
IRのメリット
ZJITがIRを導入した理由(V8のTurboFan、JavaScriptCoreのDFG/FTL、HotSpotのC2がいずれもIRを使っている理由も同じです)は、こうした最適化を組み合わせ可能にするためです。IRは本質的に、最適化をグラフ変換として適用できる演算のグラフです。
- 定数畳み込み(constant folding): 加算の両オペランドが既知の定数であれば、その演算を結果で置き換えます。
2 + 3はコンパイル時に5になります。 - デッドコード除去: 演算結果が使われなければ、その演算を丸ごと削除します。
- 共通部分式除去(common subexpression elimination): 同じ計算が2回現れたら、1回だけ計算して結果を再利用します。
- ロード/ストアの除去: 前述のとおり、冗長なメモリ操作を取り除きます。
- エスケープ解析: オブジェクトが生成され、現在のメソッドの外に出ないなら、ヒープではなくスタックに割り当てます(あるいは割り当てそのものを削除します)。
- インライン展開: メソッド呼び出しをメソッド本体で置き換えます。これにより、ほかの最適化の機会が増えます。
これらの最適化は組み合わさって効果を増します。メソッドをインライン展開すると、その処理が呼び出し側のコンテキストに現れ、定数値が見えるようになり、それが定数畳み込みを可能にし、結果として不要になったコードが生まれ、それが削除されます。1回のインライン展開の判断が、数十の演算の削除につながることもあります。
最後の砦:非最適化(deoptimization)
JITコンパイラが行うことはすべて推測に基づいています。型は変わらない、メソッドは再定義されない、モンキーパッチで最適化済みのコードが無効になることはない、と仮定しているのです。その前提が崩れたとき、JITは「非最適化(deoptimize)」、つまり最適化済みのコードを捨ててインタプリタにフォールバックする必要があります。
非最適化はJIT設計の中で最も難しい部分のひとつです。最適化されたコードは、ローカル変数を消去したり、演算の順序を入れ替えたり、深くネストした呼び出しをインライン展開したりしているかもしれません。インタプリタに戻るには、最適化されたコードが持っている情報から、インタプリタの状態(すべてのローカル変数、コールスタック、プログラムカウンタ)を再構築しなければなりません。そのため、「オンスタック置換(OSR)マップ」と呼ばれるメタデータを維持し、最適化されたコードの状態をインタプリタの状態に対応づける必要があります。
非最適化が頻繁に起きると、「deoptスラッシング」と呼ばれる状態になり、性能が純粋なインタプリタ実行より悪化することがあります。JITは最適化されたコードをコンパイルし、少しだけ実行し、非最適化し、また繰り返す、という無駄な作業に時間を費やします。V8は非最適化の回数を追跡し、特定の関数の最適化を最終的に諦めることでこれに対処しています。YJITはより単純なアプローチを取り、型の組み合わせごとに各コードパスの複数バージョンを生成します。これにより非最適化の必要性は減りますが、生成されるコードは増えます。
Ruby以外にも及ぶ意味
Rubyのこの道のりは、動的言語全体で起きていることを映し出しています。CPython 3.13に取り込まれたPythonのcopy-and-patch JITは、Pythonに本格的なJITコンパイルをもたらす最初の一歩です。LuaJITは、積極的なトレーシングJITを使うことで長年驚くほど高速でした。PHP 8.0以降のJITは、IRベースの最適化にLLVMを使っています。
そのパターンは一貫しています。まずインタプリタから始め、実行時の振る舞いを把握するためにプロファイリングを加え、ホットパスを型特殊化してコンパイルし、古典的な最適化のためにIRを導入し、そして磨き上げていく。どの言語も同じ課題(動的ディスパッチ、可変オブジェクト、eval、モンキーパッチ)に直面し、似たような解決策にたどり着いています。
これらの言語を使う開発者にとっての実践的な結論は、動的言語と静的言語の性能差は縮まりつつあるということです。型チェックやガードにはまだコストがかかり、非最適化も本質的なオーバーヘッドなので、差が完全になくなることはありません。しかし、よく最適化されたJITは、計算処理のワークロードにおいて同等のCコードの2〜5倍以内に収まることがあり、これは大多数のアプリケーションにとって「十分速い」レベルです。
もう少し繊細な結論としては、素直なコードを書くことです。JITコンパイラは予測可能なパターンを最適化します。モノモーフィックな呼び出しサイト(メソッドが常に同じ型を受け取る箇所)はうまく最適化されます。ポリモーフィックな呼び出しサイト(型が変わる箇所)は難しくなります。メガモーフィックな呼び出しサイト(数十の型を扱う箇所)は、まったく最適化されないこともあります。人間が理解しやすいコードは、たいていJITにとっても最適化しやすい。これはインセンティブがうまく噛み合っている、と言えるでしょう。


