次の方法で共有


コンパイラ

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

Hadi Brais

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

高度なプログラミング言語には、関数、条件付きステートメント、ループなど、驚くほど生産性が上る抽象プログラミング コンストラクトが多数用意されています。ただし、高度なプログラミング言語でコードを作成する場合のデメリットの 1 つは、パフォーマンスが大幅に低下するおそれがあることです。パフォーマンスを犠牲にすることなく、わかりやすく、メンテナンスしやすいコードを作成するのが理想です。このため、コンパイラがコードを自動的に最適化してパフォーマンスの向上を図ります。最近のコンパイラが行う最適化は非常に洗練されてきています。ループ、条件付きステートメント、再帰関数を変換します。コード ブロック全体を削除することもあります。ターゲットの命令セット アーキテクチャ (ISA) を利用して、高速処理されるコンパクトなコードに置き換えます。そのため、難解でメンテナンスが難しいコードを手作業で最適化するよりも、わかりやすいコードを作成することに集中するほうがはるかに賢明です。実際、手作業でコードを最適化してしまうと、コンパイラが追加の最適化や、より効率的な最適化を実行できなくなることがあります。

手作業でコードを最適化するよりも、より高速なアルゴリズムの使用、スレッド レベルの並列処理の組み込み、フレームワーク固有の機能 (ムーブ コンストラクター) の使用など、設計に関わる側面を検討します。

今回のテーマは Visual C++ コンパイラによる最適化です。最も重要な最適化手法と、最適化を行うためにコンパイラ側で決定を下さなければならない事項について説明します。コードを手作業で最適化する方法を説明することではなく、コンパイラによる自動的なコードの最適化を信頼する根拠を示すことが目的です。Visual C++ コンパイラによって行われる最適化に関して、あらゆるトピックを詳しく説明するものではありません。ただし、プログラマが本当に知っておく必要がある最適化手法と、それらの手法を適用するようコンパイラに指示する方法を具体的に紹介します。

現時点では、どのようなコンパイラでも対応できない重要な最適化手法もあります。それは、非効率なアルゴリズムを効率の良いアルゴリズムに置き換えること、またはデータ構造のレイアウトを変更して局所性を高めることなどです。ただし、このような最適化手法については今回取り上げません。

コンパイラによる最適化とは

最適化とは、コードの 1 つ以上の特性を改善する目的で、機能的に同等な別のコードに置き換えるプロセスです。最も重要な特性はコードの速度とサイズの 2 つです。ほかにも、コード実行に必要な電力量、コードのコンパイルにかかる時間、JIT (Just-in-Time) コンパイルが必要なコードの場合は JIT コンパイルにかかる時間といった特性もあります。

コンパイラは、コードの最適化に使用する手法に関して改良が続けられていますが、完璧ではありません。それでも、手作業でのプログラムの調整に時間を割くよりは、コンパイラに用意されている機能を利用して、コンパイラにコードを調整させるほうが、通常、はるかに生産的です。

コンパイラによるコードの最適化の効率を向上するには、次の 4 とおりの方法があります。

  1. わかりやすく、メンテナンスしやすいコードを作成する。Visual C++ のオブジェクト指向機能をパフォーマンスの敵だとは考えません。Visual C++ の最新バージョンでは、そのようなオーバーヘッドを最小限に抑えるか、ときには、完全に取り除くことができます。
  2. コンパイラ ディレクティブを使用する。たとえば、既定よりも高速な関数呼び出し規約を使用するようにコンパイラに指示します。
  3. コンパイラの組み込み関数を使用する。組み込み関数は、コンパイラによって自動的に実装される特殊な関数です。コンパイラは組み込み関数の細かいしくみを把握しているため、関数呼び出しを、ターゲットの ISA を非常に効率よく利用する命令シーケンスに置き換えます。現在、Microsoft .NET Framework は組み込み関数をサポートしていないため、どのマネージ言語でも組み込み関数はサポートされません。ただし、Visual C++ ではこの機能が大々的にサポートされています。組み込み関数を使用すると、コードのパフォーマンスは向上しますが、読みやすさと移植の容易さは低下します。
  4. ガイド付き最適化のプロファイル (PGO) を使用する。この手法により、コードの実行時の動作についてさらに詳しい情報がコンパイラに伝わり、それに従って最適化されます。

今回の目的は、わかりやすくても非効率なコードに対して最適化を実行する (1 つ目の方法を適用) 具体例を使って、コンパイラを信用する根拠を示すことです。また、ガイド付き最適化のプロファイルについて簡単に紹介し、コードの一部を微調整できるコンパイラ ディレクティブについてもいくつか取り上げます。

