ORC の設計と実装

はじめに

このドキュメントは、ORC JIT API の設計と実装に関する高レベルの概要を提供することを目的としています。特に明記されていない限り、すべての議論は最新の ORCv2 API (LLVM 7 以降で利用可能) を指します。OrcV1 から移行したいクライアントは、「ORCv1 から ORCv2 への移行」のセクションを参照してください。

ユースケース

ORC は、JIT コンパイラを構築するためのモジュール式の API を提供します。そのような API には、いくつかのユースケースがあります。例えば

1. LLVM チュートリアルでは、簡単な ORC ベースの JIT クラスを使用して、おもちゃの言語である Kaleidoscope からコンパイルされた式を実行します。

2. LLVM デバッガである LLDB は、式評価のためにクロスコンパイル JIT を使用します。このユースケースでは、クロスコンパイルにより、デバッガプロセスでコンパイルされた式を、異なるデバイス/アーキテクチャにある可能性のあるデバッグターゲットプロセスで実行できます。

3. 既存の JIT インフラストラクチャ内で LLVM の最適化を利用したいハイパフォーマンス JIT (例: JVM、Julia) において。

  1. インタプリタや REPL (例: Cling (C++) や Swift インタプリタ) で。

モジュール化されたライブラリベースの設計を採用することで、ORC をこれらの可能な限り多くのコンテキストで役立てることを目指しています。

特徴

ORC は次の機能を提供します

JIT リンキング

ORC は、リロケータブルオブジェクトファイル (COFF、ELF、MachO) [1] を実行時にターゲットプロセスにリンクするための API を提供します。ターゲットプロセスは、JIT セッションオブジェクトと JIT リンカーを含む同じプロセスである場合もあれば、RPC を介して JIT と通信する別のプロセス (別のマシンまたはアーキテクチャで実行されているものも含む) である場合もあります。

LLVM IR コンパイル

ORC は、LLVM IR を JIT されたプロセスに追加することを容易にする既成のコンポーネント (IRCompileLayer、SimpleCompiler、ConcurrentIRCompiler) を提供します。

Eager および Lazy コンパイル

デフォルトでは、ORC はシンボルが JIT セッションオブジェクト (ExecutionSession) でルックアップされるとすぐにコンパイルします。デフォルトで Eager にコンパイルすると、ORC を既存の JIT のインメモリコンパイラとして簡単に使用できます (MCJIT が一般的に使用される方法と同様)。ただし、ORC は lazy-reexports を介した Lazy コンパイルの組み込みサポートも提供します (遅延性 を参照)。

カスタムコンパイラとプログラム表現のサポート

クライアントは、JIT セッションで定義する各シンボルにカスタムコンパイラを提供できます。ORC は、シンボルの定義が必要になったときにユーザーが提供したコンパイラを実行します。ORC は実際には完全に言語に依存しません。LLVM IR は特別に扱われず、カスタムコンパイラに使用されるのと同じラッパーメカニズム (MaterializationUnit クラス) を介してサポートされます。

並行 JIT コード並行コンパイル

JIT コードは複数のスレッドで実行でき、新しいスレッドを生成でき、複数のスレッドから並行して ORC (例えば Lazy コンパイルを要求するために) に再突入できます。ORC によって起動されたコンパイラは、クライアントが適切なディスパッチャを設定していれば、並行して実行できます。組み込みの依存関係追跡により、ORC はすべての依存関係が JIT され、呼び出しまたは使用しても安全になるまで、JIT コードまたはデータへのポインタをリリースしないことが保証されます。

削除可能なコード

JIT されたプログラム表現のリソース

直交性構成可能性

上記の各機能は独立して使用できます。ORC コンポーネントを組み合わせて、非 Lazy、インプロセス、シングルスレッドの JIT、または Lazy、アウトオブプロセス、並行 JIT、またはその中間を作成することができます。

LLJIT と LLLazyJIT

ORC は、すぐに使用できる 2 つの基本的な JIT クラスを提供します。これらは、JIT を作成するために ORC コンポーネントを組み立てる方法の例として、および以前の LLVM JIT API (例: MCJIT) の代替として役立ちます。

LLJIT クラスは、IRCompileLayer と RTDyldObjectLinkingLayer を使用して、LLVM IR のコンパイルとリロケータブルオブジェクトファイルのリンキングをサポートします。すべての操作は、シンボルのルックアップ時に Eager に実行されます (つまり、シンボルの定義は、そのアドレスをルックアップしようとするとすぐにコンパイルされます)。LLJIT は、ほとんどの場合 MCJIT の適切な代替となります (注: JITEventListeners などの一部の高度な機能はまだサポートされていません)。

LLLazyJIT は LLJIT を拡張し、CompileOnDemandLayer を追加して LLVM IR の Lazy コンパイルを可能にします。addLazyIRModule メソッドを介して LLVM IR モジュールが追加されると、そのモジュールの関数本体は、最初に呼び出されるまでコンパイルされません。LLLazyJIT は、LLVM の元の (MCJIT より前の) JIT API の代替となることを目指しています。

