コンパイラ

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

Hadi Brais

コード サンプルのダウンロード

コンパイラの最適化に関する連載の第 2 部です。初回 (msdn.microsoft.com/magazine/dn904673) は、関数のインライン化、ループ アンローリング、ループ不変条件コード モーション、自動ベクター化、および COMDAT 最適化について説明しました。今回は、それ以外の 2 つの最適化として、レジスタ割り当てと命令スケジューリングを取り上げます。これまで同様、Visual C++ コンパイラを中心に、Microsoft .NET Framework におけるしくみについても簡単に紹介します。コードのコンパイルには Visual Studio 2013 を使用します。では、始めましょう。

レジスタ割り当て

レジスタ割り当てとは、変数用にメモリを確保する必要をなくし、使用可能なレジスタに一連の変数を割り当てるプロセスです。このプロセスは通常、1 つの関数全体のレベルで実行されます。ただし、リンク時のコード生成 (/LTCG) が有効になっている場合は特に、複数の関数にまたがってこのプロセスを実行して、さらに効率の高い割り当てが可能になる場合があります (ここでは、特に記載がない限り、変数はすべて自動変数、つまり構文上有効期間が決まる変数です)。

レジスタ割り当ては、特に重要な最適化です。これを理解するために、さまざまなレベルのメモリへのアクセスにかかる時間を確認します。レジスタへのアクセスにかかる時間は 1 プロセッサ サイクル未満です。キャッシュへのアクセスは少し時間がかかり、数サイクル~数十サイクルです。(リモート) DRAM メモリへのアクセスはそれよりもずっと時間がかかります。さらに、ハード ドライブへのアクセスは非常に遅く、数百万サイクルかかります。また、メモリ アクセスによって共有キャッシュやメイン メモリとのトラフィックが増加します。レジスタ割り当てを行い、使用可能なレジスタをできる限り活用することで、メモリへのアクセス回数が減少します。

コンパイラは各変数のレジスタへの割り当てを試みます。その変数が関係するすべての命令が実行されるまで、変数がレジスタに割り当たったままの状態が理想です。後ほど簡単に説明する理由からよく起こることですが、割り当て状態を維持できないと、1 つ以上の変数がメモリに吐き出され、読み込みと書き込みが頻繁に行われることになります。レジスタ負荷とは、レジスタの使用を維持できないために変数がメモリに吐き出されたレジスタの数を表します。レジスタ負荷が大きいほどメモリのアクセス回数が多くなり、メモリのアクセス回数が増えるとプログラム自体が遅くなるだけでなく、システム全体の処理速度が低下する可能性があります。

最新の x86 プロセッサには、コンパイラが割り当てることができるレジスタとして、8 個の 32 ビット汎用レジスタ、8 個の 80 ビット浮動小数点レジスタ、および 8 個の 128 ビット ベクター レジスタがあります。すべての x64 プロセッサに、16 個の 64 ビット汎用レジスタ、8 個の 80 ビット浮動小数点レジスタ、および最低 16 個の (128 ビット幅以上の) ベクター レジスタがあります。最新の 32 ビット ARM プロセッサには、15 個の 32 ビット汎用レジスタと 32 個の 64 ビット浮動小数点レジスタがあります。すべての 64 ビット ARM プロセッサには、31 個の 64 ビット汎用レジスタ、32 個の 128 ビット浮動小数点レジスタ、および 16 個の 128 ビット ベクター レジスタ (NEON) があります。これらのレジスタはすべて、レジスタ割り当てに使用できます (また、グラフィック カードに用意されているレジスタもこの一覧に加えることができます)。使用可能なレジスタのどれにもローカル変数を割り当てられない場合、その変数はスタック上に割り当てられます。後ほど説明しますが、これは、ほとんどすべての関数において、さまざまな原因で発生します。ここで、図 1 のプログラムについて考えてみましょう。このプログラムの動作には意味がありませんが、レジスタ割り当ての説明には優れた例といえるでしょう。

図 1 レジスタ割り当てサンプル プログラム

#include <stdio.h>
int main() {
  int n = 0, m;
  scanf_s("%d", &m);
  for (int i = 0; i < m; ++i){
    n += i;
  }
  for (int j = 0; j < m; ++j){
    n += j;
  }
  printf("%d", n);
  return 0;
}

