LLVMにおけるスタックマップとパッチポイント

定義

このドキュメントでは、「ランタイム」を、LLVMクライアントとして機能するすべてのコンポーネント(LLVM IRジェネレーター、オブジェクトコードコンシューマー、コードパッチャーを含む)をまとめて指します。

スタックマップは、特定の命令アドレスにおけるlive valuesの位置を記録します。これらのlive valuesは、スタックマップ全体で有効なすべてのLLVM値を指すわけではありません。代わりに、ランタイムがその時点で有効である必要がある値のみです。例えば、それらは、スタックマップを含むコンパイル済み関数とは独立して、その時点でプログラムの実行を再開するためにランタイムが必要とする値かもしれません。

LLVMは、指定されたスタックマップセクション内のオブジェクトコードにスタックマップデータを埋め込みます。このスタックマップデータには、各スタックマップのレコードが含まれています。レコードはスタックマップの命令アドレスを格納し、マッピングされた各値のエントリを含みます。各エントリは、レジスタ、スタックオフセット、または定数として値の位置をエンコードします。

パッチポイントは、実行時に新しい命令シーケンスをパッチするためにスペースが予約されている命令アドレスです。パッチポイントは、LLVMへの呼び出しと非常によく似ています。呼び出し規約に従う引数を取り、値を返す場合があります。また、ランタイムがパッチポイントを見つけ、その時点でのlive valuesの位置を見つけることができるスタックマップの生成も意味します。

モチベーション

この機能は現在実験段階ですが、ランタイム(JIT)コンパイラが最も分かりやすい例として、さまざまな設定で役立つ可能性があります。パッチポイント組み込み関数のアプリケーションの例としては、ポリモーフィックメソッドディスパッチのインラインコールキャッシュの実装や、JavaScriptなどの動的型付け言語のプロパティの取得の最適化などがあります。

ここに記載されている組み込み関数は、現在オープンソースのWebKitプロジェクト内のJavaScriptコンパイラで使用されています(FTL JITを参照)。ただし、スタックマップまたはコードパッチが必要な場合はいつでも使用できるように設計されています。組み込み関数は実験段階であるため、LLVMリリース間の互換性は保証されません。

このドキュメントで説明されているスタックマップ機能は、スタックマップの計算で説明されている機能とは別です。GCFunctionMetadataは、GCRoot組み込み関数によってキャプチャされた収集済みヒープへのポインタの位置を提供します。これは「スタックマップ」と見なすこともできます。上記のスタックマップとは異なり、GCFunctionMetadataスタックマップインターフェースは、任意の型の有効なレジスタ値を命令アドレスに関連付ける方法も、結果として得られるスタックマップのフォーマットも指定しません。ここで説明されているスタックマップは、ガベージコレクションランタイムにより豊富な情報を提供できる可能性がありますが、このドキュメントではその使用方法については説明しません。

組み込み関数

次の2種類の組み込み関数を使用して、スタックマップとパッチポイントを実装できます。llvm.experimental.stackmapllvm.experimental.patchpointです。どちらの種類の組み込み関数もスタックマップレコードを生成し、何らかの形式のコードパッチを許可します。これらは独立して使用できます(つまり、llvm.experimental.patchpointは、llvm.experimental.stackmapへの追加の呼び出しを必要とせずに、暗黙的にスタックマップを生成します)。どちらを使用するかは、コードパッチのためにスペースを予約する必要があるかどうか、および組み込み関数の引数のいずれかを呼び出し規約に従ってローワーする必要があるかによって異なります。llvm.experimental.stackmapはスペースを予約せず、呼び出し引数も期待しません。ランタイムがスタックマップのアドレスでコードをパッチする場合、プログラムテキストを破壊的に上書きします。これは、周囲のコードを上書きすることなく、インプレースパッチのためにスペースを予約するllvm.experimental.patchpointとは異なります。llvm.experimental.patchpoint組み込み関数は、呼び出し規約に従って指定された数の引数をローワーします。これにより、パッチされたコードはマーシャリングせずにインプレース関数呼び出しを行うことができます。

これらの組み込み関数の各インスタンスは、スタックマップセクションにスタックマップレコードを生成します。レコードにはIDが含まれており、ランタイムがスタックマップを一意に識別し、囲んでいる関数の先頭からのコード内のオフセットを許可します。

