すべてのプログラマーがコンパイラーの最適化について知っておくべきこと

高レベルのプログラミング言語には、関数、条件付きステートメント、ループなど、多くの抽象的なプログラミング構成要素が含まれています。これらは驚くほど生産的です。 ただし、高水準言語でコードを記述することの欠点の1つは、プログラムの速度が大幅に低下する可能性があることです。 したがって、コンパイラーは自動的にコードを最適化し、作業の速度を上げようとします。 今日、最適化ロジックは非常に複雑になっています。コンパイラはループ、条件式、再帰関数を変換します。 コードのブロック全体を削除します。 プロセッサアーキテクチャのコードを最適化して、本当に高速でコンパクトにします。 そして、これは非常にクールです。なぜなら、理解や維持が難しい手動の最適化を行うよりも、読み取り可能なコードの作成に集中する方が良いからです。 さらに、手動の最適化により、コンパイラが追加のより効率的な自動最適化を実行できなくなる場合があります。 手作業で最適化を記述するのではなく、並行性やライブラリ機能の使用など、アーキテクチャ設計と効率的なアルゴリズムに焦点を合わせた方が良いでしょう。



この記事は、Visual C ++コンパイラの最適化に関するものです。 コンパイラーがそれらを正しく適用するために適用しなければならない最も重要な最適化手法とソリューションについて説明します。 私の目標は、コードを手動で最適化する方法を説明することではなく、自分でコードを最適化するためにコンパイラを信頼する必要がある理由を示すことです。 この記事は、Visual C ++コンパイラが行う最適化の完全なセットの説明ではなく、知っておくべき本当に重要なもののみを示します。 コンパイラが実行できない他の重要な最適化があります。 たとえば、非効率的なアルゴリズムを効果的なアルゴリズムに置き換えたり、データ構造のアライメントを変更したりします。 この記事では、このような最適化については説明しません。



コンパイラーの最適化の定義

最適化とは、コードの速度とサイズが最も重要な特性を改善するために、コードを元のコードと機能的に同等な別のコードに変換するプロセスです。 その他の特性には、コード実行ごとの消費エネルギー量とコンパイル時間(結果のコードがJITを使用する場合のJITコンパイル時間)が含まれます。



コンパイラは常に改善されており、そのアプローチは改善されています。 それらが完全ではないという事実にもかかわらず、多くの場合、最も正しいアプローチは、手動で行うよりも低レベルの最適化をコンパイラに任せることです。



コンパイラーが最適化をより効率的に実行するのに役立つ4つの方法があります。
  1. 保守しやすい読み取り可能なコードを作成します。 Visual C ++のさまざまなOOP機能をパフォーマンスの最悪の敵と考えないでください。 Visual C ++の最新バージョンでは、OOPオーバーヘッドを最小限に抑えることができ、場合によってはそれらを完全に取り除くこともできます。
  2. コンパイラディレクティブを使用します。 たとえば、デフォルトよりも速い関数呼び出し規約を使用するようコンパイラーに指示します。
  3. コンパイラに組み込まれている関数を使用します。 これらは、コンパイラーによって実装が自動的に提供される特別な関数です。 コンパイラは、指定されたソフトウェアアーキテクチャ上でコードができるだけ速く実行されるように、マシン命令のシーケンスを効率的に配置する方法について深い知識を持っていることに注意してください。 現在、Microsoft .NET Frameworkは組み込み関数をサポートしていないため、マネージ言語はそれらを使用できません。 ただし、Visual C ++はそのような機能を広範囲にサポートしています。 ただし、コードのパフォーマンスは向上しますが、読みやすさと移植性に悪影響を与えることを忘れないでください。
  4. プロファイルに基づく最適化(PGO)を使用します。 このテクノロジーのおかげで、コンパイラーは、操作中のコードの動作についてより多くのことを認識し、それに応じて最適化します。




この記事の目的は、非効率的であるが読み取り可能なコードに適用される最適化を実行するコンパイラーを信頼できる理由を示すことです(最初の方法)。 また、プロファイルガイドによる最適化の概要を説明し、ソースコードの一部を改善できるコンパイラディレクティブについても説明します。



