フロントエンド作成者向けのパフォーマンスのヒント

概要

このドキュメントの対象読者は、LLVM IRをターゲットとする言語フロントエンドの開発者です。このドキュメントは、最適化に適したIRを生成する方法に関するヒント集のホームです。

IRのベストプラクティス

どのオプティマイザーにも言えることですが、LLVMには長所と短所があります。場合によっては、ソースIRの小さな変更が、生成されたコードに大きな影響を与えることがあります。

以下のリストの特定の項目に加えて、LLVMで最も成熟したフロントエンドはClangであることに注意してください。その結果、IRがClangが生成する可能性のあるものから離れるほど、効果的に最適化される可能性が低くなります。モデル化しようとしているセマンティクスを持つ簡単なCプログラムを記述し、ClangのIRGenがどのようなIRを生成するかについてどのような決定を下すかを確認すると役立つ場合があります。ClangのCodeGenディレクトリを調べるのも良いアイデアの源になります。ClangとLLVMは明示的にバージョンロックされているため、使用しているLLVMライブラリと同じgitリビジョンまたはリリースからビルドされたClangを使用していることを確認する必要があることに注意してください。常に、特に新しいプロジェクトの立ち上げ中は、ツリーの先端の開発を追跡することを強くお勧めします。

基本

  1. モジュールにデータレイアウトの仕様とターゲットトリプルが含まれていることを確認してください。これらの部分がないと、ターゲット固有の最適化は有効になりません。これは、生成されたコードの品質に大きな影響を与える可能性があります。

  2. 出力された各関数またはグローバルに対して、可能な限りプライベートなリンケージタイプ(プライベート、内部、またはlinkonce_odrが望ましい)を使用してください。そうすることで、LLVMのプロシージャ間最適化がより効果的になります。

  3. 入次数が高い基本ブロック(たとえば、数十または数百の前任者を持つ基本ブロック)は避けてください。他の問題の中でも、レジスタアロケーターは、そのような構造に直面するとパフォーマンスが低下することが知られています。このガイダンスの唯一の例外は、入次数が高い統合されたリターンブロックは問題ないということです。

allocas の使用

alloca 命令は、関数スコープのスタックスロットを表すために使用できますが、動的なフレーム拡張を表すこともできます。関数スコープの変数または場所を表す場合は、エントリブロックの先頭にalloca命令を配置することをお勧めします。特に、call命令の前に配置してください。call命令はインライン化され、複数の基本ブロックに置き換えられる場合があります。その結果、後続のalloca命令は、その後、エントリ基本ブロックにはなくなります。

SROA(集約体のスカラー置換)およびMem2Regパスは、エントリ基本ブロックにあるalloca命令の排除のみを試みます。SSAは、オプティマイザーの多くが期待する標準形式であることを考えると、allocaがMem2RegまたはSROAで排除できない場合、オプティマイザーは効果が低下する可能性があります。

集約型の値の作成を避ける

集約型(つまり、構造体と配列)の値の作成を避けてください。特に、それらをロードおよびストアしたり、insertvalue命令とextractvalue命令で操作したりすることは避けてください。代わりに、集約体の個々のフィールドのみをロードおよびストアしてください。

この規則にはいくつかの例外があります。

  • グローバル変数イニシャライザーで集約型の値を使用しても問題ありません。

  • レジスタ内の複数の値の戻りを表すためにこれが行われる場合は、構造体を返しても問題ありません。

  • with.overflowファミリの組み込み関数など、LLVM組み込み関数によって返された構造体を操作しても問題ありません。

  • 値を作成せずに集約を使用しても問題ありません。たとえば、getelementptr命令やsretなどの属性でよく使用されます。

バイトサイズ以外の型のロードとストアを避ける

i1のようなバイトサイズ以外の型のロードまたはストアを避けてください。代わりに、それらを次のバイトサイズ型に適切に拡張します。

たとえば、ブール値を操作する場合は、i1をゼロ拡張してi8にして保存し、i8をロードしてi1に切り詰めてロードします。

バイトサイズ以外の型でロード/ストアを使用する場合は、常にそれらの型を使用するようにしてください。たとえば、最初にi8を保存してから、i1をロードしないでください。

GEP インデックスをマシンレジスタ幅に zext する

内部的には、LLVMはGEPインデックスの幅をマシンレジスタ幅に昇格させることがよくあります。その際、安全のために符号拡張(sext)演算を使用することをデフォルトとします。ソース言語がインデックスの範囲に関する情報を提供している場合は、zext命令を使用してインデックスをマシンレジスタ幅に手動で拡張することをお勧めします。

アライメントを指定するタイミング

アライメントを指定しない場合でも、LLVMは常に正しいコードを生成しますが、非効率的なコードを生成する可能性があります。たとえば、MIPS(または古いARM ISA)をターゲットにしている場合、ハードウェアはアライメントされていないロードとストアを処理しないため、自然なアライメントよりも低いアライメントでロードまたはストアを実行すると、トラップしてエミュレートするパスに入ります。これを避けるために、LLVMは、ロード/ストアがIRで十分に高いアライメントを持っていないすべての場合について、ロード、シフト、マスク(またはMIPSでのロードライト+ロードレフト)の低速なシーケンスを出力します。