llvm.experimental.stackmap’ 組み込み関数

構文:

declare void
  @llvm.experimental.stackmap(i64 <id>, i32 <numShadowBytes>, ...)

概要:

llvm.experimental.stackmap’組み込み関数は、コードを生成せずに、スタックマップ内の指定された値の位置を記録します。

オペランド:

最初のオペランドは、スタックマップ内にエンコードされるIDです。2番目のオペランドは、組み込み関数に続くシャドウバイトの数です。続く可変数のオペランドは、スタックマップに位置が記録されるlive valuesです。

この組み込み関数を、コードパッチサポートのないベアメタルスタックマップとして使用するには、シャドウバイトの数を0に設定できます。

セマンティクス:

スタックマップ組み込み関数は、そのシャドウをカバーするためにnopが必要でない限り、その場にコードを生成しません(下記参照)。ただし、関数エントリからのオフセットはスタックマップに格納されます。これは、スタックマップの前にある命令の直後にある相対命令アドレスです。

スタックマップIDにより、ランタイムは目的のスタックマップレコードを見つけることができます。LLVMはこのIDを一意性を確認せずにスタックマップレコードに直接渡します。

LLVMは、スタックマップの命令オフセットの後に続く命令のシャドウを保証します。その間、基本ブロックの終わり、またはllvm.experimental.stackmapまたはllvm.experimental.patchpointへの別の呼び出しが発生することはありません。これにより、ランタイムは、コードの外部からトリガーされたイベントに応じて、この時点でコードをパッチできます。スタックマップの後の命令のコードはスタックマップのシャドウにエミットされ、これらの命令は破壊的なパッチによって上書きされる可能性があります。シャドウバイトがない場合、この破壊的なパッチにより、現在の関数外のプログラムテキストまたはデータが上書きされる可能性があります。ランタイムがこのコーナーケースを考慮する必要がないように、重複するスタックマップシャドウは許可しません。

例えば、8バイトのシャドウを持つスタックマップ

call void @runtime()
call void (i64, i32, ...) @llvm.experimental.stackmap(i64 77, i32 8,
                                                      ptr %ptr)
%val = load i64, ptr %ptr
%add = add i64 %val, 3
ret i64 %add

1バイトのnopパディングが必要になる場合があります

0x00 callq _runtime
0x05 nop                <--- stack map address
0x06 movq (%rdi), %rax
0x07 addq $3, %rax
0x0a popq %rdx
0x0b ret                <---- end of 8-byte shadow

ここで、ランタイムがコンパイルされたコードを無効にする必要がある場合、スタックマップのアドレスに続くように8バイトのコードをパッチできます。

0x00 callq _runtime
0x05 movl  $0xffff, %rax <--- patched code at stack map address
0x0a callq *%rax         <---- end of 8-byte shadow

このように、ランタイムへの通常の呼び出しが戻った後、コードはスタックマップによって位置が特定された値からスタックフレームを再構築できる特別なエントリポイントへのパッチされた呼び出しを実行します。

llvm.experimental.patchpoint.*’ 組み込み関数

構文:

declare void
  @llvm.experimental.patchpoint.void(i64 <id>, i32 <numBytes>,
                                     ptr <target>, i32 <numArgs>, ...)
declare i64
  @llvm.experimental.patchpoint.i64(i64 <id>, i32 <numBytes>,
                                    ptr <target>, i32 <numArgs>, ...)

概要:

llvm.experimental.patchpoint.*’組み込み関数は、指定された<target>への関数呼び出しを作成し、スタックマップ内の指定された値の位置を記録します。

オペランド:

最初のオペランドはID、2番目のオペランドはパッチ可能な領域のために予約されたバイト数、3番目のオペランドは関数のターゲットアドレス(オプションでnull)、4番目のオペランドは、以下の可変数のオペランドのうち関数の呼び出し引数と見なされる数を指定します。残りの可変数のオペランドは、スタックマップに位置が記録されるlive valuesです。

セマンティクス:

パッチポイント組み込み関数はスタックマップを生成します。また、アドレスが定数nullでない場合、<target>で指定されたアドレスへの関数呼び出しをエミットします。関数呼び出しとその引数は、組み込み関数の呼び出しサイトで指定された呼び出し規約に従ってローワーされます。void以外の戻り値を持つ組み込み関数のバリアントは、呼び出し規約に従って値を返します。

PowerPCでは、<target>は、間接呼び出しの対象となるABI関数ポインタでなければなりません。具体的には、ELF V1 ABIでコンパイルする場合、<target>は、通常C/C++関数ポインタ表現として使用される関数記述子アドレスです。

パッチポイント引数を0に要求することは有効です。この場合、すべての可変オペランドはllvm.experimental.stackmap.*のように処理されます。違いは、パッチのためにスペースが予約され、呼び出しがエミットされ、戻り値が許可されることです。

引数の位置は、呼び出し規約によって既に固定されているため、通常はスタックマップに記録されません。残りのlive valuesは、レジスタ、スタックの位置、または定数のいずれかとして、その位置が記録されます。スタックマップで使用するために、引数をレジスタにロードすることを強制するが、それらのレジスタを動的に割り当てることを許可する特別な呼び出し規約`anyregcc`が導入されました。これらの引数レジスタは、残りのlive valuesに加えて、スタックマップにレジスタ位置が記録されます。

パッチポイントは、少なくとも<numBytes>の命令エンコード空間をカバーするためにnopも出力します。したがって、クライアントは<numBytes>が、サポートされているターゲットでターゲットアドレスへの呼び出しをエンコードするのに十分であることを確認する必要があります。呼び出し先が定数nullの場合、最小要件はありません。0バイトのnullターゲットパッチポイントは有効です。

ランタイムは、呼び出しシーケンスとnopを含む、パッチポイントに対して出力されたコードをパッチすることができます。ただし、ランタイムは予約された空間内でLLVMが出力するコードについて何も想定することはできません。部分的なパッチは許可されていません。ランタイムは、必要に応じてnopでパディングして、すべての予約バイトをパッチする必要があります。

この例は、ネイティブ呼び出し規約に従って、$rdiに1つの引数、$raxに戻り値がある、15バイトを予約するパッチポイントを示しています。

%target = inttoptr i64 -281474976710654 to ptr
%val = call i64 (i64, i32, ...)
         @llvm.experimental.patchpoint.i64(i64 78, i32 15,
                                           ptr %target, i32 1, ptr %ptr)
%add = add i64 %val, 3
ret i64 %add

生成される可能性があります

0x00 movabsq $0xffff000000000002, %r11 <--- patch point address
0x0a callq   *%r11
0x0d nop
0x0e nop                               <--- end of reserved 15-bytes
0x0f addq    $0x3, %rax
0x10 movl    %rax, 8(%rsp)

スタックマップの位置は記録されないことに注意してください。パッチされたコードシーケンスが特定の呼び出し規約レジスタに固定された引数を必要としない場合、anyregcc規約を使用できます。

%val = call anyregcc @llvm.experimental.patchpoint(i64 78, i32 15,
                                                   ptr %target, i32 1,
                                                   ptr %ptr)

スタックマップは、%ptr引数と戻り値の位置を示すようになりました。

Stack Map: ID=78, Loc0=%r9 Loc1=%r8

パッチコードシーケンスは、%r8に割り当てられた引数を使用し、%r9に割り当てられた値を返すことができるようになりました。

0x00 movslq 4(%r8) %r9              <--- patched code at patch point address
0x03 nop
...
0x0e nop                            <--- end of reserved 15-bytes
0x0f addq    $0x3, %r9
0x10 movl    %r9, 8(%rsp)

スタックマップ形式

LLVMモジュール内のスタックマップまたはパッチポイントイントリンシックスの存在は、コード生成にスタックマップセクションの作成を強制します。このセクションの形式は次のとおりです。