コンパイラーの最適化には、折りたたみ定数などの単純な変換から、コマンドのスケジューリングなどの複雑な変換まで、さまざまな手法があります。 この記事では、コードのパフォーマンスを大幅に向上させる(2桁のパーセント)ことができ、関数(関数のインライン化)、COMDAT最適化、およびループ最適化を置き換えることでサイズを削減できる最も重要な最適化に限定します。 次のセクションで最初の2つのアプローチについて説明し、Visual C ++で最適化のパフォーマンスを制御する方法を示します。 結論として、.NET Frameworkで使用されるこれらの最適化について簡単に説明します。 記事全体を通して、すべての例でVisual Studio 2013を使用します。



リンク時コード生成

リンク時コード生成(LTCG)コード生成は、C / C ++コードのプログラム全体の最適化(プログラム全体の最適化、WPO)を実行するための手法です。 C / C ++コンパイラは、各ソースコードファイルを個別に処理し、対応するオブジェクトファイルを発行します。 つまり、コンパイラーは、プログラム全体を最適化するのではなく、単一のファイルのみを最適化できます。 ただし、一部の重要な最適化はプログラム全体にのみ適用される場合があります。 リンカはプログラムを完全に理解しているため、これらの最適化はリンク中にのみ使用でき、コンパイル中には使用できません。



LTCGが有効な場合(フラグ/GL



)、コンパイラドライバー( cl.exe



)はフロントエンド( c1.dll



またはc1xx.dll



)のみを呼び出し、リンクまでバックエンド( c2.dll



)を延期します。 結果のオブジェクトファイルには、マシンコードではなくC中間言語(CIL)が含まれます。 次に、リンカー( link.exe



)が呼び出されます。 彼は、オブジェクトファイルにCILコードが含まれていることを確認し、バックエンドを呼び出します。バックエンドは、WPOを実行してバイナリオブジェクトファイルを生成し、リンカーがそれらを接続して実行可能ファイルを形成できるようにします。



フロントエンドは、最適化のオン/オフに関係なく、いくつかの最適化(折りたたみ定数など)も実行します。 ただし、重要な最適化はすべてバックエンドによって実行され、コンパイルキーを使用して制御できます。



LTCGを使用すると、バックエンドで多くの最適化を積極的に実行できます( /O1



または/O2



および/Gw



とともに/GL



コンパイラキー、および/OPT:REF



および/OPT:ICF



リンクキーを使用)。 この記事では、インライン化とCOMDAT最適化のみについて説明します。 LTCG最適化の完全なリストは、ドキュメントに記載されています。 リンカは、ネイティブ、ネイティブ管理、および純粋に管理されたオブジェクトファイル、および安全な管理オブジェクトファイルとsafe.netmodulesでLTCGを実行できることを知っておくと役立ちます。



2つのソースコードファイル( source1.c



およびsource2.c



)とヘッダーファイル( source2.h



)のプログラムを使用します。 source1.c



およびsource2.c



を以下のリストに示します。すべてのsource2.c



関数のプロトタイプを含むヘッダーファイルは非常に単純なので、説明しません。



 // source1.c #include <stdio.h> // scanf_s and printf. #include "Source2.h" int square(int x) { return x*x; } main() { int n = 5, m; scanf_s("%d", &m); printf("The square of %d is %d.", n, square(n)); printf("The square of %d is %d.", m, square(m)); printf("The cube of %d is %d.", n, cube(n)); printf("The sum of %d is %d.", n, sum(n)); printf("The sum of cubes of %d is %d.", n, sumOfCubes(n)); printf("The %dth prime number is %d.", n, getPrime(n)); }
      
      





 // source2.c #include <math.h> // sqrt. #include <stdbool.h> // bool, true and false. #include "Source2.h" int cube(int x) { return x*x*x; } int sum(int x) { int result = 0; for (int i = 1; i <= x; ++i) result += i; return result; } int sumOfCubes(int x) { int result = 0; for (int i = 1; i <= x; ++i) result += cube(i); return result; } static bool isPrime(int x) { for (int i = 2; i <= (int)sqrt(x); ++i) { if (x % i == 0) return false; } return true; } int getPrime(int x) { int count = 0; int candidate = 2; while (count != x) { if (isPrime(candidate)) ++count; } return candidate; }
      
      