変数を使用可能なレジスタに割り当てる前に、コンパイラはまず、関数 (/LTCG の場合は複数の関数) 内で宣言される変数すべての用途を分析し、同じタイミングで有効になっていなければならない変数のセットを判断します。その後、各変数へのアクセス回数を見積もります。2 つの変数が含まれるセットが異なれば、それらを同じレジスタに割り当てることができます。同じセットの変数の一部に適切なレジスタを割り当てられない場合、その変数をメモリに吐き出す必要があります。コンパイラは、メモリの総アクセス回数を最小限に抑えるために、アクセス回数が最も少ない変数を選んでメモリへの吐き出しを試みます。これが基本的な考え方です。ただし、これよりも適切な割り当てが可能になる特殊なケースが多数あります。最新のコンパイラは、優れた割り当てを考案することができますが、最適な割り当てではありません。とは言え、人間がコンパイラよりも優れた割り当てを考え出すことは至難の技です。

このことを念頭に、最適化を有効にして 図 1 のプログラムをコンパイルし、コンパイラがどのようにローカル変数をレジスタに割り当てるかを確認します。割り当ての対象となる変数は、n、m、i、j の 4 つです。今回のターゲットには x86 プラットフォームを想定します。生成されたアセンブリ コード (/FA) を調べると、変数 n がレジスタ ESI に、変数 m が ECX に、変数 i と j はどちらも EAX に割り当てられているのがわかります。最後の 2 つの変数は有効期間が重ならないため、コンパイラは EAX をうまく再利用しています。また、m のアドレスを使用しているコードがあるため、コンパイラはスタック上に m の領域を確保します。x64 プラットフォームでは、変数 n は レジスタ EDI に、変数 m は EDX に、変数 i は EAX に、変数 j は EBX に割り当てられます。今回は、何らかの理由で、i と j が同じレジスタに割り当てられませんでした。

これは最適な割り当てでしょうか。違います。ESI と EDI を使用している点が問題です。これらのレジスタは、呼び出された側が保存しておくべきレジスタです。つまり、呼び出された関数では、開始時と終了時にこのレジスタの値を同じにしておかなければなりません。そのため、コンパイラは関数の開始時に ESI/EDI レジスタの値をスタックにプッシュする命令を生成し、関数の終了時にそれらをスタックからポップする命令を生成する必要があります。EDX のように、呼び出し側が保存しておくべきレジスタを使用すると、どちらのプラットフォームでもこの処理を回避できます。レジスタ割り当てアルゴリズムのこのような欠陥は、関数のインライン化によって軽減できる場合があります。ほかにも、実行されないコードの削除、共通部分式の削除、命令スケジューリングなどの最適化を行うことで、レジスタが効率よく割り当られる可能性が高いコードにすることができます。

変数の有効期間が重ならないのは実際によくあることなので、そのような変数すべてを同じレジスタに割り当てれば非常に経済的です。では、変数を格納するレジスタが不足するとどうなるでしょう。その場合は、変数をメモリに吐き出さなければなりません。ただし、これを巧みに行う方法があります。変数をすべてスタック上の同じ場所に吐き出します。この最適化をスタック パッキングと呼び、Visual C++ によってサポートされます。スタック パッキングを行うとスタック フレームのサイズが小さくなり、データ キャッシュ ヒット率が向上して、パフォーマンスが高まる場合があります。