定数畳み込みなどの単純な変換から、命令スケジューリングなどの非常に高度な変換まで、さまざまなコンパイラ最適化手法があります。ただし、今回はパフォーマンスを大幅に (10% 以上のレベルで) 向上し、コードのサイズを削減できる最も重要な最適化に絞って説明します。具体的には、関数のインライン化、COMDAT 最適化、およびループの最適化です。ここからは関数のインライン化と COMDAT 最適化について説明し、その後、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 は、バックエンドによって積極的にさまざまな最適化を実行できるようにします (/GL と /O1 または /O2、および /Gw コンパイラ スイッチを併せて指定し、/OPT:REF と /OPT:ICF リンカー スイッチを指定)。今回は、関数のインライン化と COMDAT 最適化のみを取り上げます。すべての LTCG 最適化については、LTCG のドキュメントを参照してください。リンカーによって LTCG を実行できるのは、ネイティブのオブジェクト ファイル、ネイティブとマネージの混在したオブジェクト ファイル、純粋マネージ オブジェクト ファイル、安全なオブジェクト ファイル、安全な .netmodule です。

ここでは、2 つのソース ファイル (source1.c と source2.c) と 1 つのヘッダー ファイル (source2.h) から成るプログラムをビルドします。source1.c のファイルと source2.c のファイルを図 1図 2 にそれぞれ示します。ヘッダー ファイルは、source2.c のすべての関数のプロトタイプを保持していますが、とてもシンプルなのでここには記載しません。

図 1 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));
}

図 2 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 乗して返す square 関数と、このプログラムの main 関数です。main 関数は、square 関数と、isPrime を除く source2.c のすべての関数を呼び出します。source2.c ファイルには、5 つの関数があります。指定された整数のキューブを返す cube 関数、1 ~ 指定された整数までの数の合計を返す sum 関数、1 ~ 指定された整数までのキューブの合計を返す sumOfcubes 関数、指定された整数が素数かどうかを判断する isPrime 関数、x 番目の素数を返す getPrime 関数です。エラー チェックは今回関心のあるトピックではないので省略しています。

このコードはシンプルですが、便利です。シンプルな計算を実行する関数がいくつかあります。そのうちいくつかは、ループ向けにシンプルにしておく必要があります。getPrime 関数は while ループを含み、このループ内でさらにループを含む isPrime 関数を呼び出すため、最も複雑です。このコードを使用して、最も重要なコンパイラの最適化の 1 つである関数のインライン化と、その他のいくつかの最適化をデモします。

3 種類の構成でコードをビルドし、結果を検証して、コンパイラによってどのように変換されたかを確認します。つまり、生成されたアセンブリ コードを検証するためのアセンブラー出力ファイル (/FA[s] コンパイラ スイッチにより生成)、実行された COMDAT 最適化を特定するためのマップ ファイル (/MAP リンカー スイッチにより生成) が必要です (どの COMDAT 最適化が実行されたかは、/verbose:icf スイッチと /verbose:ref スイッチを使用することで、リンカーでも報告できます)。したがって、これから説明するどの構成でも、これらのスイッチは必ず指定します。また、生成されるコードを検証しやすくするため、C コンパイラ (/TC) も使用します。ただし、ここで説明する内容はすべて、C++ コードにも当てはまります。

デバッグ構成

デバッグ構成を主に使用します。これは、/GL スイッチを指定せずに /Od コンパイラ スイッチを指定すると、バックエンドの最適化がすべて無効になるためです。この構成でコードをビルドすると、生成されるオブジェクト ファイルには、ソース コードに正確に一致するバイナリ コードが含まれます。これは、生成されるアセンブラー出力ファイルとマップ ファイルを検証して、確認できます。この構成は、Visual Studio のデバッグ構成に相当します。

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

この構成は Visual Studio のリリース構成に似ています。最適化は有効 (/O1、/O2、または /Ox コンパイル スイッチを指定) ですが、/GL コンパイル スイッチは指定されません。この構成で生成されるオブジェクト ファイルには、最適化されたバイナリ コードが含まれます。ただし、プログラム全体レベルでの最適化は実行されません。