source1.c



ファイルには、整数の2乗を計算する2乗関数と、プログラムmain



main関数の2つの関数が含まれています。 メイン関数は、正方形関数と、 source2.c



を除くsource2.c



すべての関数をisPrime



ます。 source2.c



ファイルには、整数を3 cube



ためのcube



、1から特定の数までの整数のsum



を計算するための合計、1から特定の数までの整数のキューブの合計を計算するためのgetPrime



、簡単にするために数を確認するためのisPrime



isPrime



5つの関数が含まれます指定された数の素数を取得します。 この記事では興味がないので、エラー処理をスキップしました。



コードは非常にシンプルですが、便利です。 単純な計算を行ういくつかの関数があり、それらのいくつかはループを含んでいます。 getPrime



関数は、その中にgetPrime



関数を呼び出すwhile



が含まれているgetPrime



、最も複雑です。 このコードを使用して、コンパイラーの最適化といくつかの追加の最適化をインライン化する重要な関数の1つを示します。



3つの異なる構成でのコンパイラーの結果を検討してください。 自分でサンプルを扱う場合は、実行されるCOMDAT最適化を調べるために、アセンブラー出力ファイル(コンパイラーキー/FA[s]



を使用して取得)およびマップファイル(リンカー/MAP



キーを使用して取得)が必要です(リンカーはそれらを報告します) /verbose:icf



および/verbose:ref



/verbose:icf



を含める場合)。 すべてのキーが正しいことを確認し、記事を読み続けます。 C( /TC



)コンパイラーを使用して、生成されたコードを学習しやすくしますが、記事に記載されているものはすべてC ++コードにも適用されます。



デバッグ構成

デバッグ構成が主に使用されるのは、 /GL



スイッチなしで/Od



スイッチを指定すると、すべてのバックエンド最適化がオフになるためです。 この構成では、結果のオブジェクトファイルには、ソースコードと完全に一致するバイナリコードが含まれています。 結果のアセンブラ出力ファイルとマップファイルを調べて、これを確認できます。 この構成は、Visual Studioのデバッグ構成と同等です。



コンパイル時コード生成リリース構成

この構成は、リリース構成( /O1



/O2



または/Ox



スイッチを指定)に似ていますが、 /GL



スイッチは含まれていません。 この構成では、結果のオブジェクトファイルには最適化されたバイナリコードが含まれますが、プログラム全体のレベルの最適化は実行されません。



source1.c



生成されたアセンブリリストファイルをsource1.c



、2つの重要な最適化が行われていることがsource1.c



ます。 square



関数の最初の呼び出しであるsquare(n)



、コンパイル時に計算された値に置き換えられました。 これはどのように起こりましたか? コンパイラーは、関数の本体が小さいことに気付き、その内容を呼び出しに置き換えることにしました。 次に、コンパイラーは、値の計算に既知の初期値を持つローカル変数n



があり、初期割り当てと関数呼び出しの間で変化しないという事実に注意を引きました。 したがって、彼は乗算演算の値を計算し、結果を置き換えることが安全であるという結論に達しました( 25



)。 square



関数の2番目の呼び出しsquare(m)



もインラインでした。つまり、関数の本体が呼び出しに置き換えられました。 ただし、変数mの値はコンパイル時には不明であるため、コンパイラは事前に式の値を計算できませんでした。



それでは、 source2.c



アセンブリリストファイルをsource2.c



みましょう。 sumOfCubes



関数のcube



関数の呼び出しはインラインでした。 これにより、コンパイラはループの最適化を実行できます(これについては、「ループの最適化」セクションで詳しく説明します)。 isPrime



関数は、SSE2命令を使用して、 sqrt



呼び出されたときにint



double



に変換し、 sqrt



から結果を取得するときにdouble



からint



に変換しました。 実際、ループの開始前にsqrt



1回呼び出されました。 /arch



スイッチは、x86がデフォルトでSSE2を使用することをコンパイラーに通知することに注意してください(ほとんどのx86プロセッサーおよびx86-64プロセッサーはSSE2をサポートします)。



