Ahead of Time Compilation
この文書は、Juliaにおける事前コンパイル(AOT)システムの設計と構造について説明しています。このシステムは、システムイメージやパッケージイメージを生成する際に使用されます。ここで説明されている実装の多くは、aotcompile.cpp
、staticdata.c
、およびprocessor.cpp
にあります。
Introduction
Juliaは通常、コードをジャストインタイム(JIT)でコンパイルしますが、コードを事前にコンパイルして、その結果をファイルに保存することも可能です。これはいくつかの理由で便利です:
- Juliaプロセスの起動にかかる時間を短縮するために。
- JITコンパイラでコードを実行するのではなく、費やす時間を短縮するために(初回実行までの時間、TTFX)。
- JITコンパイラによって使用されるメモリ量を減らすために。
High-Level Overview
以下の説明は、ユーザーが新しいAOTモジュールをコンパイルする際に内部で発生するエンドツーエンドパイプラインの現在の実装詳細のスナップショットです。これは、ユーザーが using Foo
と入力したときに発生します。これらの詳細は、より良い処理方法を実装するにつれて時間とともに変わる可能性があるため、現在の実装は以下に記載されたデータフローや関数と正確に一致しない場合があります。
Compiling Code Images
まず、ネイティブコードにコンパイルする必要があるメソッドを特定する必要があります。これは、コンパイルされるコードを実際に実行することによってのみ行うことができ、コンパイルが必要なメソッドのセットは、メソッドに渡される引数の型に依存します。特定の型の組み合わせを持つメソッド呼び出しは、実行時まで知られない場合があります。このプロセス中に、コンパイラが見る正確なメソッドが追跡され、後のコンパイルのためにコンパイルトレースが生成されます。
現在、画像をコンパイルする際、Juliaはトレース生成をAOTコンパイルを行うプロセスとは異なるプロセスで実行します。これは、プリコンパイル中にデバッガを使用しようとする際に影響を及ぼす可能性があります。デバッガを使用してプリコンパイルをデバッグする最良の方法は、rrデバッガを使用し、プロセスツリー全体を記録し、rr ps
を使用して関連する失敗プロセスを特定し、その後rr replay -p PID
を使用して失敗プロセスのみを再生することです。
メソッドがコンパイルされるべきものとして特定されると、それらは jl_create_system_image
関数に渡されます。この関数は、ネイティブコードをファイルにシリアライズする際に使用されるいくつかのデータ構造を設定し、その後、メソッドの配列を使って jl_create_native
を呼び出します。jl_create_native
はメソッドに対してコード生成を実行し、1つ以上のLLVMモジュールを生成します。次に、jl_create_system_image
はモジュールから生成されたコードに関するいくつかの有用な情報を記録します。
モジュールは、jl_create_system_image
によって記録された情報とともにjl_dump_native
に渡されます。jl_dump_native
は、コマンドラインオプションに応じてモジュールをビットコード、オブジェクト、またはアセンブリファイルにシリアライズするために必要なコードを含んでいます。シリアライズされたコードと情報は、アーカイブとしてファイルに書き込まれます。
最終ステップは、jl_dump_native
によって生成されたアーカイブ内のオブジェクトファイルに対してシステムリンカーを実行することです。このステップが完了すると、コンパイルされたコードを含む共有ライブラリが生成されます。
Loading Code Images
コードイメージをロードする際、リンカーによって生成された共有ライブラリがメモリにロードされます。その後、システムイメージデータが共有ライブラリからロードされます。このデータには、共有ライブラリにコンパイルされた型、メソッド、およびコードインスタンスに関する情報が含まれています。このデータは、ランタイムの状態をコードイメージがコンパイルされたときの状態に復元するために使用されます。
コード画像がマルチバージョニングでコンパイルされている場合、ローダーは現在のマシンで利用可能なCPU機能に基づいて、使用する各関数の適切なバージョンを選択します。
システムイメージの場合、他のコードがロードされていないため、ランタイムの状態はコードイメージがコンパイルされたときと同じです。パッケージイメージの場合、コードがコンパイルされたときと比較して環境が変わっている可能性があるため、各メソッドはグローバルメソッドテーブルに対してチェックされ、まだ有効なコードであるかどうかを判断する必要があります。
Compiling Methods
Tracing Compiled Methods
Juliaには、JITコンパイラによってコンパイルされたすべてのメソッドを記録するためのコマンドラインフラグ --trace-compile=filename
があります。このフラグにファイル名が指定されている場合、関数がコンパイルされると、Juliaはそのファイルにメソッドと呼び出された引数の型を含むプリコンパイルステートメントを出力します。これにより、後でAOTコンパイルプロセスで使用できるプリコンパイルスクリプトが生成されます。PrecompileTools パッケージには、この機能をパッケージ開発者が利用しやすくするためのツールが含まれています。
jl_create_system_image
jl_create_system_image
は、ランタイムの状態を後で復元するために必要なすべてのJulia特有のメタデータを保存します。これには、コードインスタンス、メソッドインスタンス、メソッドテーブル、および型情報などのデータが含まれます。この関数は、ネイティブコードをファイルにシリアライズするために必要なデータ構造も設定します。最後に、jl_create_native
を呼び出して、渡されたメソッドのネイティブコードを含む1つ以上のLLVMモジュールを作成します。jl_create_native
は、渡されたメソッドに対してコード生成を実行する責任があります。
jl_dump_native
jl_dump_native
は、ネイティブコードを含む LLVM モジュールをファイルにシリアライズする役割を担っています。モジュールに加えて、jl_create_system_image
によって生成されたシステムイメージデータがグローバル変数としてコンパイルされます。このメソッドの出力は、コードとシステムイメージデータを含むビットコード、オブジェクト、および/またはアセンブリアーカイブです。
jl_dump_native
は、ネイティブコードを生成する際の大きな時間の消費源の一つであり、LLVM IR の最適化やマシンコードの生成に多くの時間が費やされます。したがって、この関数は最適化とマシンコード生成のステップをマルチスレッド化することができます。このマルチスレッド化はモジュールのサイズに基づいてパラメータ化されていますが、環境変数 JULIA_IMAGE_THREADS
を設定することで明示的にオーバーライドすることができます。デフォルトの最大スレッド数は利用可能なスレッド数の半分ですが、これを低く設定することでコンパイル中のピークメモリ使用量を減少させることができます。
jl_dump_native
は、Julia ローダーと統合されることで、複数のアーキテクチャ向けに最適化されたネイティブコードを生成することもできます。これは、JULIA_CPU_TARGET
環境変数を設定することでトリガーされ、最適化パイプライン内のマルチバージョニングパスによって仲介されます。このマルチスレッドでの動作を実現するために、モジュールがサブモジュールに分割される前に注釈ステップが追加され、これらのサブモジュールはそれぞれ独自のスレッドで出力されます。この注釈ステップでは、モジュール全体で利用可能な情報を使用して、異なるアーキテクチャ向けにどの関数がクローンされるかを決定します。注釈が行われた後、個々のスレッドは異なるアーキテクチャ向けにコードを並行して出力でき、異なるサブモジュールがクローンされた関数によって呼び出される必要な関数を生成することが保証されます。
モジュールがシリアライズされた方法に関する他のメタデータもアーカイブに保存されており、モジュールをシリアライズするために使用されたスレッドの数やコンパイルされた関数の数などが含まれています。
Static Linking
AOTコンパイルプロセスの最終ステップは、jl_dump_native
によって生成されたアーカイブ内のオブジェクトファイルにリンカーを実行することです。これにより、コンパイルされたコードを含む共有ライブラリが生成されます。この共有ライブラリは、Juliaによってロードされ、ランタイムの状態を復元するために使用されます。システムイメージをコンパイルする際には、Cコンパイラによって使用されるネイティブリンカーが最終的な共有ライブラリを生成するために使用されます。パッケージイメージの場合、より一貫したリンキングインターフェースを提供するためにLLVMリンカーLLDが使用されます。
Loading Code Images
Loading the Shared Library
コードイメージをロードする最初のステップは、リンカーによって生成された共有ライブラリをロードすることです。これは、共有ライブラリへのパスで jl_dlopen
を呼び出すことによって行われます。この関数は、共有ライブラリをロードし、ライブラリ内のすべてのシンボルを解決する責任があります。
Loading Native Code
ローダーはまず、コンパイルされたネイティブコードがローダーが実行されているアーキテクチャに対して有効であるかどうかを特定する必要があります。これは、古いCPUが認識しない命令を実行するのを避けるために必要です。これは、現在のマシンで利用可能なCPU機能と、コードがコンパイルされたCPU機能を照合することで行われます。マルチバージョニングが有効になっている場合、ローダーは現在のマシンで利用可能なCPU機能に基づいて、使用する各関数の適切なバージョンを選択します。マルチバージョン化された機能セットがない場合、ローダーはエラーをスローします。
マルチバージョニングパスの一部は、モジュール内のすべての関数のグローバル配列を作成します。このプロセスがマルチスレッド化されると、配列の配列が作成され、ローダーはこれを再編成して、このアーキテクチャ用にコンパイルされたすべての関数を含む1つの大きな配列にします。モジュール内のグローバル変数についても同様のプロセスが行われます。
Setting Up Julia State
ローダーは、ネイティブコードの読み込みから生成されたグローバル変数と関数を使用して、現在のプロセス内でJuliaランタイムのコアデータ構造をセットアップします。このセットアップには、Juliaランタイムに型とメソッドを追加し、キャッシュされたネイティブコードを他のJulia関数やインタープリタで使用できるようにすることが含まれます。パッケージイメージの場合、各メソッドは検証されなければならず、グローバルメソッドテーブルの状態はパッケージイメージがコンパイルされた状態と一致している必要があります。特に、パッケージイメージのロード時に異なるメソッドのセットが存在する場合、そのメソッドは無効化され、最初の使用時に再コンパイルされなければなりません。これは、パッケージが事前コンパイルされたか、コードが直接実行されたかに関係なく、実行セマンティクスが同じであることを保証するために必要です。システムイメージは、この検証を行う必要がなく、ロード時にグローバルメソッドテーブルが空であるため、システムイメージはパッケージイメージよりもロード時間が短くなります。