LLJIT および LLLazyJIT インスタンスは、それぞれのビルダー クラスである LLJITBuilder および LLazyJITBuilder を使用して作成できます。たとえば、ThreadSafeContext Ctx にロードされたモジュール M があると仮定すると、

// Try to detect the host arch and construct an LLJIT instance.
auto JIT = LLJITBuilder().create();

// If we could not construct an instance, return an error.
if (!JIT)
  return JIT.takeError();

// Add the module.
if (auto Err = JIT->addIRModule(TheadSafeModule(std::move(M), Ctx)))
  return Err;

// Look up the JIT'd code entry point.
auto EntrySym = JIT->lookup("entry");
if (!EntrySym)
  return EntrySym.takeError();

// Cast the entry point address to a function pointer.
auto *Entry = EntrySym.getAddress().toPtr<void(*)()>();

// Call into JIT'd code.
Entry();

ビルダー クラスは、JIT インスタンスが構築される前に指定できる多数の構成オプションを提供します。例:

// Build an LLLazyJIT instance that uses four worker threads for compilation,
// and jumps to a specific error handler (rather than null) on lazy compile
// failures.

void handleLazyCompileFailure() {
  // JIT'd code will jump here if lazy compilation fails, giving us an
  // opportunity to exit or throw an exception into JIT'd code.
  throw JITFailed();
}

auto JIT = LLLazyJITBuilder()
             .setNumCompileThreads(4)
             .setLazyCompileFailureAddr(
                 ExecutorAddr::fromPtr(&handleLazyCompileFailure))
             .create();

// ...

LLJIT を使い始めたいユーザー向けに、最小限のプログラム例が llvm/examples/HowToUseLLJIT にあります。

設計概要

ORC の JIT プログラムモデルは、静的リンカーと動的リンカーで使用されるリンキングとシンボル解決のルールをエミュレートすることを目的としています。これにより、ORC は、シンボルリンケージや可視性、weak [3] および共通シンボル定義などの構造を使用する、通常の静的コンパイラ (例: clang) によって生成された IR を含む、任意の LLVM IR を JIT できます。

これがどのように機能するかを確認するために、libAlibB という 2 つの動的ライブラリに対してリンクするプログラム foo を想像してください。コマンドラインでこのプログラムを構築すると、次のようになります。

$ clang++ -shared -o libA.dylib a1.cpp a2.cpp
$ clang++ -shared -o libB.dylib b1.cpp b2.cpp
$ clang++ -o myapp myapp.cpp -L. -lA -lB
$ ./myapp

ORC では、これは仮想の CXXCompilingLayer に対する API 呼び出しに変換されます (簡潔にするためにエラーチェックは省略)。

ExecutionSession ES;
RTDyldObjectLinkingLayer ObjLinkingLayer(
    ES, []() { return std::make_unique<SectionMemoryManager>(); });
CXXCompileLayer CXXLayer(ES, ObjLinkingLayer);

// Create JITDylib "A" and add code to it using the CXX layer.
auto &LibA = ES.createJITDylib("A");
CXXLayer.add(LibA, MemoryBuffer::getFile("a1.cpp"));
CXXLayer.add(LibA, MemoryBuffer::getFile("a2.cpp"));

// Create JITDylib "B" and add code to it using the CXX layer.
auto &LibB = ES.createJITDylib("B");
CXXLayer.add(LibB, MemoryBuffer::getFile("b1.cpp"));
CXXLayer.add(LibB, MemoryBuffer::getFile("b2.cpp"));

// Create and specify the search order for the main JITDylib. This is
// equivalent to a "links against" relationship in a command-line link.
auto &MainJD = ES.createJITDylib("main");
MainJD.addToLinkOrder(&LibA);
MainJD.addToLinkOrder(&LibB);
CXXLayer.add(MainJD, MemoryBuffer::getFile("main.cpp"));

// Look up the JIT'd main, cast it to a function pointer, then call it.
auto MainSym = ExitOnErr(ES.lookup({&MainJD}, "main"));
auto *Main = MainSym.getAddress().toPtr<int(*)(int, char *[])>();

int Result = Main(...);

この例では、コンパイルがどのようにまたはいつ発生するかについては何もわかりません。それは、仮想の CXXCompilingLayer の実装によって異なります。ただし、実装に関係なく、同じリンカーベースのシンボル解決ルールが適用されます。たとえば、a1.cpp と a2.cpp の両方が関数「foo」を定義している場合、ORCv2 は重複定義エラーを生成します。一方、a1.cpp と b1.cpp の両方が「foo」を定義している場合、エラーは発生しません (異なる動的ライブラリは同じシンボルを定義できます)。main.cpp が「foo」を参照する場合、main.cpp は「main」dylib の一部であり、「main」dylib は LibB の前に LibA にリンクするため、「foo」は LibB の定義ではなく LibA の定義にバインドする必要があります。

多くの JIT クライアントは、通常の Ahead-of-Time リンキングルールを厳密に遵守する必要がないため、すべてのコードを単一の JITDylib に配置するだけで問題なく対処できるはずです。ただし、従来 Ahead-of-Time リンキングに依存している言語/プロジェクト (例: C++) のコードを JIT しようとするクライアントは、この機能によって作業がはるかに簡単になることがわかるでしょう。