リンク時コード生成リリース構成

この構成は、Visiual Studioのリリース構成と同じです。最適化が有効になり、 /GL



コンパイラキーが指定されます( /O1



または/O2



明示的に指定することもできます)。 したがって、アセンブリオブジェクトファイルの代わりにCILコードでオブジェクトファイルを生成するようコンパイラーに指示します。 これは、上記のように、リンカがコンパイラのバックエンドを呼び出してWPOを実行することを意味します。 ここで、LTCGの大きな利点を示すために、いくつかのWPOについて説明します。 この構成用に生成されたアセンブリコードのリストは、オンラインで入手できます。



関数のインライン化がオンになっている間(最適化をオンにするとオンになる/Ob



スイッチ)、 /GL



スイッチを使用すると、コンパイラーは/Gy



スイッチに関係なく他のファイルで定義された関数をインライン化できます(後で詳しく説明します)。 /LTCG



オプションであり、リンカーにのみ影響します。



source1.c



アセンブリリストファイルをsource1.c



scanf_s



を除くすべての関数の呼び出しがインラインであることがsource1.c



ます。 その結果、コンパイラは関数cube



sum



およびsumOfCubes



を計算できました。 isPrime



関数のみisPrime



インライン化isPrime



ませんでした。 ただし、 getPrime



で手動でインライン化したgetPrime



、コンパイラーはgetPrime



でインラインgetPrime



を実行します。



ご覧のとおり、関数のインライン化は、関数呼び出しが最適化されるだけでなく、コンパイラーが多くの追加の最適化を実行できるため重要です。 インライン化は通常、コードのサイズを増やすことでパフォーマンスを向上させます。 この最適化を過度に使用すると、コードの膨張と呼ばれる現象が発生します。 したがって、関数を呼び出すたびに、コンパイラーはコストと利点を計算し、関数をインライン化するかどうかを決定します。



インライン化の重要性により、Visual C ++コンパイラはインライン化を強力にサポートします。 auto_inline



ディレクティブを使用して、関数セットをインライン化しないようにコンパイラーに指示できます。 __declspec(noinline)



を使用して、指定された関数またはメソッドをコンパイラーに伝えることもできます。 関数をinline



でマークし、コンパイラーにインラインを実行するようにアドバイスすることもできます(ただし、コンパイラーは、それが悪いと判断した場合、このアドバイスを無視することもできます)。 inline



は、C ++の最初のバージョンから使用可能であり、C99で登場しました。 CとC ++の両方にMicrosoftの__inline



コンパイラキーワードを使用できます。これは、このキーワードをサポートしない古いバージョンのCを使用する場合に便利です。 __forceinline



(CおよびC ++の場合)は、可能であれば、コンパイラーが常に関数をインライン化するように強制します。 最後に、 inline_recursion



こととして、 inline_recursion



ディレクティブを使用してインライン化することにより、指定した深さまたは不定の深さの再帰関数をデプロイするようコンパイラーに指示できます。 現時点では、コンパイラーは関数呼び出しの場所でインライン展開を制御する機能を持たず、その宣言の場所では機能しないことに注意してください。



/Ob0



は、インライン化を完全に無効にします。これは、デバッグ中に役立ちます(このスイッチは、Visual Studioのデバッグ構成で機能します)。 /Ob1



は、 inline



/Ob1



inline



/Ob1



マークされた関数のみを__forceinline



候補と見なすようコンパイラーに指示します。 /Ob2



は、指定された/O[1|2|x]



でのみ動作し、インライン化のためにすべての関数を考慮するようコンパイラーに指示します。 私の意見では、 inline



および__inline



を使用する唯一の理由は、 /Ob1



キーのインライン化を制御することです。



コンパイラは常に関数をインライン化できるわけではありません。 例えば、仮想関数の仮想呼び出し中:コンパイラーはどの関数が呼び出されるかを正確に知らないため、関数をインライン化することはできません。 別の例:関数は、名前を介した呼び出しではなく、関数へのポインターを介して呼び出されます。 インライン化が可能になるように、このような状況を避けるようにしてください。 そのようなすべての条件の完全なリストは、MSDNで見つけることができます。