残念ながら、言うほど簡単ではありません。理論上は (ほぼ) 最適なレジスタ割り当てを実現できそうですが、実際には、次のようにこれを実現できない理由がたくさんあります。

  • x86 プラットフォームと x64 プラットフォームで使用可能なレジスタ (前述) や、他のプラットフォーム (ARM など) で使用可能なレジスタには複雑な制限事項があり、自由に使用できません。各命令では、オペランドに使用できるレジスタに制限があります。そのため、命令を使用する場合には、その命令に必要なオペランドに指定できるレジスタを使用しなければなりません。また、一部の命令の結果が事前定義されたレジスタに格納されます。命令では、このレジスタに格納された値は揮発性があると想定されます。同じ計算を実行する場合でも、さらに効率の高いレジスタ割り当てが可能な別の命令シーケンスが存在することもあります。命令選択、命令スケジューリング、およびレジスタ割り当ての問題は複雑に絡み合っています。
  • すべての変数がプリミティブ型とは限りません。自動変数の構造体や配列も珍しくありません。このような変数を直接レジスタに割り当てることは考えられませんが、個別に割り当てることはできます。ただし、現在のコンパイラではまだ適切に処理されません。
  • 関数の呼び出し規約によって、一部の引数に固定割り当てが行われ、他の引数はレジスタの可用性に関係なく割り当ての対象ではなくなります。これについては後ほど解説します。また、呼び出し側が保存すべきレジスタと呼び出された側が保存すべきレジスタという概念が状況を複雑にしています。
  • 変数のアドレスを使用するコードがある場合、アドレスが設定される場所に変数を格納する方が適切です。レジスタにはアドレスがないため、変数はレジスタが使用可能かどうかにかかわらずメモリに格納されることになります。

このように説明すると、現在のコンパイラが行うレジスタ割り当てはひどいものだと思われるかもしれません。しかし、そこそこうまく割り当てが行われ、非常にゆっくりとしてペースですが向上しています。また、アセンブリ コードを作成しているところを想像してみてください。これらをすべて考慮して記述しているでしょうか。

x86 アーキテクチャをターゲットにする場合、/LTCG を有効にすると、コンパイラがより適切な割り当てを見つけるのに役立ちます。/GL コンパイラ スイッチを指定すると、生成される OBJ ファイルには、アセンブリ コードではなく C 中間言語 (CIL) コードが含まれます。関数の呼び出し規約は CIL コードには組み込まれません。特定の関数が出力の実行可能ファイルからエクスポートされるように定義されていない場合、コンパイラはその呼び出し規約に反してパフォーマンスを向上できます。このようなことが可能なのは、コンパイラが関数の呼び出しサイトをすべて特定できるためです。Visual C++ では、呼び出し規約に関係なく関数のすべての引数をレジスタ割り当ての対象にすることで、これを活用しています。レジスタ割り当てを改善できない場合でも、コンパイラは、より効率的な連携を実現するために、パラメーターの並び替えや、未使用のパラメーターの削除などを試みます。/GL スイッチを指定しないと、生成される OBJ ファイルには呼び出し規約が既に考慮されたバイナリ コードが含まれます。アセンブリ OBJ ファイルに CIL OBJ ファイルの関数への呼び出しサイトが含まれる場合、関数のアドレスが利用されている場合、またはアドレスが仮想の場合、コンパイラは呼び出し規約を最適化できません。/LTCG を指定しないと、既定ですべての関数とメソッドに外部リンケージが含まれます。そのため、コンパイラはこの手法を適用できません。ただし、OBJ ファイル内の関数で内部リンケージが明示的に定義されている場合は、OBJ ファイル内に限って、コンパイラはこの手法を適用できます。ドキュメントで「カスタム呼び出し規約」と呼ばれているこの手法は、x86 アーキテクチャをターゲットとする場合に重要です。x86 アーキテクチャでは、既定の呼び出し規約 (具体的には __cdecl) が効率的ではないためです。一方、x64 アーキテクチャの __fastcall 呼び出し規約は、最初の 4 つの引数がレジスタを使って渡されるため、非常に効率的です。そのため、カスタム呼び出し規約は x86 をターゲットとする場合にのみ実行されます。

/LTCG が有効でも、エクスポートされた関数やメソッドでは、コンパイラが先述のすべての場合と同様に特定できない呼び出しサイトもあることから、そのような関数やメソッドの呼び出し規約に反することはできないことに注意してください。

レジスタ割り当ての効果は、変数へのアクセス回数を推定する際の正確さによって異なります。ほとんどの関数は条件付きステートメントを含んでおり、これが推定の正確さを損ないます。この推定の微調整には、ガイド付き最適化のプロファイルを使用できます。

/LTCG が有効で、ターゲット プラットフォームが x64 の場合、コンパイラは手続き間レジスタ割り当てを行います。つまり、コンパイラは、関数チェーン内で宣言された変数を考慮し、各関数のコードによって課される制約に応じて適切な割り当てを見つけようと試みます。見つからない場合は、各関数を個別に処理するグローバル レジスタ割り当てを行います (ここでの "グローバル" は関数全体を意味します)。