生成される source1.c のアセンブリ リスト ファイルを検証すると、2 種類の最適化が行われているのがわかります。1 つは、square 関数の最初の呼び出し (図 1 の square(n)) が、コンパイル時に計算を評価して、完全に削除されていることです。これがどのように行われたかを説明します。まず、コンパイラによって、square 関数は小さいのでインライン化すべきと判断されました。関数のインライン化が済むと、ローカル変数 n の値は確定していて、代入ステートメントから関数呼び出しまでの間で変化しないと判断されます。そこで、乗算を実行しても問題ないと判断されて、結果 (25) に置き換えられます。もう 1 つの最適化では、square 関数の 2 番目の呼び出し、square(m) が同様にインライン化されます。ただし、m の値はコンパイル時には確定しないので、計算結果を評価できず、実際にコードが生成されます。

ここで、source2.c のアセンブリ リスト ファイルを検証してみると、さらに興味深いことがわかります。sumOfCubes 内の cube 関数の呼び出しがインライン化されています。それによって、ループに対して多くの最適化を実行できるようになっています (「ループの最適化」で説明します)。さらに、isPrime 関数では SSE2 命令セットが使用され、sqrt 関数の呼び出し時に int から double に変換され、sqrt から値を返すときには double から int に変換されています。sqrt は、ループが開始される前に、1 度だけ呼び出されます。コンパイラに /arch スイッチが指定されていない場合は、x86 コンパイラでは既定で SSE2 が使用されます。現在採用されている x86 プロセッサの大半と、x86-64 プロセッサのすべては、SSE2 をサポートします。

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

LTCG のリリース構成は、Visual Studio のリリース構成とまったく同じです。この構成では、最適化が有効で、/GL コンパイラ スイッチが指定されます。このスイッチは、/O1 または /O2 を使用すると暗黙のうちに指定され、アセンブリ オブジェクト ファイルではなく、CIL オブジェクト ファイルを生成するようコンパイラに指示します。このようにすることで、前述のように、リンカーがコンパイラのバックエンドを呼び出して、WPO を実行します。ここで、いくつかの WPO 最適化を取り上げ、LTCG の持つ大きなメリットを紹介します。この構成で生成されるアセンブリ コード リストはオンラインで提供しています。

関数のインライン化が有効な限り (/Ob、最適化を要求するときは常に有効)、/GL スイッチが指定されると、/Gy コンパイラ スイッチ (後述) が指定されているかどうかにかかわらず、他の翻訳単位で定義されている関数のコンパイラによるインライン化が有効になります。/LTCG リンカー スイッチは省略可能で、リンカーに対してのみ指示を提供します。

source1.c のアセンブリ リスト ファイルを調べると、scanf_s 以外のすべての関数呼び出しがインライン化されているのがわかります。その結果、コンパイラは cube、sum、sumOfCubes の計算を実行することができました。isPrime 関数だけはインライン化されていません。ただし、getPrime 内で手動でインライン化されている場合は、やはりコンパイラによって main 内の getPrime がインライン化されます。

このように、関数のインライン化は、関数呼び出しを最適化するだけでなく、その結果として他のさまざまな最適化をコンパイラが実行できるようにするため、重要です。通常、関数をインライン化すると、パフォーマンスの向上と引き換えに、コードのサイズが増大します。この最適化を使いすぎると、コードの肥大化 (code bloat) と呼ばれる現象が発生します。呼び出しサイトごとに、コンパイラはコスト/メリット分析を実行して、関数をインライン化するかどうかを判断します。

インライン化の重要性を踏まえて、Visual C++ コンパイラはインライン化の制御に関する標準の規定をはるかに上回るサポートを提供します。auto_inline プラグマを使って、特定の範囲の関数をインライン化しないようにコンパイラに指示することもできれば、特定の関数やメソッドを __declspec(noinline) を使ってマークすることで、インライン化しないように指示することもできます。関数を inline キーワードでマークすると、その関数をインライン化するようにコンパイラに指示することができます (ただし、インライン化することで全体的に見るとマイナスになる場合は、この指示をコンパイラは無視します)。inline キーワードは、C99 において導入されたもので、C++ の初期バージョンから提供されています。マイクロソフト固有のキーワード __inline は、C のコードでも C++ のコードでも使用できます。これは、inline キーワードをサポートしていない古いバージョンの C を使うときに便利です。さらに、__forceinline キーワード (C および C++) を使うと、可能な場合は常に関数をインライン化するように、コンパイラを制御できます。最後に、これも重要な機能ですが、inline_recursion プラグマを使ってインライン化することで、再帰関数を特定の深さまでまたは無制限に展開するように、コンパイラに指示することもできます。ただし、現時点では、関数定義レベルではなく、呼び出しサイト レベルでインライン化の制御を可能にする機能はコンパイラにはありません。

