🌟

Julia の World Age と他の JIT 言語の違いをサンプルコードで比較してみた

に公開1

はじめに

動的言語では「関数を実行中に再定義できる」ことが大きな魅力の一つです。しかし、これを 高性能な JIT 最適化 と両立させるのは簡単ではありません。

Julia はこの問題を解決するために world age という独自の仕組みを採用しています。一方で、Java / .NET / JavaScript / Ruby / Python などの他の JIT 言語は、「推測して最適化 → 壊れたら捨てる」という方式で対応しています。

この記事では、Julia の world age の仕組みと、他の JIT 言語がどのように動的再定義を処理しているかを、サンプルコードとコア概念を交えて比較していきます。

Julia の World Age

コア概念

Julia の world age システムは、メソッド定義ごとに増加する World counter という全体カウンターを中心に動作します。各関数は Method Table で型タプルからメソッドへのマッピングを管理し、特定の world の視界で最適化された機械コードが CodeInstance として保存されます。

サンプルコード

f(x) = "old"        # world 1
g() = f(1)          # g は world 1 の f を見る

f(x::Int) = "new"   # world 2 で新しいメソッドを追加

g()   # => "old" (過去の world に固定)
f(1)  # => "new" (最新 world を参照)
Base.invokelatest(f, 1)  # => "new" (最新を明示的に呼ぶ)

崩壊トリガと挙動

新しいメソッドが定義されると world が進行しますが、既存コードは古い world を参照し続けます。型定義や階層変更が行われると、依存コードが invalidation され再コンパイルが必要になります。最新の world を使いたい場合は、invokelatest 関数を使って明示的に越境することができます。

Python(CPython)

コア概念

Python では関数定義の仕組みが非常にシンプルです。関数定義は「関数オブジェクト」を作成し、それを名前に束縛するだけの操作になります。そのため関数の再定義は実質的に参照の差し替えに過ぎません。モジュールを reload するとコードは更新されますが、from-import で取得した参照は元のオブジェクトを指し続けるため固定されます。

サンプルコード

# mod.py
def f(x): 
    return "old"

# main.py
import importlib
import mod

def g(): 
    return mod.f(1)

print(g())             # old

# mod.py を編集して f = lambda x: "new" に変更
importlib.reload(mod)
print(g())             # new

from mod import f
print(f(1))            # old (古い参照のまま)

崩壊トリガ

from mod import f のような直接インポートを行うと、参照が固定され更新されなくなります。また、関数をクロージャやデフォルト引数に束縛した場合も、元の関数オブジェクトへの参照が保持されるため更新されません。

JavaScript(V8 など)

コア概念

JavaScript の JIT エンジンは、高速化のために複数の最適化手法を組み合わせています。Hidden Class(Shape) によってオブジェクトの内部構造を効率的に表現し、Inline Cache (IC/PIC) で呼び出しサイトごとに型や shape を記録して高速化を図ります。さらに OSR (On-Stack Replacement) により、実行中のホットなフレームを途中で最適化されたコードに切り替えることができます。

サンプルコード

let impl = (x) => "old";
function g(x) { 
    return impl(x); 
}

console.log(g(1)); // old
impl = (x) => "new"; // 差し替え
console.log(g(1)); // new

崩壊トリガ

オブジェクトにプロパティの追加・削除・順序変更が行われると shape が変化し、IC が破壊されてパフォーマンスが低下します。また evalwith、Proxy などの動的操作を多用すると、最適化が困難になり実行速度が遅くなります。

Ruby(YJIT/MJIT)

コア概念

Ruby の JIT システムでは、メソッドテーブルに世代番号を付与する仕組みを採用しています。再定義や mixin が行われるとグローバル世代が進行し、Inline Cache を無効化します。Julia とは異なり、Ruby では常に最新のメソッド定義を参照するという設計になっています。

サンプルコード

class K
  def f(x) 
    "old" 
  end
end

k = K.new
def g(k) 
  k.f(1) 
end

puts g(k) # old

class K
  def f(x) 
    "new" 
  end
end

puts g(k) # new

崩壊トリガ

define_methodmethod_missing を多用すると、メソッドキャッシュが効かずにパフォーマンスが低下します。クラスの再オープンを頻繁に行うと、グローバル世代が進行して IC の無効化が連続的に発生し、最適化効果が失われます。

JVM(Java / HotSpot)

コア概念