C と C++ にはどちらも register キーワードが用意されており、プログラマはこれを使用して、レジスタに格納する変数に関するヒントをコンパイラに提供できます。実は、C の最初のバージョンでこのキーワードが導入されたとき (1972 年ころ) は、レジスタ割り当てを効果的に行う方法をだれも知らなかったので、このキーワードが便利でした (ただし、1960 年代後半に IBM が S/360 シリーズ用に開発した FORTRAN IV コンパイラは、簡単なレジスタ割り当てが可能でした。ほとんどの S/360 モデルは 16 個の 32 ビット汎用レジスタと 4 個の 64 ビット浮動小数点レジスタを備えていました)。また、C の多くの他の機能と同じように、register キーワードを使用することで C コンパイラの記述が簡単になります。ほぼ 10 年後、C++ が作成されました。(残念ながら小さな違いが多数あるのですが) C は C++ のサブセットと考えられたため、C++ にも register キーワードが用意されました。80 年代初頭以降、効果的なレジスタ割り当てアルゴリズムが数多くの実装されたため、このキーワードの存在は今日までにさまざまな混乱を生んでいます。これ以降に作成されたほとんどの言語 (C#、Visual Basic など) には、このようなキーワードは用意されていません。このキーワードは C++11 以降は推奨されなくなりましたが、最新バージョンの C、C11 においてはその限りではありません。このキーワードはベンチマークの記述においてのみ使用することをお勧めします。Visual C++ コンパイラは、できる限りこのキーワードを考慮します。C はレジスタ変数のアドレスを使用できません。一方、C++ ではアドレスを使用できますが、コンパイラは、手動で指定したストレージ クラスに反して、変数をレジスタ以外のアドレス指定可能な場所に格納する必要があります。

CLR をターゲットにする場合、コンパイラは、スタック マシンをモデル化する共通中間言語 (CIL) コードを生成します。この場合、コンパイラはレジスタ割り当てを行いません (生成されるコードの一部がネイティブの場合でも、当然ながらレジスタ割り当てはそのコードに対して行われます)。レジスタ割り当ては、JIT (Just-in-Time) コンパイラ (.NET ネイティブ コンパイルの場合は Visual C++ バック エンド) でランタイムが実行されるまで先送りにされます。RyuJIT (.NET Framework 4.5.1 以降に付属の JIT コンパイラ) は、かなり優れたレジスタ割り当てアルゴリズムを実装しています。

命令スケジューリング

レジスタ割り当てと命令スケジューリングは、コンパイラがバイナリを生成する前に行う最後の最適化です。

最も単純な命令を除くすべての命令が、複数のステージを経て実行されます。各ステージは、特定のプロセッサ ユニットによって処理されます。これらのユニットすべてをできる限り活用するために、プロセッサは複数の命令をパイプライン形式で発行し、さまざまな命令がさまざまなステージで同時実行されるようにしています。これにより、大幅にパフォーマンスが向上する場合があります。ただし、何らかの理由で命令の 1 つの実行準備が間に合わないと、パイプライン全体が停止します。これが発生する理由は、結果をコミットするための他の命令の待機、メモリやディスクからのデータの待機、I/O 操作の完了の待機など、さまざまです。

命令スケジューリングは、この問題を軽減する手法です。命令スケジューリングには、次の 2 種類があります。

  • コンパイラベース: コンパイラが関数の命令を分析し、パイプラインを停止する可能性のある命令を判断します。その後、予想される停止のコストを最小限に抑えると同時にプログラムの正常性を維持できる別の命令の順序を探します。これを、命令の順序変更と呼びます。
  • ハードウェアベース: 最新の x86、x64、および ARM プロセッサの大半は、命令 (正確にはマイクロ命令) のストリームを予測して、そのオペランドと必要な機能単位の実行に使用できる命令を発行します。これを、アウトオブオーダー実行 (OoOE または 3OE) や動的実行と呼びます。結果として、プログラムは元の順序とは異なる順序で実行されます。

他の理由で、コンパイラが特定の命令の順序を変更することもあります。たとえば、コンパイラは、コード参照の局所性が高くなるように、入れ子になったループの順序を変更することがあります (この最適化をループ インターチェンジと呼びます)。もう 1 つの例は、メモリから読み込んだ同じ値を使用する命令を連続して実行し、値の読み込みを 1 回にして、レジスタからメモリに吐き出されるコストを削減するものです。また、データと命令キャッシュ ミスを削減する例もあります。

プログラマとして、コンパイラやプロセッサが行う命令スケジューリングのしくみを知っておく必要はありません。ただし、この手法の効果とその処理方法については知っておいた方がよいでしょう。

命令スケジューリングではほとんどのプログラムの正常性が維持されますが、直感的ではない意外な結果が生成されることがあります。図 2 は、命令スケジューリングによってコンパイラが不適切なコードを生成する例を示しています。これを確認するには、リリース モードでプログラムを C コード (/TC) として コンパイルします。ターゲット プラットフォームは x86 と x64 のどちらに設定してもかまいません。生成されたアセンブリ コードを調査するので、/FA を指定して、コンパイラでアセンブリ リストが生成されるようにします。

図 2 命令スケジューリング サンプル プログラム

#include <stdio.h>
#include <time.h>
__declspec(noinline) int compute(){
  /* Some code here */
  return 0;
}
int main() {
  time_t t0 = clock();
  /* Target location */
  int result = compute();
  time_t t1 = clock(); /* Function call to be moved */
  printf("Result (%d) computed in %lld ticks.", result, t1 - t0);
  return 0;
}

このプログラムで、compute 関数の実行時間を計測します。これを行うには、通常、関数の呼び出しを clock などの時間計測関数の呼び出しでラップします。その後、clock 値の差異を計算して、関数の実行にかかった時間を推定します。このコードの目的は、コードのパフォーマンスを測定する最適な方法を示すことではなく、命令スケジューリングの危険性を示すことにある点に注意してください。

これは C コードであり、プログラムは非常に単純なので、生成されるアセンブリ コードを簡単に理解できます。アセンブリ コードを見て、呼び出し命令に注目すると、clock 関数への 2 つ目の呼び出しが、compute 関数への呼び出しよりも前にある (2 つ目の呼び出しが "ターゲットの場所" に移動されている) ことがわかります。この移動によって、測定値は完全に間違ったものになります。

この順序変更は、準拠する実装の標準によって課される最低限の要件に反するものではないため、適切なものです。

しかし、なぜコンパイラはこのような順序変更を行うのでしょう。コンパイラは、clock への 2 つ目の呼び出しは compute への呼び出しには左右されないと判断します (実際、コンパイラにとって、これらの関数は互いにまったく影響を及ぼしません)。また、clock への最初の呼び出しの後、命令キャッシュにこの関数の命令の一部が含まれており、データ キャッシュにその命令に必要なデータの一部が含まれている可能性があります。その命令やデータは、compute を呼び出すと上書きされる可能性があるため、それに応じてコンパイラがコードの順序を変更しました。

Visual C++ コンパイラには、命令スケジューリングだけを無効にし、他の最適化は有効にしておくスイッチはありません。さらに、この問題は、compute 関数がインライン化されている場合に動的実行を行うと発生することもあります。compute 関数の実行方法とプロセッサがどの程度先まで予想できるかに応じて、3OE プロセッサでは、compute 関数が完了する前に、clock への 2 つ目の呼び出しの実行開始を決めてしまう可能性があります。コンパイラと同じように、大部分のプロセッサでは、開発者が動的実行を無効にすることはできません。ただし、公正を期すために補足すると、動的実行によってこの問題が発生することはほとんどありません。いずれにせよ、この問題が発生したかどうかは、どのようにしてわかるでしょう。

Visual C++ コンパイラは、この最適化を極めて慎重に実行します。十分な注意を払うことで、さまざまな要因で命令 (呼び出し命令など) の順序変更を防ぎます。次のような状況では、コンパイラが clock 関数の呼び出しを特定の場所 (ターゲットの場所) に移動しないことがわかりました。

  • 呼び出されている任意の関数からインポートした関数を、関数呼び出しの場所とターゲットの場所の間で呼び出す。このコードが示すように、インポートした任意の関数を compute 関数から呼び出す場合、コンパイラは clock への 2 つ目の呼び出しを移動しません。
__declspec(noinline) int compute(){
  int x;
  scanf_s("%d", &x); /* Calling an imported function */
  return x;
}
  • インポートした関数を、compute への呼び出しと clock への 2 つ目の呼び出しの間で呼び出す。
int main() {
  time_t t0 = clock();
  int result = compute();
  printf("%d", result); /* Calling an imported function */
  time_t t1 = clock();
  printf("Result (%d) computed in %lld.", result, t1 - t0);
  return 0;
}
  • 呼び出されている任意の関数のグローバル変数または静的変数に、関数呼び出しの場所とターゲットの場所の間でアクセスする。これは、変数が読み取られているか書き込まれているかを保持します。以下は、compute 関数でグローバル変数にアクセスして、コンパイラが clock の 2 つ目の呼び出しを移動しないようにするコードです。
int x = 0;
__declspec(noinline) int compute(){
  return x;
}
  • t1 を揮発性にマークする。

コンパイラが命令の順序変更を行わない状況はほかにもあります。これにはすべて C++ as-if 規則 (コードの観測可能な動作が変わらない限りにおいて、定義されていない演算を含まないプログラムをコンパイラが自由に変換できる) が関係しています。Visual C++ はこの規則に従っていますが、それ以上に保守的に、コードのコンパイルにかかる時間を削減します。インポートした関数には副作用がある場合があります。ライブラリ I/O 関数と揮発性変数へのアクセスには副作用があります。

volatile、restrict、および /favor

volatile キーワードで変数を修飾すると、レジスタ割り当てと命令の順序変更の両方に影響します。まず、変数はどのレジスタにも割り当てられなくなります (ほとんどの命令は一部のオペランドをレジスタに格納する必要があるため、変数はレジスタに読み込まれます。ただし、その目的はその変数を使用する一部の命令の実行のみです)。つまり、変数の読み取りや書き込みの際には必ずメモリ アクセスが発生します。次に、揮発性変数への書き込みには解放のセマンティクスがあります。つまり、構文上その変数への書き込みの前に行われるすべてのメモリ アクセスは事前に行われます。3 つ目に、揮発性変数からの読み取りには取得のセマンティクスがあります。つまり、構文上その変数からの読み取りの後に行われるすべてのメモリ アクセスは事後に行われます。ただし、落とし穴があります。このような順序変更が保証されるのは、/volatile:ms スイッチを指定した場合だけです。一方、/volatile:iso スイッチを指定すると、コンパイラは言語の標準に従います。その場合、このキーワードへの準拠は一切保証されません。ARM では、/volatile:iso が既定で指定されています。それ以外のアーキテクチャでは、既定で /volatile:ms が指定されています。C++11 が登場する前は、標準にマルチスレッド プログラム用のものがなかったため、/volatile:ms が便利でした。しかし、C11/C++11 以降では /volatile:ms を使用するとコードを移植できなくなるため、/volatile:ms は使用せず、アトミックを使用することを強くお勧めします。/volatile:iso を指定してプログラムが適切に動作する場合、/volatile:ms を指定しても適切に動作することは注目に値します。しかし、さらに重要なのは、/volatile:ms を指定して適切に動作する場合でも、/volatile:iso を指定すると適切に動作しないことがあることです。これは、/volatile:ms の方が /volatile:iso よりも強力な保証をしているためです。

/volatile:ms スイッチは、取得と解放のセマンティクスを実装しています。これらのセマンティクスをコンパイル時に維持するだけでは不十分です。コンパイラは (ターゲット プラットフォームに応じて) 追加の命令 (mfence や xchg など) を生成して、3OE プロセッサにそれらのセマンティクスをコード実行中に維持するように指示します。そのため、揮発性変数を使用する場合、変数がレジスタにキャッシュされないだけでなく、追加の命令が生成されるために、パフォーマンスが低下します。

C# の言語仕様によると、volatile キーワードのセマンティクスは、/volatile:ms スイッチが指定された Visual C++ コンパイラが提供するセマンティクスに似ています。ただし、違いもあります。C# の volatile キーワードは逐次一貫性 (SC) のある取得/解放のセマンティクスを実装していますが、/volatile:ms が指定された C/C++ の volatile は純粋な取得/解放のセマンティクスを実装しています。また、/volatile:iso が指定された C/C++ の volatile には取得/解放のセマンティクスがないことを忘れないでください。詳細はここで取り扱う範囲を超えているので触れません。一般的に、メモリ バリアによってコンパイラによるさまざまな最適化が行われなくなる場合があります。

そもそもコンパイラがこのような保証を提供しない場合は、プロセッサによって提供される対応する保証が自動的に無効になります。

__restrict キーワード (または restrict) も、レジスタ割り当てと命令スケジューリングの両方に影響します。ただし、volatile とは対照的に、restrict を使用すると最適化の効果を大幅に高めることができます。スコープ内にあるこのキーワードによってマークされたポインター変数は、スコープ外で作成されてオブジェクトの変更に使用される、同じオブジェクトを参照する他の変数がないことを示します。また、このキーワードによって、コンパイラはポインターに対して、確信を持って自動ベクター化やループの最適化など、多数の最適化を実行できるようになるため、生成されるコードのサイズが削減されます。restrict キーワードは、極秘のハイテク最適化抑止対策ツールと考えることができます。これだけで 1 回のコラムになるため、ここでは説明しません。

変数が volatile と __restrict の両方でマークされている場合、コードの最適化方法を判断する際に、volatile キーワードが優先されます。それどころか、コンパイラで restrict が完全に無視されることがあります。ただし、volatile は必ず考慮されます。

/favor スイッチを使用すると、指定したアーキテクチャに合わせて微調整された命令スケジューリングをコンパイラが実行できるようになります。また、コンパイラは特定の機能がプロセッサによってサポートされているかどうかを確認する命令を生成しないようにできるため、生成されるコード サイズが小さくなる場合があります。これにより、命令キャッシュ ヒット率が高まり、パフォーマンスが向上します。既定値は /favor:blend で、Intel および AMD の x86 および x64 プロセッサで最適なパフォーマンスを実現するコードが生成されます。

まとめ

Visual C++ コンパイラが実行する 2 つの重要な最適化として、レジスタ割り当てと命令スケジューリングについて説明しました。

キャッシュへのアクセスよりもレジスタへのアクセスの方がはるかに短時間であることから、レジスタ割り当ては、コンパイラが実行する最適化の中で最も重要なものです。命令スケジューリングも同様に重要です。ただし、最新のプロセッサにはすばらしい動的実行機能があるため、命令スケジューリングの重要性は以前ほど大きくありません。しかし、コンパイラが関数の規模にかかわらず関数のすべての命令を認識できても、プロセッサは限られた数の命令しか認識できません。また、アウトオブオーダー実行ハードウェアは、コアが機能している限り機能するため、非常に多くの電力を消費します。さらに、x86 および x64 プロセッサは C11/C++11 メモリ モデルよりも強力なメモリ モデルを実装しており、パフォーマンスが向上する可能性のある特定の命令の順序変更が行われません。そのため、コンパイラベースの命令スケジューリングは、電力の限られるデバイスにとって、依然として非常に重要です。

いくつかのキーワードとコンパイラ スイッチが、パフォーマンスにマイナスまたはプラスの影響を及ぼすことがあります。そのため、これらのキーワードやスイッチを適切に使用し、コードの実行ができる限り短時間で行われ、適切な結果が得られるようにする必要があります。取り上げたい最適化はまだたくさんあります。次回のコラムをお待ちください。


Hadi Brais は、インド工科大学デリー校 (IITD) で博士号を取得した研究者で、次世代のメモリ テクノロジ向けのコンパイラ最適化について研究しています。彼は、自身の時間のほとんどを C/C++/C# のコード作成や CLR と CRT の詳細な調査に費やしています。彼のブログは hadibrais.wordpress.com (英語) で公開されています。連絡先は hadi.b@live.com (英語のみ) です。

この記事のレビューに協力してくれた技術スタッフの Jim Hogg (Microsoft Visual C++ チーム) に心より感謝いたします。