ORC でのシンボルルックアップは、シンボルのアドレスを提供すること以外に、他の 2 つの重要な機能を果たします。(1) (まだコンパイルされていない場合) 検索されたシンボルのコンパイルをトリガーし、(2) 並行コンパイルの同期メカニズムを提供します。ルックアッププロセスの疑似コードは次のとおりです。

construct a query object from a query set and query handler
lock the session
lodge query against requested symbols, collect required materializers (if any)
unlock the session
dispatch materializers (if any)

このコンテキストでは、マテリアライザは、要求に応じてシンボルの実行可能な定義を提供するものです。通常、マテリアライザはコンパイラのラッパーにすぎませんが、(定義をバックアップするプログラム表現がオブジェクトファイルである場合) JIT リンカーを直接ラップすることも、(たとえば、定義がスタブである場合) ビットをメモリに直接書き込むクラスにすることもできます。マテリアライゼーションは、呼び出しまたはアクセスしても安全なシンボル定義を生成するために必要なすべてのアクション (コンパイル、リンキング、ビットのスプラッティング、ランタイムへの登録など) の包括的な用語です。

各マテリアライザは作業を完了すると、JITDylib に通知し、JITDylib は次に、新しくマテリアライズされた定義を待機しているすべてのクエリオブジェクトに通知します。各クエリオブジェクトは、まだ待機中のシンボルの数をカウントし、このカウントがゼロに達すると、クエリオブジェクトは結果を記述するSymbolMap (シンボル名からアドレスへのマップ) を持つクエリハンドラを呼び出します。シンボルのマテリアライズに失敗した場合、クエリはエラーを伴ってクエリハンドラをすぐに呼び出します。

収集されたマテリアライゼーションユニットは、ディスパッチされるために ExecutionSession に送信され、ディスパッチ動作はクライアントが設定できます。デフォルトでは、各マテリアライザは呼び出し元のスレッドで実行されます。クライアントは、マテリアライザを実行するために新しいスレッドを作成したり、作業をスレッドプールの作業キューに送信したりできます (これが LLJIT/LLLazyJIT の実行内容です)。

トップレベル API

上記の例では、ORC のトップレベル API の多くが表示されます。

  • ExecutionSession は JIT されたプログラムを表し、JIT のコンテキストを提供します。JITDylib、エラー報告メカニズムが含まれており、マテリアライザをディスパッチします。

  • JITDylibs はシンボルテーブルを提供します。

  • Layers (ObjLinkingLayer および CXXLayer) は、コンパイラのラッパーであり、クライアントはこれらのコンパイラでサポートされているコンパイルされていないプログラム表現を JITDylib に追加できます。

  • ResourceTrackers を使用すると、コードを削除できます。

他にもいくつかの重要な API が明示的に使用されています。JIT クライアントはそれらを認識する必要はありませんが、Layer の作成者はそれらを使用します。

  • MaterializationUnit - XXXLayer::add が呼び出されると、与えられたプログラム表現(この例では C++ ソース)は MaterializationUnit にラップされ、その後 JITDylib に格納されます。MaterializationUnit は、提供する定義を記述し、コンパイルが必要な場合にプログラム表現をアンラップしてレイヤーに返す役割を担います(この所有権のシャッフルにより、プログラム表現の所有権がレイヤーのメンバーから取り出すのではなくスタック上で返されるため、スレッドセーフなレイヤーの作成が容易になります。レイヤーのメンバーから取り出す場合には同期が必要になります)。

  • MaterializationResponsibility - MaterializationUnit がプログラム表現をレイヤーに返す際、関連付けられた MaterializationResponsibility オブジェクトが付属します。このオブジェクトは、実体化する必要がある定義を追跡し、それらが正常に実体化されたか、またはエラーが発生した場合に JITDylib に通知する方法を提供します。

絶対シンボル、エイリアス、および再エクスポート

ORC を使用すると、絶対アドレスを持つシンボルや、他のシンボルの単なるエイリアスであるシンボルを簡単に定義できます。

絶対シンボル

絶対シンボルは、さらなる実体化を必要とせずにアドレスに直接マッピングされるシンボルです。例:「foo」= 0x1234。絶対シンボルのユースケースの 1 つは、プロセスシンボルの解決を可能にすることです。例:

JD.define(absoluteSymbols(SymbolMap({
    { Mangle("printf"),
      { ExecutorAddr::fromPtr(&printf),
        JITSymbolFlags::Callable } }
  });

このマッピングが確立されると、JIT に追加されたコードは、printf のアドレスを「組み込む」必要なく、シンボリックに printf を参照できます。これにより、JIT コードのキャッシュされたバージョン(たとえば、コンパイルされたオブジェクト)を JIT セッション間で再利用できるようになります。JIT コードは変更されなくなり、変更されるのは絶対シンボル定義のみとなるためです。

プロセスシンボルおよびライブラリシンボルの場合、DynamicLibrarySearchGenerator ユーティリティ(JITDylib にプロセスおよびライブラリシンボルを追加する方法を参照)を使用して、絶対シンボルマッピングを自動的に構築できます。ただし、absoluteSymbols 関数は、JIT 内のグローバルでないオブジェクトを JIT コードから見えるようにする場合にも役立ちます。たとえば、JIT 標準ライブラリが JIT オブジェクトにアクセスしていくつかの呼び出しを行う必要があると想像してください。オブジェクトのアドレスをライブラリに組み込むこともできますが、その場合はセッションごとに再コンパイルする必要があります。

// From standard library for JIT'd code:

class MyJIT {
public:
  void log(const char *Msg);
};

void log(const char *Msg) { ((MyJIT*)0x1234)->log(Msg); }

これを JIT 標準ライブラリのシンボリック参照に変えることができます。

extern MyJIT *__MyJITInstance;

void log(const char *Msg) { __MyJITInstance->log(Msg); }

次に、JIT の起動時に絶対シンボル定義を使用して、JIT オブジェクトを JIT 標準ライブラリから見えるようにします。

MyJIT J = ...;

auto &JITStdLibJD = ... ;

JITStdLibJD.define(absoluteSymbols(SymbolMap({
    { Mangle("__MyJITInstance"),
      { ExecutorAddr::fromPtr(&J), JITSymbolFlags() } }
  });

エイリアスと再エクスポート

エイリアスと再エクスポートを使用すると、既存のシンボルにマッピングされる新しいシンボルを定義できます。これは、コードを再コンパイルすることなく、セッション間でシンボル間のリンケージ関係を変更する場合に役立ちます。たとえば、JIT コードがログ関数 void log(const char*) にアクセスでき、JIT 標準ライブラリに log_fastlog_detailed の 2 つの実装があることを想像してください。JIT は、JIT の起動時にエイリアスを設定することにより、log シンボルが参照されたときに使用される定義を選択できます。

auto &JITStdLibJD = ... ;

auto LogImplementationSymbol =
 Verbose ? Mangle("log_detailed") : Mangle("log_fast");

JITStdLibJD.define(
  symbolAliases(SymbolAliasMap({
      { Mangle("log"),
        { LogImplementationSymbol
          JITSymbolFlags::Exported | JITSymbolFlags::Callable } }
    });

symbolAliases 関数を使用すると、単一の JITDylib 内でエイリアスを定義できます。reexports 関数は同じ機能を提供しますが、JITDylib の境界を越えて動作します。例:

auto &JD1 = ... ;
auto &JD2 = ... ;

// Make 'bar' in JD2 an alias for 'foo' from JD1.
JD2.define(
  reexports(JD1, SymbolAliasMap({
      { Mangle("bar"), { Mangle("foo"), JITSymbolFlags::Exported } }
    });

reexports ユーティリティは、複数の他の JITDylib からシンボルを再エクスポートすることにより、単一の JITDylib インターフェイスを構成する場合に役立ちます。

遅延評価

ORC の遅延評価は、「遅延再エクスポート」と呼ばれるユーティリティによって提供されます。遅延再エクスポートは、通常の再エクスポートまたはエイリアスに似ています。既存のシンボルに新しい名前を提供します。ただし、通常の再エクスポートとは異なり、遅延再エクスポートのルックアップでは、再エクスポートされたシンボルの即時実体化はトリガーされません。代わりに、関数スタブの実体化のみがトリガーされます。この関数スタブは、JIT への再入力を提供する遅延コールスルーを指すように初期化されます。スタブが実行時に呼び出されると、遅延コールスルーは再エクスポートされたシンボルをルックアップし(必要に応じてそのシンボルの実体化をトリガーします)、スタブを更新して(後続の呼び出しで再エクスポートされたシンボルを直接呼び出すように)、再エクスポートされたシンボル経由で戻ります。既存のシンボルルックアップメカニズムを再利用することにより、遅延再エクスポートは同じ並行性保証を継承します。遅延再エクスポートへの呼び出しは複数のスレッドから同時に行うことができ、再エクスポートされたシンボルはコンパイルの任意の状態(未コンパイル、コンパイル中、またはすでにコンパイル済み)になり、呼び出しは成功します。これにより、遅延評価を、リモートコンパイル、同時コンパイル、同時 JIT コード、投機的コンパイルなどの機能と安全に組み合わせることができます。

通常の再エクスポートと遅延再エクスポートには、クライアントが認識する必要があるもう 1 つの重要な違いがあります。遅延再エクスポートのアドレスは、再エクスポートされたシンボルのアドレスとは異なります(一方、通常の再エクスポートは、再エクスポートされたシンボルと同じアドレスを持つことが保証されています)。ポインタの等価性を気にするクライアントは、通常、再エクスポートのアドレスを再エクスポートされたシンボルの正規アドレスとして使用します。これにより、再エクスポートの実体化を強制することなくアドレスを取得できます。

使用例

JITDylib JD にシンボル foo_body および bar_body の定義が含まれている場合、次を呼び出すことにより、JITDylib JD2 に遅延エントリポイント Foo および Bar を作成できます。

auto ReexportFlags = JITSymbolFlags::Exported | JITSymbolFlags::Callable;
JD2.define(
  lazyReexports(CallThroughMgr, StubsMgr, JD,
                SymbolAliasMap({
                  { Mangle("foo"), { Mangle("foo_body"), ReexportedFlags } },
                  { Mangle("bar"), { Mangle("bar_body"), ReexportedFlags } }
                }));

LLJIT クラスで lazyReexports を使用する方法の完全な例は、llvm/examples/OrcV2Examples/LLJITWithLazyReexports にあります。

カスタムコンパイラのサポート

TBD。

ORCv1 から ORCv2 への移行

LLVM 7.0 以降、新しい ORC 開発作業は、同時 JIT コンパイルのサポートの追加に焦点が当てられています。同時実行をサポートする新しい API(新しいレイヤーインターフェイスと実装、および新しいユーティリティを含む)は、まとめて ORCv2 と呼ばれ、元の非同時実行レイヤーとユーティリティは、ORCv1 と呼ばれるようになりました。

ORCv1 のレイヤーとユーティリティの大部分は、LLVM 8.0 で「Legacy」プレフィックスが付いて名前が変更され、LLVM 9.0 で非推奨の警告が添付されています。LLVM 12.0 では、ORCv1 は完全に削除されます。

ORCv1 から ORCv2 への移行は、ほとんどのクライアントにとって簡単であるはずです。ほとんどの ORCv1 のレイヤーとユーティリティには、直接置き換えることができる ORCv2 の対応物[2]があります。ただし、ORCv1 と ORCv2 の間には、注意すべきいくつかの設計上の違いがあります。

  1. ORCv2 は、MCJIT で始まった JIT-as-linker モデルを完全に採用しています。モジュール(およびその他のプログラム表現、たとえばオブジェクトファイル)は、JIT クラスまたはレイヤーに直接追加されなくなりました。代わりに、レイヤーによって JITDylib インスタンスに追加されます。JITDylib は定義がどこに存在するかを決定し、レイヤーは定義をどのようにコンパイルするかを決定します。JITDylibs 間のリンケージ関係は、モジュール間の参照がどのように解決されるかを決定し、シンボルリゾルバーは使用されなくなりました。詳細については、「設計概要」のセクションを参照してください。

    リンケージ関係をモデル化するために複数の JITDylib が必要な場合を除き、ORCv1 クライアントはすべてのコードを単一の JITDylib に配置する必要があります。MCJIT クライアントは LLJIT を使用し(「LLJIT および LLLazyJIT」を参照)、LLJIT のデフォルトで作成されたメイン JITDylib にコードを配置できます(LLJIT::getMainJITDylib() を参照)。

  2. すべての JIT スタックに ExecutionSession インスタンスが必要になりました。ExecutionSession は、文字列プール、エラーレポート、同期、シンボルルックアップを管理します。

  3. ORCv2 では、メモリオーバーヘッドを削減し、ルックアップパフォーマンスを向上させるために、文字列値ではなく一意の文字列(SymbolStringPtr インスタンス)を使用します。「シンボル文字列を管理する方法」のサブセクションを参照してください。

  4. IR レイヤーには、std::unique_ptr<Module> ではなく ThreadSafeModule インスタンスが必要です。ThreadSafeModule は、同じ LLVMContext を使用するモジュールに同時にアクセスされないようにするためのラッパーです。「ThreadSafeModule と ThreadSafeContext の使用方法」を参照してください。

  5. シンボルルックアップはレイヤーによって処理されなくなりました。代わりに、スキャンする JITDylib のリストを受け取る JITDylib の lookup メソッドがあります。

    ExecutionSession ES;
    JITDylib &JD1 = ...;
    JITDylib &JD2 = ...;
    
    auto Sym = ES.lookup({&JD1, &JD2}, ES.intern("_main"));
    
  6. removeModule/removeObject メソッドは、ResourceTracker::remove に置き換えられました。「コードを削除する方法」のサブセクションを参照してください。

ORCv2 API の使用方法のコード例と提案については、「ハウツー」のセクションを参照してください。

ハウツー

シンボル文字列を管理する方法

ORC のシンボル文字列は、ルックアップパフォーマンスを向上させ、メモリオーバーヘッドを削減し、シンボル名を効率的なキーとして機能させるために一意にされています。文字列値の一意の SymbolStringPtr を取得するには、ExecutionSession::intern メソッドを呼び出します。

ExecutionSession ES;
/// ...
auto MainSymbolName = ES.intern("main");

シンボルの C/IR 名を使用してルックアップを実行する場合は、文字列をインターンする前にプラットフォームリンカーのマングルも適用する必要があります。Linux では、このマングルは何も操作しませんが、他のプラットフォームでは、通常、文字列にプレフィックスを追加する必要があります(たとえば、Darwin では「_」)。マングルスキームは、ターゲットの DataLayout に基づいています。DataLayout と ExecutionSession が与えられたら、両方のジョブを実行する MangleAndInterner 関数オブジェクトを作成できます。

ExecutionSession ES;
const DataLayout &DL = ...;
MangleAndInterner Mangle(ES, DL);

// ...

// Portable IR-symbol-name lookup:
auto Sym = ES.lookup({&MainJD}, Mangle("main"));

JITDylib を作成し、リンケージ関係を設定する方法

ORC では、すべてのシンボル定義は JITDylib に存在します。JITDylib は、ExecutionSession::createJITDylib メソッドを一意の名前で呼び出すことによって作成されます。

ExecutionSession ES;
auto &JD = ES.createJITDylib("libFoo.dylib");

JITDylib は ExecutionEngine インスタンスによって所有され、破棄されるときに解放されます。

コードを削除する方法

JITDylib から個々のモジュールを削除するには、最初に明示的な ResourceTracker を使用して追加する必要があります。その後、ResourceTracker::remove を呼び出すことによってモジュールを削除できます。

auto &JD = ... ;
auto M = ... ;

auto RT = JD.createResourceTracker();
Layer.add(RT, std::move(M)); // Add M to JD, tracking resources with RT

RT.remove(); // Remove M from JD.

JITDylib に直接追加されたモジュールは、その JITDylib のデフォルトのリソーストラッカーによって追跡されます。

すべてのコードは、JITDylib::clear を呼び出すことによって JITDylib から削除できます。これにより、クリアされた JITDylib は空ですが使用可能な状態になります。

JITDylib は、ExecutionSession::removeJITDylib を呼び出すことによって削除できます。これにより、JITDylib がクリアされ、その後、使用不能な状態になります。JITDylib に対してそれ以上の操作を実行できなくなり、最後にそのハンドルが解放されるとすぐに破棄されます。

リソース管理APIの使用例は、llvm/examples/OrcV2Examples/LLJITRemovableCodeにあります。

カスタムプログラム表現のサポートを追加する方法

カスタムプログラム表現のサポートを追加するには、プログラム表現用のカスタムMaterializationUnitと、カスタムLayerが必要です。Layerには、addemitの2つの操作があります。add操作は、プログラム表現のインスタンスを受け取り、それを保持するためのカスタムMaterializationUnitsの1つを構築し、それをJITDylibに追加します。emit操作は、MaterializationResponsibilityオブジェクトとプログラム表現のインスタンスを受け取り、通常はコンパイルして結果のオブジェクトをObjectLinkingLayerに渡すことで、それを具体化します。

カスタムMaterializationUnitには、materializediscardの2つの操作があります。materialize関数は、ユニットによって提供されるシンボルが検索されるときに呼び出され、与えられたMaterializationResponsibilityとラップされたプログラム表現を渡して、レイヤーのemit関数を呼び出す必要があります。discard関数は、ユニットによって提供される一部の弱いシンボルが不要になった場合(JITがオーバーライド定義を見つけたため)に呼び出されます。これを使用して定義を早期に破棄することも、無視してリンカーに後で定義を破棄させることもできます。

以下はASTLayerの例です。

// ... In you JIT class
AstLayer astLayer;
// ...


class AstMaterializationUnit : public orc::MaterializationUnit {
public:
  AstMaterializationUnit(AstLayer &l, Ast &ast)
  : llvm::orc::MaterializationUnit(l.getInterface(ast)), astLayer(l),
  ast(ast) {};

  llvm::StringRef getName() const override {
    return "AstMaterializationUnit";
  }

  void materialize(std::unique_ptr<orc::MaterializationResponsibility> r) override {
    astLayer.emit(std::move(r), ast);
  };

private:
  void discard(const llvm::orc::JITDylib &jd, const llvm::orc::SymbolStringPtr &sym) override {
    llvm_unreachable("functions are not overridable");
  }


  AstLayer &astLayer;
  Ast &ast;
};

class AstLayer {
  llvhm::orc::IRLayer &baseLayer;
  llvhm::orc::MangleAndInterner &mangler;

public:
  AstLayer(llvm::orc::IRLayer &baseLayer, llvm::orc::MangleAndInterner &mangler)
  : baseLayer(baseLayer), mangler(mangler){};

  llvm::Error add(llvm::orc::ResourceTrackerSP &rt, Ast &ast) {
    return rt->getJITDylib().define(std::make_unique<AstMaterializationUnit>(*this, ast), rt);
  }

  void emit(std::unique_ptr<orc::MaterializationResponsibility> mr, Ast &ast) {
    // compileAst is just function that compiles the given AST and returns
    // a `llvm::orc::ThreadSafeModule`
    baseLayer.emit(std::move(mr), compileAst(ast));
  }

  llvm::orc::MaterializationUnit::Interface getInterface(Ast &ast) {
      SymbolFlagsMap Symbols;
      // Find all the symbols in the AST and for each of them
      // add it to the Symbols map.
      Symbols[mangler(someNameFromAST)] =
        JITSymbolFlags(JITSymbolFlags::Exported | JITSymbolFlags::Callable);
      return MaterializationUnit::Interface(std::move(Symbols), nullptr);
  }
};

完全な例については、Building A JIT's Chapter 4のソースコードをご覧ください。

ThreadSafeModuleとThreadSafeContextの使用方法

ThreadSafeModuleとThreadSafeContextは、それぞれModulesとLLVMContextsをラップしたものです。ThreadSafeModuleは、std::unique_ptr<Module>と(共有される可能性がある)ThreadSafeContext値のペアです。ThreadSafeContextは、std::unique_ptr<LLVMContext>とロックのペアです。この設計には、LLVMContextのロック機構とライフタイム管理を提供するという2つの目的があります。ThreadSafeContextは、同じLLVMContextを使用する2つのModulesによる偶発的な同時アクセスを防ぐためにロックできます。基盤となるLLVMContextは、それを指すThreadSafeContext値がすべて破棄されると解放されるため、参照しているModulesが破棄されるとすぐにコンテキストメモリを再利用できます。

ThreadSafeContextは、std::unique_ptr<LLVMContext>から明示的に構築できます。

ThreadSafeContext TSCtx(std::make_unique<LLVMContext>());

ThreadSafeModuleは、std::unique_ptr<Module>とThreadSafeContext値のペアから構築できます。ThreadSafeContext値は、複数のThreadSafeModules間で共有できます。

ThreadSafeModule TSM1(
  std::make_unique<Module>("M1", *TSCtx.getContext()), TSCtx);

ThreadSafeModule TSM2(
  std::make_unique<Module>("M2", *TSCtx.getContext()), TSCtx);

ThreadSafeContextを使用する前に、クライアントは、コンテキストが現在のスレッドでのみアクセス可能であるか、コンテキストがロックされていることを確認する必要があります。上記の例(コンテキストがロックされていない場合)では、TSM1TSM2、およびTSCtxがすべて1つのスレッドで作成されているという事実に依存しています。コンテキストがスレッド間で共有される場合は、コンテキストにアタッチされたModulesへのアクセスまたは作成を行う前に、ロックする必要があります。例:

ThreadSafeContext TSCtx(std::make_unique<LLVMContext>());

DefaultThreadPool TP(NumThreads);
JITStack J;

for (auto &ModulePath : ModulePaths) {
  TP.async(
    [&]() {
      auto Lock = TSCtx.getLock();
      auto M = loadModuleOnContext(ModulePath, TSCtx.getContext());
      J.addModule(ThreadSafeModule(std::move(M), TSCtx));
    });
}

TP.wait();

Modulesへの排他的アクセスを管理しやすくするために、ThreadSafeModuleクラスは、便利な関数withModuleDoを提供します。この関数は、暗黙的に(1)関連付けられたコンテキストをロックし、(2)指定された関数オブジェクトを実行し、(3)コンテキストをロック解除し、(3)関数オブジェクトによって生成された結果を返します。例:

ThreadSafeModule TSM = getModule(...);

// Dump the module:
size_t NumFunctionsInModule =
  TSM.withModuleDo(
    [](Module &M) { // <- Context locked before entering lambda.
      return M.size();
    } // <- Context unlocked after leaving.
  );

同時コンパイルの可能性を最大化したいクライアントは、新しいThreadSafeModuleを新しいThreadSafeContext上に作成する必要があります。このため、ThreadSafeModuleには、std::unique_ptr<LLVMContext>から新しいThreadSafeContext値を暗黙的に構築する便利なコンストラクターが用意されています。

// Maximize concurrency opportunities by loading every module on a
// separate context.
for (const auto &IRPath : IRPaths) {
  auto Ctx = std::make_unique<LLVMContext>();
  auto M = std::make_unique<Module>("M", *Ctx);
  CompileLayer.add(MainJD, ThreadSafeModule(std::move(M), std::move(Ctx)));
}

シングルスレッドで実行する予定のクライアントは、すべてのモジュールを同じコンテキストにロードすることでメモリを節約することを選択できます。

// Save memory by using one context for all Modules:
ThreadSafeContext TSCtx(std::make_unique<LLVMContext>());
for (const auto &IRPath : IRPaths) {
  ThreadSafeModule TSM(parsePath(IRPath, *TSCtx.getContext()), TSCtx);
  CompileLayer.add(MainJD, ThreadSafeModule(std::move(TSM));
}

JITDylibsにプロセスとライブラリのシンボルを追加する方法

JITコードは、ホストプログラムまたはサポートライブラリのシンボルにアクセスする必要がある場合があります。これを有効にする最善の方法は、これらのシンボルをJITDylibsに反映して、実行セッション内で定義された他のシンボルと同じように表示されるようにすることです(つまり、ExecutionSession::lookupを介して検索可能であり、リンク中にJITリンカーから見えるようにします)。

外部シンボルを反映する方法の1つは、absoluteSymbols関数を使用して手動で追加することです。

const DataLayout &DL = getDataLayout();
MangleAndInterner Mangle(ES, DL);

auto &JD = ES.createJITDylib("main");

JD.define(
  absoluteSymbols({
    { Mangle("puts"), ExecutorAddr::fromPtr(&puts)},
    { Mangle("gets"), ExecutorAddr::fromPtr(&getS)}
  }));

反映するシンボルのセットが小さく固定されている場合は、absoluteSymbolsを使用するのが妥当です。一方、シンボルのセットが大きい場合や可変の場合は、定義ジェネレーターによってオンデマンドで定義を追加する方が理にかなっている可能性があります。定義ジェネレーターは、JITDylibにアタッチできるオブジェクトで、そのJITDylib内の検索で1つ以上のシンボルが見つからなかった場合にコールバックを受信します。定義ジェネレーターには、検索が続行される前に、欠落しているシンボルの定義を作成する機会が与えられます。

ORCは、プロセス(または特定の動的ライブラリ)からシンボルを反映するためのDynamicLibrarySearchGeneratorユーティリティを提供します。たとえば、ランタイムライブラリのインターフェース全体を反映するには

const DataLayout &DL = getDataLayout();
auto &JD = ES.createJITDylib("main");

if (auto DLSGOrErr =
    DynamicLibrarySearchGenerator::Load("/path/to/lib"
                                        DL.getGlobalPrefix()))
  JD.addGenerator(std::move(*DLSGOrErr);
else
  return DLSGOrErr.takeError();

// IR added to JD can now link against all symbols exported by the library
// at '/path/to/lib'.
CompileLayer.add(JD, loadModule(...));

DynamicLibrarySearchGeneratorユーティリティは、反映できるシンボルのセットを制限するためのフィルター関数を使用して構築することもできます。たとえば、メインプロセスから許可されたシンボルのセットを公開するには

const DataLayout &DL = getDataLayout();
MangleAndInterner Mangle(ES, DL);

auto &JD = ES.createJITDylib("main");

DenseSet<SymbolStringPtr> AllowList({
    Mangle("puts"),
    Mangle("gets")
  });

// Use GetForCurrentProcess with a predicate function that checks the
// allowed list.
JD.addGenerator(cantFail(DynamicLibrarySearchGenerator::GetForCurrentProcess(
      DL.getGlobalPrefix(),
      [&](const SymbolStringPtr &S) { return AllowList.count(S); })));

// IR added to JD can now link against any symbols exported by the process
// and contained in the list.
CompileLayer.add(JD, loadModule(...));

プロセスまたはライブラリのシンボルへの参照は、シンボルの生のアドレスを使用してIRまたはオブジェクトファイルにハードコードすることもできますが、JITシンボルテーブルを使用したシンボリック解決を優先する必要があります。これにより、IRとオブジェクトが読みやすく、後続のJITセッションで再利用できるようになります。ハードコードされたアドレスは読みにくく、通常は1つのセッションにしか適していません。

ロードマップ

ORCは現在も活発に開発中です。現在および将来の作業の一部を以下に示します。

現在の作業

  1. TargetProcessControl:プロセス外実行のインツリーサポートの改善

    TargetProcessControlAPIは、メモリ割り当て、メモリ書き込み、関数実行、およびプロセス照会(ターゲットトリプルなど)を含む、JITターゲットプロセス(JITコードを実行するプロセス)に対するさまざまな操作を提供します。このAPIをターゲットにすることで、インプロセスJITとアウトプロセスJITの両方で同様に機能する新しいコンポーネントを開発できます。

  2. ORC RPCベースのTargetProcessControl実装

    TargetProcessControlAPIのORC RPCベースの実装は、ファイル記述子/ソケットを介した簡単なアウトプロセスJITを可能にするために現在開発中です。

  3. コアステートマシンのクリーンアップ

    コアORCステートマシンは現在、JITDylibとExecutionSessionの間で実装されています。メソッドは徐々にExecutionSessionに移動されています。これにより、コードベースが整理され、JITDylibsの非同期削除(実際にはExecutionSessionで関連付けられたステートオブジェクトを削除し、そのすべての参照が解放されるまでJITDylibインスタンスを失効状態のままにする)もサポートできるようになります。

近い将来の作業

  1. ORC JITランタイムライブラリ

    JITコード用のランタイムライブラリが必要です。これには、TLS登録、再入関数、言語ランタイム(Objective CやSwiftなど)の登録コード、およびその他のJIT固有のランタイムコードが含まれます。これはcompiler-rtと同様の方法で(場合によってはその一部として)構築する必要があります。

  2. リモートjit_dlopen / jit_dlclose

    静的プログラムが動作する環境をより完全に模倣するために、JITコードが現在のスレッドですべての初期化子/非初期化子を実行してJITDylibsを「dlopen」および「dlclose」できるようにする必要があります。これには、上記のランタイムライブラリからのサポートが必要です。

  3. デバッグサポート

    ORCは現在、基盤となるJITリンカーとしてRuntimeDyldを使用する場合、GDBRegistrationListener APIをサポートしています。JITLinkベースのプラットフォームには、新しいソリューションが必要です。

さらに将来の作業

  1. 投機的コンパイル

    ORCの同時コンパイルのサポートにより、投機的JITコンパイルを簡単に有効にできます。これは、まだ必要ではないが、将来必要になると考えられるコードのコンパイルです。これを使用して、コンパイルの遅延を隠し、JITスループットを向上させることができます。ORCを使用した投機的コンパイルの概念実証の例はすでに開発されています(llvm/examples/SpeculativeJITを参照)。この点に関する今後の作業は、投機的決定をフィードするための既存のプロファイリングサポート(現在PGOで使用)の再利用と改善、および投機的コンパイルの使用を簡素化するための組み込みツールに焦点を当てることが予想されます。