インライン化は既定で有効になっていますが、/Ob0 スイッチはインライン化を完全に無効にします。このスイッチは、デバッグ時に使用します (Visual Studio のデバッグ構成では自動的に指定されます)。/Ob1 スイッチは、inline、__inline、または __forceinline でマークされている関数のみを、インライン化の対象として見なすようにコンパイラに指示します。/Ob2 スイッチは、/O[1|2|x] を指定すると有効になり、コンパイラにすべての関数をインライン化の対象と見なすよう指示します。個人的には、inline または __inline キーワードを使用する理由になるのは、/Ob1 スイッチを使ってインライン化を制御することだけだと思います。

コンパイラは、特定の状況では関数をインライン化できません。例を挙げると、仮想関数を仮想的に呼び出す場合です。仮想関数は、どの関数が呼び出されるかをコンパイラが認識できないためインライン化できません。また、関数の名前ではなく、関数へのポインターにより関数を呼び出す場合もインライン化できません。インライン化を有効にするには、このような状況を避けるようにします。このような状況を網羅したリストについては、MSDN のドキュメントを参照してください。

プログラム全体のレベルで適用すると有効性が高くなる最適化は、関数のインライン化だけではありません。実際、ほとんどの最適化は、プログラム全体のレベルで適用した方が有効です。ここからは COMDAT 最適化と呼ばれる、そのような種類の最適化について説明します。

既定では、翻訳単位をコンパイルすると、生成されるオブジェクト ファイルの 1 セクションに、すべてのコードが格納されます。リンカーは、そのセクションのレベルで処理を行います。つまり、リンカーはセクションの削除、統合、並び替えを実行できます。このため、リンカーでは、実行可能ファイルのサイズを大幅に (10% 以上のレベルで) 削減し、パフォーマンスを向上できる 3 種類の最適化を実行できません。実行できない 1 つは、参照されていない関数とグローバル変数を削除することです。2 つ目は、まったく同じ関数と定数のグローバル変数を折りたたむことです。3 つ目は、関数とグローバル変数の順序を変えて、実行パスが同じ関数と、それらと併せてアクセスされる変数を、メモリ内で物理的に近い位置に配置されるようにして、局所性を向上することです。

リンカーによるこれらの最適化を有効にするには、関数には /Gy (関数レベルのリンク) コンパイラ スイッチ、変数には /Gw (グローバル データの最適化) コンパイラ スイッチを指定してパッケージ化し、個別のセクションにまとめるようにコンパイラに指示します。このようなセクションを COMDAT と呼びます。また、特定のグローバル データ変数を __declspec( selectany) でマークして、その変数を COMDAT にパッケージ化するようにコンパイラに指示することもできます。そのうえで /OPT:REF リンカー スイッチを指定すると、参照されていない関数やグローバル変数がリンカーによって削除されます。また、/OPT:ICF スイッチを指定すると、同じ関数やグローバル定数の変数がリンカーによって折りたたまれます (ICF は Identical COMDAT Folding の略です)。/ORDER リンカー スイッチを使うと、生成されるイメージ内で COMDAT を特定の順序で配置するように、リンカーに指示できます。これらの最適化はすべて、リンカーによる最適化であり、/GL コンパイラ スイッチは不要です。/OPT:REF スイッチと /OPT:ICF スイッチは、当然、デバッグ時は無効にします。

できる限り LTCG を使用します。LTCG を使わない理由になるのは、生成されるオブジェクト ファイルとライブラリ ファイルを配布する場合のみです。これらのファイルに含まれるのは、アセンブリ コードではなく CIL コードでした。CIL コードを使用できるのは、そのコードを生成したのと同じバージョンのコンパイラー/リンカーのみです。したがって、これらのファイルを使用するには、開発者と同じバージョンのコンパイラーを用意する必要があるため、オブジェクト ファイルのユーザビリティが大幅に制限される可能性があります。この場合、コンパイラーのすべてのバージョン向けにオブジェクト ファイルを配布するのでない限り、コンパイル時のコード生成を使用します。ユーザビリティが制限されるほか、このようなオブジェクト ファイルは、対応するアセンブラ オブジェクト ファイルよりもサイズが数倍大きくなります。ただし、CIL オブジェクト ファイルがもたらす大きなメリット、つまり WPO を実現できることは忘れないでください。

ループの最適化