プログラムのレベルで全体として適用できる最適化は、関数のインライン化だけではありません。 ほとんどの最適化は、このレベルで最も効果的に機能します。 この記事の残りの部分では、COMDAT最適化と呼ばれる特定のクラスの最適化について説明します。



デフォルトでは、モジュールのコンパイル中、すべてのコードは結果のオブジェクトファイルの単一セクションに保存されます。 リンカはセクションレベルで動作します。セクションの削除、結合、並べ替えができます。 これにより、実行ファイルのサイズを縮小し、パフォーマンスを向上させる3つの非常に重要な最適化(2桁のパーセント)を実行できなくなります。 1つ目は、未使用の関数とグローバル変数を削除します。 2番目は、同一の関数とグローバル定数を折りたたみます。 3番目の関数は、実行時に物理メモリフラグメント間の遷移が短くなるように、関数とグローバル変数を並べ替えます。



これらのリンカ最適化を有効にするには、コンパイラキー/Gy



(リンク関数レベル)および/Gw



(グローバルデータの最適化)を使用して、関数と変数を別々のセクションにパックするようコンパイラに依頼する必要があります。 これらのセクションはCOMDATと呼ばれます。 __declspec( selectany)



を使用して特定のグローバル変数をマークし、COMDATで変数をパッケージ化するようコンパイラーに指示することもできます。 さらに、 /OPT:REF



リンカキーを使用すると、未使用の関数とグローバル変数を削除できます。 キー/OPT:ICF



は、同一の関数とグローバル定数を折りたたむのに役立ちます(ICFは同一のCOMDATフォールディングです)。 /ORDER



スイッチにより、リンカは特定の順序で結果のイメージにCOMDATを配置します。 すべてのリンカ最適化には/GL



キーが必要ないことに注意してください。 /OPT:REF



および/OPT:ICF



スイッチは、明らかな理由でデバッグ中にオフにする必要があります。



可能な限りLTCGを使用する必要があります。 LTCGを放棄する唯一の理由は、結果のオブジェクトファイルとライブラリファイルを配布するためです。 マシンコードの代わりにCILコードが含まれていることを思い出してください。 開発者がファイルを使用するには同じバージョンのコンパイラを使用する必要があるため、CILコードは、生成された同じバージョンのコンパイラとリンカでのみ使用できます。これは大きな制限です。 この場合、コンパイラのバージョンごとにオブジェクトファイルの個別のバージョンを配布したくない場合は、代わりにコード生成を使用する必要があります。 バージョン制限に加えて、オブジェクトファイルは、対応するアセンブラオブジェクトファイルよりも何倍も大きくなります。 ただし、CILコードを持つオブジェクトファイルの大きな利点を忘れないでください。これはWPOを使用する機能です。



ループ最適化

Visual C ++コンパイラは、いくつかのタイプのループ最適化をサポートしていますが、ループアンロール、自動ベクトル化、およびループ不変コードモーションの3つのみについて説明します。 source1.c



のコードを変更して、nではなくmがsumOfCubes



に渡されるようにした場合、コンパイラーはパラメーターの値を計算できなくなります。任意の引数で機能するように関数をコンパイルする必要があります。 結果の関数は大きく最適化されるため、コンパイラーはインライン化されません。



/O1



スイッチを使用してコードをコピーすると、 sumOfCubes



最適化は適用されsumOfCubes



/O2



スイッチを使用してコンパイルすると、速度が最適化されます。 この場合、 sumOfCubes



関数内のループが巻き戻されてベクトル化されるsumOfCubes



、コードサイズが大幅に増加します。 キューブ関数をインライン化しないとベクトル化ができないことを理解することは非常に重要です。 さらに、サイクルを巻き戻すことは、インライン化なしではそれほど効果的ではありません。 最終コードの簡略化されたグラフィカルな表現を次の図に示します(このグラフはx86とx86-64の両方に有効です)。







この図では、緑色の菱形は入口点を示し、赤色の長方形は出口点を示します。 青い菱形は、 sumOfCubes



関数がsumOfCubes