Header {
  uint8  : Stack Map Version (current version is 3)
  uint8  : Reserved (expected to be 0)
  uint16 : Reserved (expected to be 0)
}
uint32 : NumFunctions
uint32 : NumConstants
uint32 : NumRecords
StkSizeRecord[NumFunctions] {
  uint64 : Function Address
  uint64 : Stack Size (or UINT64_MAX if not statically known)
  uint64 : Record Count
}
Constants[NumConstants] {
  uint64 : LargeConstant
}
StkMapRecord[NumRecords] {
  uint64 : PatchPoint ID
  uint32 : Instruction Offset
  uint16 : Reserved (record flags)
  uint16 : NumLocations
  Location[NumLocations] {
    uint8  : Register | Direct | Indirect | Constant | ConstantIndex
    uint8  : Reserved (expected to be 0)
    uint16 : Location Size
    uint16 : Dwarf RegNum
    uint16 : Reserved (expected to be 0)
    int32  : Offset or SmallConstant
  }
  uint32 : Padding (only if required to align to 8 byte)
  uint16 : Padding
  uint16 : NumLiveOuts
  LiveOuts[NumLiveOuts]
    uint16 : Dwarf RegNum
    uint8  : Reserved
    uint8  : Size in Bytes
  }
  uint32 : Padding (only if required to align to 8 byte)
}

各位置の最初のバイトは、RegNumフィールドとOffsetフィールドをどのように解釈するかを示す型をエンコードします。

エンコード

説明

0x1

レジスタ

Reg

レジスタ内の値

0x2

直接

Reg + Offset

フレームインデックス値

0x3

間接

[Reg + Offset]

スタックにスピルされた値

0x4

定数

Offset

小さな定数

0x5

ConstIndex

Constants[Offset]

大きな定数

一般的な場合、値はレジスタで使用可能であり、Offsetフィールドは0になります。スタックにスピルされた値は、Indirect位置としてエンコードされます。ランタイムは、通常[BP + Offset]という形式で、スタックアドレスからそれらの値を読み込む必要があります。alloca値がスタックマップイントリンシックスに直接渡される場合、LLVMはレジスタまたはスタックスロットの割り当てを回避するための最適化として、フレームインデックスをスタックマップに折りたたむことができます。これらのフレームインデックスは、BP + Offsetという形式のDirect位置としてエンコードされます。LLVMは、Constant位置のOffset内、またはConstantIndex位置によって参照される定数プール内に、定数を直接出力することによっても最適化できます。

各呼び出しサイトでは、「liveout」レジスタリストも記録されます。これらは、スタックマップ全体で有効なレジスタであり、ランタイムによって保存する必要があります。これは、パッチポイントイントリンシックスが、デフォルトでほとんどのレジスタを呼び出し側保存レジスタとして保持する呼び出し規約で使用される場合の重要な最適化です。

liveoutレジスタリストの各エントリには、DWARFレジスタ番号とバイト単位のサイズが含まれています。スタックマップ形式は、特定のサブレジスタ情報を意図的に省略しています。代わりに、ランタイムはこれを保守的に解釈する必要があります。たとえば、スタックマップが%raxで1バイトを報告する場合、値は%alまたは%ahのいずれかにある可能性があります。実際には問題ありません。ランタイムは単に%raxを保存するからです。ただし、スタックマップが%ymm0で16バイトを報告する場合、ランタイムは%xmm0のみを保存することで安全に最適化できます。

スタックマップ形式は、LLVM SVNリビジョンとランタイム間の契約です。現在、これは実験的なものであり、短期的に変更される可能性がありますが、ランタイムを更新する必要性を最小限に抑えることが重要です。その結果、スタックマップの設計は、シンプルさと拡張性を重視しています。表現のコンパクトさは二次的なものですが、ランタイムはモジュールをコンパイルした直後にデータを解析し、独自の形式で情報をエンコードすると予想されます。ランタイムはセクションの割り当てを制御するため、複数のモジュールで同じスタックマップスペースを再利用できます。

スタックマップのサポートは、現在64ビットプラットフォームでのみ実装されています。ただし、32ビット実装では、わずかな量の無駄なスペースで同じ形式を使用できます。

スタックマップセクション

JITコンパイラは、LLVM C API LLVMCreateSimpleMCJITMemoryManager()を介して独自のメモリマネージャを提供することで、このセクションに簡単にアクセスできます。メモリマネージャを作成する際、JITはコールバックLLVMMemoryManagerAllocateDataSectionCallback()を提供します。LLVMがこのセクションを作成すると、コールバックを呼び出し、セクション名を渡します。JITはこの時点でセクションのメモリ内アドレスを記録し、後で解析してスタックマップデータを取得できます。