アライメントは、allocasとグローバルでのアライメントを保証するために使用されますが、ほとんどの場合、これは不要です(ほとんどのターゲットには、十分に高いデフォルトのアライメントがあり、問題ありません)。また、これは「このロード/ストアにはこのアライメントがあるか、未定義の動作である」というバックエンドへの契約を提供するために使用されます。これは、バックエンドがそのアライメントに依存する命令を自由に出力できる(また、中間レベルのオプティマイザーはそのアライメントを必要とする変換を自由に行うことができる)ことを意味します。x86では、ほとんどすべての命令がアライメントに依存しないため、大きな違いはありません。MIPSでは、大きな違いが生じる可能性があります。

ロードとストアがアトミックである場合、バックエンドは、アライメントされていないアクセスをネイティブにアライメントされたアクセスシーケンスに下げることができないことに注意してください。その結果、アトミックロードとストアにはアライメントが必須です。

考慮すべきその他の事項

  1. ptrtoint/inttoptr は控えめに使用してください(ポインタエイリアス分析を妨害します)。GEPを優先してください。

  2. 定数アドレスのinttoptrよりもグローバルを優先してください。これにより、参照可能性情報が得られます。MCJITでは、getSymbolAddressを使用して実際のアドレスを提供します。

  3. 順序付けられたメモリ操作とアトミックメモリ操作に注意してください。それらは最適化が難しく、現在のオプティマイザーでは十分に最適化されていない可能性があります。ソース言語によっては、代わりにフェンスを使用することを検討できます。

  4. 例外(アンワインド)をスローすることがわかっている関数を呼び出す場合は、到達不能な命令を含む通常の宛先を持つinvokeを使用してください。この形式は、呼び出しが異常に返されることをオプティマイザーに伝えます。通常は返されず、現在の関数でアンワインドコードを必要としないinvokeの場合は、必要に応じてnoreturn call命令を使用できます。オプティマイザーは、到達不能なアンワインド宛先を持つinvokeをcall命令に変換するため、これは一般に必要ありません。

  5. 動的なプロファイリング情報が利用できない場合でも、静的に既知のコールドパスを示すためにプロファイルメタデータを使用してください。これにより、コードの配置、したがってタイトなループのパフォーマンスに大きな違いが生じる可能性があります。

  6. ループのコードを生成するときは、ループのヘッダーブロックを必要以上に早く終了させないようにしてください。ループヘッダーブロックのターミネータがループ終了条件付き分岐である場合、ヘッダーにないロードの場合、LICMの有効性が制限されます。(これは、LLVMがそのようなロードを投機的に実行しても安全かどうかを認識していない可能性があるため、ループ終了条件が取られていないことを証明できない限り、それ以外の場合はループ不変のロードを持ち上げることができないためです。)場合によっては、ループを終了するまれに実行されるパスに沿って使用されない場合でも、このような命令をヘッダーに出力すると有利になる可能性があります。ループヘッダーを終了する条件自体が不変である場合、またはループインデックス変数を調べることで簡単に解除できる場合は、このガイダンスは特に適用されません。

  7. ホットループでは、予測可能性の高いターミネータで終わる小さな基本ブロックの命令を、後続のブロックに複製することを検討してください。ホットな後続ブロックに、複製された命令でベクトル化できる命令が含まれている場合、これによりスループットが大幅に向上する可能性があります。ただし、これは常に有益とは限らず、コードサイズが大幅に増加する可能性があることに注意してください。

  8. 値を定数と照合する場合、一貫した比較型を使用してチェックを発行してください。GVNパスは、比較の型が反転していても冗長な等価性を最適化しますが、GVNはパイプラインの後半でしか実行されません。その結果、他の重要な最適化を実行する機会を逃す可能性があります。

  9. ソース言語の仕様で特定のコードシーケンスを発行することが要求されていない限り、算術イントリンシックの使用は避けてください。オプティマイザは一般的な制御フローと算術演算に関する推論が非常に得意ですが、さまざまなイントリンシックに関する推論はそれほど得意ではありません。コード生成の目的で有益な場合、オプティマイザは最適化パイプラインの後半でイントリンシック自体を形成する可能性が高くなります。言語フロントエンドでこれらを直接発行することは非常にまれにしか有益ではありません。この項目には、オーバーフローイントリンシックの使用が明示的に含まれます。

  10. 与えられた事実を表現する他の方法がなく、その事実が最適化の目的で重要であると確立するまで、assumeイントリンシックの使用は避けてください。Assumeは優れたプロトタイピングメカニズムですが、コンパイル時間と最適化効果の両方に悪影響を与える可能性があります。前者は十分な努力で修正できますが、後者は設計目的にかなり根本的なものです。非ターミネータの到達不能な命令を作成している場合、またはfalse値を渡している場合は、store i1 true, ptr poison, align 1の標準形式を使用してください。