JVM の最適化は階層的なアプローチを取っています。Tiered JIT により、まず軽量な C1 コンパイラで高速に最適化し、その後高度最適化を行う C2 コンパイラに移行します。Speculation では実行時プロファイルから型や分岐の傾向を仮定し、Guard & Deopt の仕組みにより、仮定が崩れた場合はガード違反として低い tier に戻して再最適化を行います。また invokedynamicSwitchPoint により、呼び出しサイトを動的に差し替えることが可能です。

サンプルコード

import java.util.function.Function;

public class HotSwapDemo {
    static volatile Function<Integer,String> impl = x -> "old";
    
    static String g(int x) { 
        return impl.apply(x); 
    }

    public static void main(String[] args) {
        System.out.println(g(1)); // old
        impl = x -> "new";        // 差し替え
        System.out.println(g(1)); // new
    }
}

崩壊トリガ

クラスの再定義(HotSwap)は多くの制限があり、実行時の変更は限定的です。型の多様化が進むとガードの失敗が頻発し、デオプティマイゼーションが発生してパフォーマンスが低下します。

.NET(C# / CLR)

コア概念

.NET の JIT システムでは、段階的な最適化戦略を採用しています。Tiered JIT により、最初は軽量な JIT でコンパイルし、プロファイルを収集した後に最適化 JIT で再コンパイルします。診断や AOP(Aspect-Oriented Programming)のために ReJIT による再 JIT が可能で、DLR CallSite では呼び出しサイト単位でルールキャッシュを持ち、動的に再バインドできます。

サンプルコード

using System;

class Program {
    static Func<int, string> Impl = x => "old";
    
    static string G(int x) => Impl(x);

    static void Main() {
        Console.WriteLine(G(1)); // old
        Impl = x => "new";       // 差し替え
        Console.WriteLine(G(1)); // new
    }
}

崩壊トリガ

メソッドの再定義が行われると CallSite が無効化されます。ホットコードは tiered JIT による再最適化の対象となり、実行特性の変化に応じて最適化レベルが調整されます。

PyPy(Python JIT)

コア概念

PyPy は独特な Trace JIT アプローチを採用しています。ホットループの実行経路を記録してからその経路を最適化し、Guard によって型や分岐条件を仮定します。仮定が失敗した場合はサイドエグジットが発生し、別の経路がホット化すると新しいトレースを作成して最適化する再トレースが行われます。

サンプルコード

impl = lambda x: "old"

def g(n):
    s = None
    for i in range(n):
        s = impl(i)   # トレース最適化
    return s

print(g(10000)) # old
impl = lambda x: "new"
print(g(10000)) # new (ガード違反で再トレース)

崩壊トリガ

型が頻繁に変動すると、トレースが分裂して最適化効果が薄れます。例外処理や eval などによるコード生成が多用されると、トレースが破壊されてパフォーマンスが低下します。

比較表

言語 方式 コア概念 崩壊トリガ
Julia world age 過去固定 / invokelatest で越境 新メソッド定義、型変更
Python 参照束縛 関数はオブジェクト、参照差替え from-import、クロージャ束縛
JavaScript IC/PIC+デオプト shape + IC プロパティ追加/削除、eval
Ruby メソッド世代+無効化 グローバル世代でキャッシュ管理 クラス再オープン、define_method
JVM 推測+ガード+デオプト Tiered JIT、invokedynamic 型多様化、クラス再定義
.NET Tiered JIT+CallSite CallSite 再バインド 再定義、tiered 最適化切替
PyPy トレースJIT Guard + サイドエグジット 型揺れ、分岐多様化

まとめ

各言語の動的再定義への対応は、そのユースケースや設計思想を反映しています:

  • Julia は「過去固定」方式で、既存コードの意味を壊さずに強力な最適化を維持できる
  • 他の JIT 言語 は「推測+ガード+デオプト」で、最新に追随しながら整合性を保つ

参考資料

Discussion

abap34abap34

Julia に関する記述についてなのですが,最初のサンプルコードの結果がおかしいように見えます.
Int むけの f が再定義されたら依存する g のコンパイル結果は invalidation されて新しい f の実装が呼び出されて次のような結果になるはずです.

julia> f(x) = "old"        # world 1
f (generic function with 1 method)

julia> g() = f(1)          # g は world 1 の f を見る
g (generic function with 1 method)

julia> f(x::Int) = "new"   # world 2 で新しいメソッドを追加
f (generic function with 2 methods)

julia> g()   # => "old" (過去の world に固定)  <---- ここが違う
"new"

julia> f(1)  # => "new" (最新 world を参照)
"new"

julia> Base.invokelatest(f, 1)  # => "new" (最新を明示的に呼ぶ)
"new"