MachO(例:Darwin)の場合、スタックマップセクション名は「__llvm_stackmaps」です。セグメント名は「__LLVM_STACKMAPS」です。

ELF(例:Linux)の場合、スタックマップセクション名は「.llvm_stackmaps」です。セグメント名は「__LLVM_STACKMAPS」です。

スタックマップの使用

このドキュメントで説明されているスタックマップのサポートは、コード内の特定の位置にある値の位置を正確に決定するために使用できます。LLVMは、これらの値と上位レベルのエンティティとの間のマッピングを維持しません。ランタイムは、LLVMが保持するID、オフセット、位置、レコード、関数の順序のみが与えられた場合、スタックマップレコードを解釈できる必要があります。

これは、すべての命令で名前付き変数の位置を追跡するための最善の努力であるデバッグ情報の目標とはかなり異なることに注意してください。

この設計の重要な動機は、実行がスタックマップに関連付けられた命令アドレスに到達したときに、ランタイムがスタックフレームを掌握できるようにすることです。ランタイムは、スタックマップによって提供される情報を使用してスタックフレームを再構築し、プログラムの実行を再開できる必要があります。たとえば、実行はインタプリタまたは同じ関数の再コンパイルされたバージョンで再開される場合があります。

この使用法はLLVMの最適化を制限します。明らかに、LLVMはスタックマップ全体にストアを移動できません。ただし、ロードも保守的に処理する必要があります。ロードが例外をトリガーする可能性がある場合、それをスタックマップの上にホイスティングすることは無効になる可能性があります。たとえば、ランタイムは、型システムの現在の状態を考慮すると、型チェックなしでロードを実行することが安全であると判断する場合があります。ロードの関数のいくつかのアクティベーションがスタック上に存在している間に型システムが変更された場合、ロードは安全ではなくなります。ランタイムは、現在の呼び出しサイトとロードの間にあるスタックマップの位置をすべてパッチすることで(通常、ランタイムはすべてのスタックマップの位置をパッチして関数を無効にします)、そのロードの以降の実行を防ぐことができます。コンパイラがロードをスタックマップの上にホイスティングした場合、ランタイムが制御を取り戻す前にプログラムがクラッシュする可能性があります。

これらのセマンティクスを強制するために、stackmapおよびpatchpointイントリンシックスは、すべてのメモリを読み書きする可能性があると見なされます。これは、一部のクライアントが望むよりも最適化を制限する可能性があります。この制限は、呼び出しサイトを「readonly」としてマークすることで回避できます。将来的には、エイリアシングを表すためにメタデータをイントリンシックス呼び出しに追加することもできるようになる可能性があり、これにより、スタックマップの上にある特定のロードを最適化することが可能になります。

直接スタックマップエントリ

スタックマップセクションに示されているように、直接スタックマップ位置はフレームインデックスのアドレスを記録します。このアドレス自体が、ランタイムが要求した値です。これは、要求された値を読み込む必要があるスタック位置を参照する間接位置とは異なります。直接位置は、allocaのアドレスを伝えることができますが、間接位置はレジスタスピルを処理します。

例:

entry:
  %a = alloca i64...
  llvm.experimental.stackmap(i64 <ID>, i32 <shadowBytes>, ptr %a)

ランタイムは、コンパイル直後、またはそれ以降のいつでも、このallocaのスタック上の相対位置を決定できます。これは、レジスタと間接の位置とは異なります。ランタイムは、スタックマップの命令アドレスに実行が到達した場合にのみ、これらの位置にある値を読み取ることができるためです。

この機能には、イントリンシックスによって直接消費される場合、LLVMがエントリブロックallocaを特別に扱う必要があります。(これは、llvm.gcrootイントリンシックスによって課せられるのと同じ要件です。)LLVM変換は、allocaを介入する値に置き換えることはできません。これは、ランタイムがスタックマップの位置が直接位置タイプであることを確認するだけで検証できます。

サポートされているアーキテクチャ

StackMap生成とその関連インリン関数に対するサポートには、各バックエンドでいくつかのコードが必要です。現在、LLVMのバックエンドの一部のみがサポートされています。現在サポートされているアーキテクチャは、X86_64、PowerPC、AArch64、およびSystemZです。