Visual C++ コンパイラでは、ループの最適化をいくつかサポートしますが、ここではループ アンローリング、自動ベクター化、ループ不変条件コード モーションの 3 種類のみを説明します。図 1 のコードを変更して、n ではなく m を sumOfCubes に渡すようにすると、コンパイラがパラメーターの値を特定できなくなるため、引数を処理するには関数をコンパイルする必要があります。結果の関数は大幅に最適化されますが、サイズがかなり大きくなるため、コンパイラによってインライン化されません。

/O1 スイッチを指定してコードをコンパイルすると、領域に関して最適化されたアセンブリ コードが生成されます。この場合、sumOfCubes 関数に対する最適化は行われません。/O2 スイッチを指定してコードをコンパイルすると、速度に関して最適化されたコードが生成されます。sumOfCubes 内のループがアンローリングされてベクター化されるため、コードのサイズは格段に大きくなりますが、速度は大幅に向上します。ベクター化は、cube 関数のインライン化なしには実現できないことを理解することが重要です。また、インライン化なしのループ アンローリングは、それほど有効ではありません。図 3 は、生成されるアセンブリ コードを簡単に図示したものです。このフローチャートは、x86 アーキテクチャでも x86-64 アーキテクチャでも変わりません。

sumOfCubes の制御フローチャート
図 3 sumOfCubes の制御フローチャート

図 3 では、緑のひし型がエントリー ポイントで、赤い長方形が終了ポイントです。青いひし型は、実行時に sumOfCubes 関数の一環として実行される条件を表しています。SSE4 がプロセッサによってサポートされていて、x が 8 以上の場合、SSE4 命令を使用して 4 つの乗算が同時に実行されます。複数の値に対して同じ演算を同時に実行するプロセスをベクター化と呼びます。また、コンパイラはループを 2 回アンローリングします。つまり、イテレーションごとにループ本体が 2 回繰り返されます。総合すると、イテレーションごとに、8 回乗算が実行されることになります。x が 8 未満の場合、従来の命令を使用して、残りの計算が実行されます。コンパイラによって、関数内に、1 つの終了ポイントではなく、個別のエピローグを含む終了ポイントが 3 つ生成されることに注意してください。これによって、ジャンプの回数が削減されます。

ループ アンローリングは、ループ内でループ本体を繰り返すプロセスです。したがって、ループ アンローリングの 1 回のイテレーションでループの複数のイテレーションが実行されます。これによって、パフォーマンスが改善されるのは、ループを制御する命令が実行される回数が少なくなるためです。おそらく、さらに重要なのは、ベクター化などのさまざまな最適化をコンパイラが実行できることです。アンローリングの欠点は、コード サイズとレジスターの負荷が増えることです。ただし、ループ本体によりますが、アンローリングによって 10% 以上のレベルで、パフォーマンスが向上する可能性があります。

x86 プロセッサと違って、すべての x86-64 プロセッサは SSE2 をサポートします。さらに、Intel や AMD の最新の x86-64 マイクロアーキテクチャでは、/arch スイッチを指定することで、AVX/AVX2 命令セットを利用できます。/arch:AVX2 を指定すると、コンパイラは FMA や BMI の命令セットも使用できます。

現在、Visual C++ コンパイラでは、ループ アンローリングを制御できません。ただし、テンプレートと __forceinline キーワードを併用することで、この手法のエミュレーションを実行できます。no_vector オプションを指定して loop プラグマを使用することで、特定のループに対する自動ベクター化を無効にできます。

生成されたアセンブリ コードを見ると、鋭い方なら、さらにコードを最適化できることに気付かれるでしょう。ただし、既に大きな成果が得られているので、コンパイラはさらにコードを分析して細かい最適化を適用することはしません。

ループがアンローリングされている関数は、someOfCubes だけではありません。コードを変更して n ではなく m を sum 関数に渡すようにした場合、コンパイラは 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;
}

このコードの唯一の違いは、イテレーションごとにインクリメントされ、出力される変数が 1 つ追加されていることです。count 変数のインクリメントをループの外に出すことで、このコードを最適化できることは、すぐにわかります。つまり、x を count 変数に代入するだけです。この最適化を、ループ不変条件コード モーションと呼びます。「ループ不変条件」という表現は、コードがループ ヘッダー内のどの式にも依存しない場合にしか、この手法が機能しないことを示しています。

ここで、気を付けなければならないことがあります。この最適化を手作業で適用した場合、結果のコードのパフォーマンスは、特定の条件では低下する可能性があります。理由はおわかりになるでしょうか。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 の値を 1 つ以上指定できます。これらは、/Og、/Os、/Ot、/Oy にそれぞれ対応します。