言語固有のプロパティの記述

ソース言語をLLVMに変換する場合、ソース言語で利用可能であり、LLVM IRによってネイティブに提供されていない概念と保証を表現する方法を見つけると、LLVMがコードを最適化する能力が大幅に向上します。例として、C/C++がすべての加算を「符号付きラップなし(nsw)」としてマークできることは、ループ誘導変数に関する推論を行い、より最適なループコードを生成する上で、オプティマイザを大いに助けます。

LLVM LangRefには、追加のセマンティック情報でIRに注釈を付けるためのいくつかのメカニズムが含まれています。このドキュメントをよく理解することを強くお勧めします。以下のリストは、特に関心のあるいくつかの項目を強調することを目的としていますが、決して網羅的なものではありません。

制限付き操作セマンティクス

  1. 必要に応じてnsw/nuwフラグを追加します。オーバーフローに関する推論は一般的にオプティマイザにとって困難なため、フロントエンドからこれらの事実を提供することは非常に大きな影響を与える可能性があります。

  2. 法律で許可されている場合は、浮動小数点演算にfast-mathフラグを使用します。厳密なIEEE浮動小数点セマンティクスが必要ない場合は、実行できる追加の最適化が多数あります。これは、浮動小数点演算を多用する計算に非常に大きな影響を与える可能性があります。

エイリアスプロパティの記述

  1. 必要に応じて、関数引数と戻り値にnoalias/align/dereferenceable/nonnullを追加します

  2. ポインタエイリアスに関するメタデータ、特にtbaaメタデータを使用して、それ以外の場合は推論できないポインタエイリアスの事実を伝えます

  3. gepでinboundsを使用します。これは、一部のエイリアスクエリのあいまいさを解消するのに役立ちます。

未定義の値

  1. 可能な限り、undef値の代わりにpoison値を使用します。

  2. 可能な限り、noundef属性で関数パラメータをタグ付けします。

メモリ効果のモデリング

  1. 既知の場合は、関数をreadnone/readonly/argmemonlyまたはnoreturn/nounwindとしてマークします。オプティマイザはこれらのフラグを推論しようとしますが、常にできるとは限りません。手動による注釈は、オプティマイザが分析できない外部関数にとって特に重要です。

  2. 可能な限り、lifetime.start/lifetime.endおよびinvariant.start/invariant.endイントリンシックを使用します。一般的な有益な使用法は、スタックのようなデータ構造(したがって、デッドストア削除が可能になります)と、allocasのライフタイムを記述するため(したがって、より小さなスタックサイズが可能になります)です。

  3. !invariant.loadとTBAAの定数フラグを使用して、不変の場所をマークします

パスの順序

新しい言語フロントエンドプロジェクトが犯す最も一般的な間違いの1つは、既存の-O2または-O3パスパイプラインをそのまま使用することです。これらのパスパイプラインは、あらゆる言語の最適化コンパイラの出発点としては優れていますが、CおよびC++用に注意深く調整されており、ターゲット言語ではありません。最適なパフォーマンスを実現するには、ほぼ確実にカスタムパス順序を使用する必要があります。いくつかの具体的な提案

  1. 実行されることがまれな多数のガード条件(例:nullチェック、型チェック、範囲チェック)がある言語の場合は、パス順序にLoopUnswitchとLICMの実行を1回または2回追加することを検討してください。CおよびC++アプリケーション用に調整された標準のパス順序では、ループからすべての解除可能なチェックを削除するのに十分ではない可能性があります。

  2. 言語で範囲チェックを使用する場合は、IRCEパスの使用を検討してください。これは現在、標準のパス順序の一部ではありません。

  3. 実行するのに役立つ健全性チェックは、最適化されたIRを-O2パイプラインに再度通すことです。結果のIRに著しい改善が見られる場合は、パス順序を調整する必要がある可能性があります。

それでも探しているものが見つからない

上記で探していたものが見つからなかった場合は、必要な最適化ヒントを提供するメタデータの一部を提案することを検討してください。このような拡張機能は比較的一般的であり、コミュニティによく受け入れられます。提案を上流に貢献させたい場合は、提案が他の人にもメリットがあるように十分に一般的であることを確認する必要があります。

Discourseで直面している問題を説明し、アドバイスを求めることも検討する必要があります。誰かが以前にあなたの問題に遭遇し、良いアドバイスをくれる可能性が十分にあります。複数の関心のある当事者がいる場合、メタデータ拡張機能がコミュニティ全体によく受け入れられる可能性も高まります。

このドキュメントへの追加

ここで取り上げる価値があるとあなたが感じるケースに出くわした場合は、llvm-commitsにパッチを送信してレビューを受けてください。

これらの項目について質問がある場合は、Discourseで質問してください。質問に関連性の高いコンテキストを多く提供できるほど、回答される可能性が高くなります。