RyuJIT: 異なるプラットフォームへの移植
2025-04-13 15:30:25
原文: RyuJIT: Porting to different platforms
まず始めに、RyuJIT概要を読んでJITアーキテクチャを理解しましょう。
プラットフォームとは?
- ターゲット命令セット
- ターゲットポインタサイズ
- ターゲットOS
- ターゲット呼び出し規約とABI(アプリケーションバイナリインターフェース)
- ランタイムデータ構造(本文書では詳細に触れていません)
- GCエンコーディング
- Windows x86(JIT32_GCENCODERを使用)を除き、すべてのターゲットは同じGCエンコーディング方式とAPIを使用します
- デバッグ情報(ほとんどのターゲットで共通)
- 例外処理(EH)情報(本文書では詳細に触れていません)
CLRの大きな利点は、VM(仮想マシン)が(非ABIの)OS間の違いをほぼ完全に隠蔽してくれることです。
全体像
新しいプラットフォームに対応するには、以下のコンポーネントを更新するか、ターゲット固有のバージョンを新たに作成する必要があります。
- 基本部分
- target.h
- 命令セットアーキテクチャ関連:
- registerXXX.h - アーキテクチャで使用される全レジスタとそのエイリアスの定義
- emitXXX.h - 命令発行メソッド(例:「単一整数引数を取る命令の発行」)とアーキテクチャ固有の内部ヘルパー関数の署名を定義
- emitXXX.cpp - emitXXX.hの実装
- emitfmtXXX.h - 命令フォーマットの検証ルールを任意で定義(例:RISC-Vでは定義されていない)
- instrsXXX.h - アーキテクチャごとのアセンブリ命令を定義
- targetXXX.h - 「callee保存の整数レジスタ用ビットマスク」や「浮動小数点レジスタのサイズ(バイト単位)」など、アーキテクチャの制約条件を定義
- targetXXX.cpp - 該当アーキテクチャのABI分類子の実装
- lowerXXX.cpp - アーキテクチャ固有のLoweringの実装
- lsraXXX.cpp - GenTree Nodesに基づくレジスタ要件設定の実装
- codegenXXX.cpp - アーキテクチャのメインコード生成処理の実装(GenTree Nodesを基にアーキテクチャ固有の命令を生成)
- hwintrinsic*XXX.*およびsimdashwintrinsic*XXX.h - ベクトル命令などのハードウェアイントリンシック機能の定義と実装
- unwindXXX.cpp - アンワインドAPIとデバッグ用アンワインド情報ダンプの実装
- 呼び出し規約とABI: コード全体に分散
- 32ビットと64ビットの区別
- こちらもコード全体に分散。ポインタサイズ固有のデータは一部target.hに集約されていますが、すべてではありません。
Add以外のすべての関数をコンパイルします。Addは最初に新しいaltjitによってコンパイルされ、失敗した場合は「ベース」JITにフォールバックします。このようにして、「ベース」JITがほとんどの関数を処理するため、非常に限られたJIT機能のみが動作すれば良いのです。
- 基本的な命令エンコーディングを実装する。
CodeGen::genArm64EmitterUnitTests()のようなメソッドを使用してテストする。 - コンパイラを構築し、加算のような非常に単純な操作のコードを生成するための最小限の実装を行う。
- CodeGenBringUpTests(src\tests\JIT\CodeGenBringUpTests)に集中し、単純なものから始める。
- これらは
XXX.csというテストに対して、XXXという名前の単一の興味深い関数をコンパイルするように設計されています(つまり、ソースファイルの名前は興味深い関数の名前と同じです。これはこれらのテストを呼び出すスクリプトを非常に単純にするために行われました)。DOTNET_AltJit=XXXを設定して、新しいJITがその1つの関数のみをコンパイルしようとするようにします。 - マージされたテストグループは、各個別テストからエントリポイントを削除し、単一のプロセスですべてのテストを呼び出す単一のラッパーを作成することで、これらのテストの単純さを妨げます。古い動作を復元するには、環境変数
BuildAsStandaloneをtrueに設定してテストをビルドします。
- これらは
DOTNET_JitDisasmを使用して、コードが実行されなくても関数の生成されたコードを確認する。
テストカバレッジの拡大
- より多くのテストを成功させる:
JITディレクトリのテストをより広く実行- すべてのPri-0「インナーループ」テストを実行
- すべてのPri-1「アウターループ」テストを実行
- JITが生成したアサートをテスト全体で収集し、頻度順に修正するとよいでしょう。つまり、最も頻繁に発生するアサートから修正していきます。
- アサートの数と、アサートのある/なしのテスト数を追跡して進捗状況を把握します。
最適化フェーズの有効化
DOTNET_JITMinOpts=1の指定ありとなしの両方でテストを実行。- 後の段階まで
DOTNET_TieredCompilation=0を設定する(またはプラットフォームに対して完全に無効にする)のが賢明です。
品質向上
- 基本モードでテストに合格したら、
JitStressとJitStressRegsストレスモードでの実行を開始。 GCStressを有効化。これにはVMの作業も必要になります。DOTNET_GCStress=4の品質向上に取り組みます。crossgen/ngenが有効になったら、DOTNET_GCStress=8とDOTNET_GCStress=Cでもテストを行います。
パフォーマンスの改善
- スループット(コンパイル時間)と生成コード品質(CQ)の両方を測定・改善するための戦略を策定します。
プラットフォーム間の機能同等性確保
- 意図的に無効にしていた機能や実装が遅れていた機能を実装します。
- SIMD(
Vector<T>)とハードウェアイントリンシックサポートを実装します。
フロントエンドの変更点
- 呼び出し規約
- 構造体の引数と戻り値が最も複雑な差異
- インポーターとモーフはこれらを深く理解しています
- 例:
fgMorphArgs()、fgFixupStructReturn()、fgMorphCall()、fgPromoteStructs()および各種構造体代入変形メソッド
- 例:
- インポーターとモーフはこれらを深く理解しています
- ARMにおけるHFA(同種浮動小数点集合)
- 構造体の引数と戻り値が最も複雑な差異
- テールコールはターゲット依存ですが、本来はそれほど依存すべきではないでしょう
- イントリンシック:各プラットフォームで異なるメソッドがイントリンシックとして認識される(例:
Sinはx86のみ、Roundはamd64以外のすべてで対応) - 乗算、剰余、除算などに対するターゲット固有の変形処理
バックエンドの変更点
- Lowering:制御フローとレジスタ要件を完全に明示化
- コード生成:ノード上のレジスタ割り当てに基づいてコード(InstrDescs)を生成しながら、レイアウト順にブロックを処理
- その後、プロログとエピログ、およびGC、例外処理、スコープテーブルを生成
- ABI関連の変更:
- 呼び出し規約におけるレジスタ要件
- 呼び出しと戻りのLowering処理
- プロログとエピログのコードシーケンス
- スタックフレームの割り当てとレイアウト
- 呼び出し規約におけるレジスタ要件
ターゲットISA「設定」
- 条件付きコンパイル(jit.hで設定、入力される定義に基づく、例:#ifdef X86)
_TARGET_64_BIT_ (32ビットターゲットは単に! _TARGET_64BIT_)
_TARGET_XARCH_, _TARGET_ARMARCH_
_TARGET_AMD64_, _TARGET_X86_, _TARGET_ARM64_, _TARGET_ARM_
- Target.h
- InstrsXXX.h
命令エンコーディング
insGroupとinstrDescデータ構造がエンコーディングに使用されますinstrDescはオペコードビットで初期化され、即値とレジスタ番号用のフィールドを持ちますinstrDescはinsGroupグループに集約されます- ラベルはグループの先頭にのみ配置可能です
- エミッタは以下の目的で呼び出されます:
- コード生成中に新しい命令(
instrDesc)を作成 - コード生成完了後に
instrDescからビットを出力 - Gcinfo(ライブGC変数とセーフポイント)を更新
- コード生成中に新しい命令(
エンコーディングの追加
- 命令エンコーディングはinstrsXXX.hに記述されています。これは各命令のオペコードビットを定義します
- 各命令セットのエンコーディング構造はターゲットによって異なります
- 「命令」とはオペコードの表現にすぎません
instrDescのインスタンスは出力される命令を表します- 命令の各「種類」に対して、emit方法を実装する必要があります。これらは基本パターンに従いますが、ターゲットによっては固有のものもあります:
emitter::emitInsMov(instruction ins, emitAttr attr, GenTree* node)
emitter::emitIns_R_I(instruction ins, emitAttr attr, regNumber reg, ssize_t val)
emitter::emitInsTernary(instruction ins, emitAttr attr, GenTree* dst, GenTree* src1, GenTree* src2) (現在Arm64のみ実装)
Lowering処理
- Lowering処理は、レジスタアロケータが使用するすべてのレジスタ要件を明示化します
- 使用カウント、定義カウント、「内部」レジスタカウント、特別なレジスタ要件など
- すべての計算を明示的にするため、コード生成の半分の作業を担います
- ただし、必ずしもlowered状態のツリーノードからターゲット命令への1対1マッピングではありません
- 最初のパスではツリーを走査して命令を変換します。この一部はターゲット非依存ですが、例外もあります:
- 関数呼び出しと引数
- switch文のlowering
- LEA(ロード有効アドレス)変換
- 2番目のパスでは、実行順序でノードを走査します
- レジスタ要件を設定
- 場合によっては(すでに走査済みの)子ノードのレジスタ要件を変更することもあります
- LSRAのためのブロック順序とノード位置を設定
LinearScan::startBlockSequence()とLinearScan::moveToNextBlock()を使用
- レジスタ要件を設定
レジスタ割り当て
- レジスタ割り当ては基本的にターゲット非依存です
- Loweringの第2フェーズがほぼすべてのターゲット依存作業を担当します
- レジスタ候補はフロントエンドで決定されます
- ローカル変数やテンポラリ、あるいはそれらのフィールド
- アドレスが取得されず、その他いくつかの制約を満たすもの
lvaSortByRefCount()でソートされ、lvIsRegCandidate()によって選別されます
アドレッシングモード
- アドレッシングモードを検出・捕捉するコードは、特に抽象化が不十分な部分です
- CodeGenCommon.cppの
genCreateAddrMode()は、アドレッシングモードを探してツリーを走査し、構成要素(ベース、インデックス、スケール、オフセット)を「出力パラメータ」として取得します- このメソッドはコードを生成せず、
gtSetEvalOrderとLoweringによってのみ使用されます
- このメソッドはコードを生成せず、
コード生成
- コード生成メソッドの構造は、基本的にすべてのアーキテクチャで同じパターンです
- ほとんどのコード生成メソッドは「gen」という接頭辞を持ちます
- 理論上、CodeGenCommon.cppには「ほぼ」すべてのターゲットに共通するコードが含まれています(この分割は完全ではありません)
- メソッドのプロログ、エピログなど
genCodeForBBList()- 実行順序でツリーを走査し、「含まれていない」各ノードを処理する
genCodeForTreeNode()を呼び出します - ブロックの制御フロー(分岐、例外処理など)のコードを生成します
- 実行順序でツリーを走査し、「含まれていない」各ノードを処理する