空のリストに off パラメーターを指定すると、指定されているコンパイラ スイッチが無視されて、対応する最適化がすべて無効になります。空のリストに on パラメーターを指定すると、指定されたコンパイラ スイッチが有効になります。

/Og スイッチはグローバル最適化を可能にします。グローバル最適化は、最適化対象の関数のみを考慮し、その関数が呼び出す関数は考慮しないで実行できる最適化です。LTCG が有効な場合、/Og を指定すると WPO が可能になります。

optimize プラグマは、関数によって違う最適化を行う場合に便利です。たとえば、ある関数では領域に関する最適化を行い、別の関数では速度に関する最適化を行う場合などです。ただし、そのレベルの制御が本当に必要な場合は、ガイド付き最適化のプロファイル (PGO) を検討します。PGO とは、インストルメント化されたバージョンのコードの実行中に記録される動作情報を保持するプロファイルを使って、コードの最適化を制御するプロセスです。コンパイラは、このプロファイルを使って、コードの最適化方法に関してより有効な判断を下すことができます。Visual Studio には、この手法をネイティブ コードとマネージ コードに適用するために必要なツールが用意されています。

.NET での最適化

.NET のコンパイル モデルではリンカーが関与しません。ただし、ソース コード コンパイラ (C# コンパイラ) と JIT コンパイラがあります。ソース コード コンパイラでは、ごく簡単な最適化しか実行されません。たとえば、関数のインライン化や、ループの最適化は実行されません。このような高度な最適化は、JIT コンパイラによって処理されます。4.5 までの .NET Framework のすべてのバージョンに付属している JIT コンパイラは、SIMD 命令をサポートしていませんが、.NET Framework 4.5.1 以降に付属の JIT コンパイラ "RyuJIT" は、SIMD をサポートします。

最適化機能の面での、RyuJIT と Visual C++ の違いは何でしょう。RyuJIT では実行時に処理を行うため、Visual C++ が実行できない最適化を実行できます。たとえば、RyuJIT なら、実行時に if ステートメントの条件が今回のアプリケーション実行中は決して true にならないと判断して、その部分を最適化できます。また、RyuJIT は、RyuJIT が実行されているプロセッサの機能を利用できます。たとえば、プロセッサが SSE4.1 をサポートしている場合、JIT コンパイラなら、単に SSE4.1 命令を sumOfcubes 関数用に生成して、生成されたコードのサイズを格段に小さくすることはできます。しかし、JIT コンパイルにかかる時間がアプリケーションのパフォーマンスに与える影響が大きいため、コードの最適化に多くの時間を割くことはできません。一方、Visual C++ コンパイラは、JIT コンパイラよりもはるかに時間をかけて、他の最適化を適用できる機会を見つけ、その機会を利用できます。.NET Native という、マイクロソフトが開発したすばらしい新機能を使うと、Visual C++ のバックエンドを使って最適化した、自己完結型の実行可能ファイルにマネージ コードをコンパイルできます。現在、.NET Native は Windows Store アプリのみをサポートしています。

マネージ コードの最適化は、現在は、ある程度しか制御できません。C# および Visual Basic コンパイラでは、/optimize スイッチを使って最適化を有効または無効にする機能しかありません。JIT 最適化を制御するには、MethodImplOptions のオプションを指定して、メソッドに System.Runtime.Compiler­Services.MethodImpl 属性を適用できます。NoOptimization オプションは最適化を有効にし、NoInlining オプションは、メソッドがインライン化されることを防ぎます。AggressiveInlining (.NET 4.5) オプションは、メソッドをインライン化するための推奨事項 (ヒント以上の情報) を JIT コンパイラに提供します。

まとめ

今回説明した最適化手法はすべて、コードのパフォーマンスを 10% 以上のレベルで大幅に向上します。また、それらのどの手法も、Visual C++ コンパイラーによってサポートされています。これらの手法が重要なのは、これらを適用した場合、コンパイラーは他の最適化を実行できることです。ここには、Visual C++ によって実行されるコンパイラ最適化について、すべての情報を網羅していません。ただし、コンパイラの最適化機能の有用性をお伝えできていればさいわいです。Visual C++ では他にもさまざまな機能があります。第 2 部もぜひご期待ください。


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

この記事のレビューに協力してくれたマイクロソフト技術スタッフの Jim Hogg に心より感謝いたします。