Visual* C++、GCC*、インテル® C++ コンパイラーなど、広く利用されているコンパイラーには様々な最適化オプションが用意されています。この連載では、C/C++ ソースのコンパイル時にパフォーマンスに影響する幾つかの最適化オプションの使い方とその効果を説明します。
第6回 (最終回) ベクトル化の裏技集
これまでの 5回の記事でインテル® コンパイラーのベクトル化に関する内容を紹介してきました。最終回では、手動プロセッサー・ディスパッチとベクトル化に関するプラグマについて説明します。
インテル® コンパイラーの手動プロセッサー・ディスパッチ機能
コンパイラーのオプションを利用し対象とする SIMD 命令を選択することはできます(オプションについては第2回の記事で説明しました)。しかし、すでにアセンブリーや組み込み関数を利用して最適化されたソースがあり、改めて C/C++ に書き換えコンパイラーのベクトル化機能を利用したくないこともあります。
このような場合、実行時に CPU がどの命令セットを実行できるか判断し、実行するコードを切り替える必要があります。この判定方法については第1回の記事で説明しましたが、インテル® コンパイラーを利用する場合、より簡単に CPU を判定して実行するコードを切り替えることができます。
手動プロセッサー・ディスパッチを使用すると、cpu_specific キーワードと cpu_dispatch キーワードにより、実行時に IA-32 またはインテル® 64 アーキテクチャー・プロセッサーを認識でき、ターゲット・プロセッサーのみで実行するコードパスと、その他のプロセッサーで実行するコードパスを作成できます。2つのキーワードは、__declspec と共に次のように宣言します。
__declspec(cpu_dispatch(cpuid)) | cpu_dispatch では手動ディスパッチするプロセッサーの cpuid をカンマで区切って指定 |
__declspec(cpu_specific(cpuid)) | cpu_specific では特定のプロセッサーで実行するコードを記述する |
generic | SSE2 命令をサポートする互換プロセッサー |
pentium_iii | SSE 命令をサポートする Pentium® III プロセッサー |
pentium_4 | SSE2 命令をサポートする Pentium® 4 プロセッサー |
pentium_m | SSE2 命令をサポートする Pentium® M プロセッサー |
pentium_4_sse3 | SSE3 命令をサポートする Pentium® 4 プロセッサー |
core_2_duo_ssse3 | SSSE3 命令をサポートする Core™2 プロセッサー |
core_2_duo_sse4_1 | SSE4.1 命令をサポートする Core™2 プロセッサー |
core_i7_sse4_2 | SSE4.2 命令をサポートする Core® i7 プロセッサー |
core_aes_pclmulqdq | AES 命令をサポートする Xeon® プロセッサー |
future_cpu_16 | AVX 命令をサポートする第2世代 Core® i7 プロセッサー |
次のサンプルコードでは、generic、SSE3 そして AVX で実行できるプロセッサーを判定しています。cpuid は大文字と小文字の区別はありません。__declspec(cpu_dispatch()) を宣言した関数本体は空でなければなりません。これはスタブ (本体が空の関数) と呼ばれます。また、対象とする関数名(この例では func)は、すべて同じでなければいけません。
このコードをコンパイルする場合、インテル® コンパイラーの /Qx や /Qax(Windows)のベクトル化オプション (Linux では、-x と –ax) を指定しても無視されます。生成されたバイナリーを実行すると、そのプロセッサーが実行できる SIMD 命令が printf 文で表示されます。実際には、printf 文がある部分にすでにある最適化されたコードを呼び出す処理を記述します。
__declspec(cpu_dispatch(generic, pentium_4_sse3, future_cpu_16)) void func(int num){ // ここにコードを書いてはいけません } __declspec(cpu_specific(generic)) void func(int num){ printf("This is generic code %d\n", num); } __declspec(cpu_specific(pentium_4_sse3)) void func(int num){ printf("This is SSE3 code %d\n", num); } __declspec(cpu_specific(future_cpu_16)) void func(int num){ printf("This is AVX code %d\n", num); } int main(){ func(1); }
手動プロセッサー・ディスパッチは、インライン展開を無効にすることがあります。ほとんどの場合、生成される実行ファイルのサイズは大きくなり、関数呼び出しのオーバーヘッドが増加することがあります。また、手動プロセッサー・ディスパッチでは、互換プロセッサーが実行できる命令セットを正確に認識できないことがあります。リリース前に、利用するすべてのターゲット・プラットフォームでアプリケーションをテストすることを推奨します。
ベクトル化に関連するインテル® コンパイラーのプラグマ
インテル® コンパイラーは様々なコンパイラー・プラグマをサポートしますが、ここでベクトル化に影響する固有のプラグマをいくつか紹介します。
#pragma distribute_point大きなループを小さなループに分割するようにコンパイラーに指示します。レジスターの使用率が高いためにベクトル化のような最適化を実行できない場合に役立ちます。
例えば次のような例の場合、最新のインテル® コンパイラー バージョン 12 では、このループをベクトル化できますが。ループ中の 3 つの行には依存性があり、コンパイラーの最適化を妨げています。この場合、for ループの直前に #pragma distribute_point を追加することで、コンパイラーが自動的にループを分割することを許可します。「PARTIAL LOOP がベクトル化されました」という最適化レポートが得られるでしょう。この例はループを分割した方が若干高速になります。
for (int i=1; i<MAX; i++) { b[i] = a[i] + 1; c[i] = a[i] + b[i]; // 直前のb[i] = … と依存性あり d[i] = c[i] + 1; // 直前のc[i] = … と依存性あり }#pragma ivdep
第3回の記事で紹介しましたが、ベクトル化の依存性をすべて無視することをコンパイラーに指示する pragma です。エリアスに限らず、データ依存などすべての依存性を無視します。
#pragma loop_countこのプラグマはコンパイラーに for ループの反復数を通知することができます。for ループの終了条件が変数で判断されている場合、コンパイラーはループの最大反復数が判断できずベクトル化に関する最適化を最大限に行えない場合があります。loop_count の書式は次のように指定できます。
#pragma loop_count(n)
コンパイラーは次のループに対し n で指定された回数分の繰り返しを試みますが、反復回数は保証されません。
#pragma loop_count(n1,[n2]…)
コンパイラーは次のループを n1 または n2 で指定された回数分か、または指定されていない回数分の繰り返しを試みます。この動作により、ループアンロールにいくらか柔軟性が生まれます。反復数は保証されません。
#pragma loop_count min(n), max(n), avg(n)
重複がないように任意の順序で 1 つまたは複数の値を指定します。コンパイラーは指定された最大、最小、平均 (n) 回数で次のループが繰り返されるようにします。min と max では指定された反復回数が保証されます。
最新のインテル® コンパイラー バージョン 12 では、このプラグマが無くても Alternate ループ作成することで最大反復数が不明なループをベクトル化できますが、loop_count を指定することでより柔軟なコードを生成することができます。
#pragma novectornovector プラグマは、ループのベクトル化が可能な場合でもループをベクトル化しないことを指示します。ベクトル化によりパフォーマンスに悪影響があるような場合、novector プラグマをソーステキストで使用し、ループのベクトル化を無効にします。この動作は、次に説明する vector always プラグマと対照的です。
例えば次の例のように (max – ct) の値が小さすぎる場合、ベクトル化は非効率です。ループの前に novector プラグマを挿入することで、ベクトル化を無効にできます。
void foo(int a[], int b[], int ct, int max){ int j; // #pragma novector // ベクトル化を無効にする for(j=ct; j<max; j++) { a[j]=a[j]+b[j]; } }#pragma vector キーワード
このプラグマは always/aligned/assert/unaligned/nontemporal/temporal などのキーワードを持ち、キーワードに従ってベクトル化を行うようコンパイラーに指示します。
aligned/unaligned キーワード
このプラグマで aligned もしくは unaligned キーワードが使用された場合、すべての配列参照に対して aligned/unaligned データ移動命令を使用してループをベクトル化することを示します。
always キーワード
always キーワードが使用されると、プラグマは後に続くループのベクトル化を制御します。assert を指定すると、コンパイラーはエラーレベルのアサーション・テストを生成し、コンパイラーの効率性ヒューリスティックによってループのベクトル化が不可能であることが示された旨のメッセージを表示します。
nontemporal/temporal キーワード
nontemporal と temporal キーワードは、IA-32 およびインテル® 64 アーキテクチャー・ベースのシステムで、レジスターのストア方法 (ストリーミングか非ストリーミング)を制御します。デフォルトでは、コンパイラーはそれぞれの変数にストリーミング・ストアを使用するかどうかを自動で決定します。
ストリーミング・ストアは、キャッシュにデータを保存せず直接メモリーに書き出しを行います。書き込んだデータでキャッシュを汚染したくないような場合、非ストリーミング・ストアよりも、大幅なパフォーマンスの向上をもたらす可能性があります。ただし、ストリーミング・ストアの使用を誤ると、パフォーマンスが大幅に低下します。
次の例では、配列 A と B に vector nontemporal を行うかどうかでどれくらいパフォーマンスに影響があるか検証することができます。ここでは、RDTSC 命令を利用してプロセッサーのスタンプ・カウンターを読んでクロック数を計測します。#pragma vector nontemporal (A, B) がコメントアウトされている場合、最初のループでの初期化はキャッシュとメモリーにデータが保存されるため、2番目のループではキャッシュからデータを読み込むことができます。プラグマを有効にすると、2番目のループは配列 A と B のデータをメモリーから読み込まなければいけません。
#define MAX 1024 double A[MAX]; double B[MAX], C[MAX]; main(){ int i, n = MAX; register int high1, low1, high2, low2; // RDTSCでスタンプ・カウンターを読み込み _asm{ rdtsc mov high1, EDX mov low1, EAX } //#pragma vector nontemporal (A, B) for (i=0; i<n; i++){ A[i] = 1; B[i] = i; } for (i=0; i<n; i++) C[i] = A[i] + B[i]; // RDTSCでスタンプ・カウンターを読み込み。直前に読み込んだカウンターとの差が上記の処理にかかったクロックサイクルとなる _asm{ rdtsc mov high2, EDX mov low2, EAX } printf("RDTSC clocks [%d%d]\n",(high2-high1), (low2-low1)); printf("%f %f\n", A[MAX-1], B[MAX-1]); }
上記のコードを第1世代の Core i7(Nehalem)で実行すると、次のような差が計測できました。
#pragma vector nontemporal (A, B) なし 19443 クロックサイクル#pragma vector nontemporal (A, B) あり 24048 クロックサイクル
この他にもいくつかのプラグマが用意されています。コンパイラーのマニュアルの「コンパイラー・リファレンス」→「インテル® C++ コンパイラー・プラグマ」を参照してください。
6回に渡ってインテル® コンパイラーのベクトル化に関する記事を紹介しました。ぜひ皆さんの開発に役立ててもらえればと思います。今後ベクトル化のさらに詳しい記事や、他の分野の最適化の関する記事も予定していますので楽しみにお待ちください。