れたときに実行される条件ステートメントを表します。 SSE4がサポートされ、xが8以上の場合、SSE4命令を使用して一度に4つの乗算を実行します。 複数の変数に対して同じ操作を実行するプロセスは、ベクトル化と呼ばれます。 コンパイラはこのループを2回ほどほどします。 これは、ループの本体が各反復で2回繰り返されることを意味します。 結果として、乗算の8つの演算のパフォーマンスは1回の反復で発生します。 x



8未満の場合、最適化されていないコードが関数の実行に使用されます。 コンパイラーは1つではなく3つの出口点を挿入することに注意してください。したがって、遷移の数が減ります。



サイクルの巻き戻しは、新しい(巻き戻された)サイクルの1回の繰り返し内でサイクルの本体を数回繰り返すことによって実行されます。 これにより、サイクル自体の操作の実行頻度が低くなるため、生産性が向上します。 さらに、これにより、コンパイラーは追加の最適化(ベクトル化など)を実行できます。 巻き戻しループの欠点は、コードの量とレジスタの負荷が増加することです。 しかし、それにもかかわらず、サイクルの本体に応じて、このような最適化は生産性を2桁の割合で高めることができます。



x86プロセッサとは異なり、すべてのx86-64プロセッサはSSE2をサポートしています。 さらに、 /arch



スイッチを使用して、IntelおよびAMDの最新のx86-64モデルでAVX / AVX2命令を利用できます。 /arch:AVX2



指定することにより、FMAおよびBMI命令も使用するようコンパイラーに指示します。



現在、Visual C ++コンパイラでは、ループの巻き戻しを制御できません。 ただし、 no_vector



オプションを指定したloop



ディレクティブを使用して、影響を与えることができます(後者は、指定されたループのno_vector



ベクトル化を無効にします)。



生成されたアセンブラコードを見ると、追加の最適化を適用できることがわかります。 とにかく、コンパイラーはとにかく素晴らしい仕事をしてくれたので、マイナーな最適化を適用するために分析するのに多くの時間を費やす必要はありません。



someOfCubes



関数someOfCubes



、ループが解かれた唯一のものでsomeOfCubes



ません。 コードを変更してn



代わりにm



sum



関数に渡すと、コンパイラーはその値を計算できず、コードを生成する必要があり、ループが2回巻き戻されます。



結論として、サイクル不変量の除去などの最適化を検討します。 次のコードを見てください。



 int sum(int x) { int result = 0; int count = 0; for (int i = 1; i <= x; ++i) { ++count; result += i; } printf("%d", count); return result; }
      
      





行った唯一の変更は、追加の変数を追加することです。この変数は各反復で増加し、最後にコンソールに表示されます。 このコードは、増分された変数をループ外に移動することで簡単に最適化されることがわかりますx



を割り当てるだけです。 この最適化は、ループ不変コードモーションと呼ばれます。 「不変」という言葉は、コードの一部がループ変数を含む式から独立している場合にこの手法が適用可能であることを示しています。



: , . ? , x



. , count



. x count, ! , x



, count



. , . , Visual C++ , , x



.



, , , , . .



O1



, /O2



, /Ox



, optimize



:



 #pragma optimize( "[optimization-list]", {on | off} )
      
      





optimization list , : g



, s



, t



, y



. /Og



, /Os



, /Ot



, /Oy



.



c off



. on



.



/Og



, , , . LTCG



, /Og



WPO.



optimize



, , : , . , , profile-guided- (PGO), , , . , . Visual Studio , , .



.NET

.NET , . (C# compiler) JIT-. . , . JIT-. JIT- .NET 4.5 SIMD. JIT- .NET 4.5.1 ( RyuJIT) SIMD.



RyuJIT Visual C++ ? , RyuJIT , , Visual C++ . , , true



, . RyuJIT . , SSE4.1, JIT- SSE4.1 subOfCubes



, . , RyuJIT , . . JIT- . Visual C++ , . Microsoft .NET Native Visual C++. Windows Store.



. C# Visual Basic /optimize



. JIT- System.Runtime.CompilerServices.MethodImpl



MethodImplOptions



. NoOptimization



, NoInlining



, AggressiveInlining



( .NET 4.5) JIT- , .



まとめ

, , . Visual C++. , , . , Visual C++. , . Visual C++ , . 2.



All Articles