コンパイラー最適化入門: 第5回 明示的にベクトル化されたコードを記述する

インテル® DPC++/C++ コンパイラーインテル® Fortran コンパイラー
Visual* C++、GCC*、インテル® C++ コンパイラーなど、広く利用されているコンパイラーには様々な最適化オプションが用意されています。この連載では、C/C++ ソースのコンパイル時にパフォーマンスに影響する幾つかの最適化オプションの使い方とその効果を説明します。

第1回 第2回 第3回 第4回 第5回 最終回

第5回 明示的にベクトル化されたコードを記述する

ソースコードを書いているときに、「ここはベクトル化しないと性能目標を達成できないな」、とオボロげに想像できることもあります。現状ではソースを記述してベクトル化レポートや、ガイド機能のオプションを利用してコンパイルしてみるまで、コードがベクトル化できるかどうかは分かりません。今回は明示的にベクトル化する、もしくはベクトル化の可能性の高いコードを記述する方法を考えてみましょう。

次の C 言語で記述されたコードを、いくつかの方法で書き換えてみます。この C コードはコンパイラーがベクトル化可能ですが、解りやすくするため簡単なコードにしました。

#define N 128
float A[N];     // 4 * 128 = 512 バイト

int main(){
int i;

     for(i=0; i<N; i++)
          A[i] += 0.5;
}
// リスト5-1 C言語のオリジナル・コード
① インライン・アセンブリー

コンパイラーによる最適化は、チューニングされたハンドアセンブリー・コードには及びません。プロセッサーの仕組みを理解し、記述されたアセンブリー・コードは最も高速であるといえるでしょう。もちろんその際にベクトル演算を行う命令を利用すれば、ベクトル・コードを生成できます。

次のコード例は、C のソース中にインライン・アセンブリーによってベクトル加算を行う命令を記述しています。インライン・アセンブリーでは計算に利用するレジスター(例では xmm0 など)を自身で管理しなければいけません。誤った使い方をすると、正しい結果を得られないばかりかプログラムを異常終了させることになります。このコードは、32 ビットコンパイラー環境向けのサンプルです(64 ビットコンパイラー向けには少し書き換える必要があります)。

#include <pmmintrin.h>
#define N 128
int main() {
__m128 simd1[N/4]; // float 4個分の配列を32個定義
__m128 mask = _mm_set_ps(0.5, 0.5, 0.5, 0.5); // 足し算用の定数

  _asm { // アセンブリー領域の始まり
  xor     eax, eax                  // インデックスを初期化
Loop:
  movaps         xmm0, XMMWORD PTR[simd1+eax*4]    // simd1をリード
  addps          xmm0, mask                        // mask 値を加算
  movaps         XMMWORD PTR[simd1+eax*4], xmm0    // simd1へライト
  add     eax, 4                     // インデックスを4つ進める
  cmp     eax, 128                   // 128に達したか?
  jb      Loop                       // まだなら繰り返し
  } // アセンブリー領域の終わり
}
// リスト5-2 インライン・アセンブリーの例

__m128 は、4 つの float を格納する 128 ビットデータ型です。128 ビット変数にデータをセットするには、直接代入できないため _mm_set_ps() 組み込み関数を利用しています。これらは次に説明する組み込み関数で提供される機能です。simd1 をアクセスするアドレス計算は、[simd1+eax*4] となっており、インデックスを 4 バイトずつ増加し、オフセットに 4 を掛けて 16 バイト単位で増えていきます。

アセンブリーを利用するとある時点では最も高速なコードを記述できますが、プロセッサーの世代が進み新しい命令がサポートされた場合、その命令を利用して記述し直さなければ新しい機能を利用することはできません。高速ではありますが、一般的にはお勧めできない方法です。

ここで 1 つ、うれしい機能を紹介します。ご存知のようにアセンブリーのニーモニック表記は、Windows ではマイクロソフト・アセンブラー(MASM)スタイル、Linux や Mac OS X では GCC スタイルで、C/C++ コードは共有できてもアセンブリー・コードには互換性がありません。インテル® コンパイラーの、-use-msasm (Linux 、Mac OS X) を利用すると、Linux やMac OS X 環境で MASM スタイルのアセンブリーを利用できます。このオプションを利用すると、前述のサンプルコードは Linux や Mac OS X 環境でもそのままコンパイルできます(ただし 32 ビットコンパイラーのみ)。

② 組み込み関数

組み込み関数とは、コンパイラーが提供する SIMD 命令に対応した関数形式の命令群です。それぞれの SIMD アセンブリー命令に対応した組み込み関数が用意されています。組み込み関数は 「_mm_命令_サフィックス」 という形式で記述でき、アセンブリー・ニーモニック addps (単精度ベクトル加算)は、_mm_add_ps と記述します。前述のアセンブリー・コードは、組み込み関数を利用すると次のようになります。

#include <pmmintrin.h>
#define N 128                 // 128*4 = 512 バイト

int main() {
int i = 0;
__m128 simd1[N/4];       // float 4個分の配列を32個定義
__m128 mask = _mm_set_ps(0.5, 0.5, 0.5, 0.5);

     for(i=0; i<N/4; i++){
          simd1[i] = _mm_add_ps(simd1[i], mask);
     }
}
// リスト5-3 組み込み関数の例

_mm_add_ps(simd1[i], mask) は、引数として渡される 2 つの 128 ビットデータのパックド単精度加算を行い、その結果を戻り値として返します。

組み込み関数を利用する利点は、定義した 128 ビットデータを引数として渡し、戻り値もそのまま受け取れるため、データのロードとストアを明示的に記述する必要がなく、さらに利用するレジスターも管理する必要がありません。単純に演算型を対応する組み込み関数に置き換えていくことができます。SIMD 演算を行う場合、アセンブリーに代わって広く利用されています。

インテル® コンパイラーを利用する場合、組み込み関数を利用するには対応する命令セットのプロトタイプ宣言を持つヘッダー・ファイルをインクルードしなければいけません。上位の命令セット用のヘッダー・ファイルは、下位の命令セット用ヘッダーを包括しています。

xmmintrin.h        SSE 命令セット対応
emmintrin.h        SSE2 命令セット対応
pmmintrin.h        SSE3 命令セット対応
tmmintrin.h         SSSE3 命令セット対応
smmintrin.h        メディアアクセラレーター命令セット対応(SSE4.1)
nmmintrin.h        文字列およびテキスト命令セット対応(SSE4.2)
wmmintrin.h        AES 命令セット対応
immintrin.h         AVX 命令セット対応

③ C++ SIMD クラス・ライブラリー

C++ SIMD クラス・ライブラリーは、インテル® コンパイラーで提供される SIMD 演算クラスです。このクラス・ライブラリーには、MMX 命令(ivec.h)、SSE 命令(fvec.h)、そして SSE2 命令(dvec.h)の 3 つの基本クラスが用意されており、整数ベクトル(Ivec)クラスと、浮動小数点ベクトル(Fvec)クラスが利用できます。前述の組み込み関数の例は、SIMD クラス・ライブラリーを利用すると、次のように記述できます。

#include <fvec.h>      // float 型データ要素向けヘッダー
#define N 128

int main(){
F32vec4 simd1[N/4];  // F32Vec4 は float 型を 4 要素定義。それを32個。
F32vec4 mask(0.5, 0.5, 0.5, 0.5); // 初期化

     for(int i=0; i<N/4; i++)
          simd1[i] += mask;
}
// リスト5-4 C++クラス・ライブラリーの例

ベクトル・オブジェクトのすべての変数は、前述の __m128 データ型に暗黙的に変換されます。つまり __m128 データ型で利用可能なデータ変換用の組み込み関数は、そのまま利用でき、_m128 simd = Vec1 & Vec2 (両者とも F32vec4 オブジェクト変数)、といった操作もできます。

C++ で提供される valarray 型を利用するとより簡単に記述できますが、valarray 型変数では代入と四則演算以外のオペレーターはサポートされません。

#include <valarray>   // valarray向けヘッダー
#define N 128

int main(){
valarray<float>  simd1(N);   // float 型を 128 要素定義

     simd1 += 0.5;
}
// リスト5-5 valarray の利用例

SIMD クラス・ライブラリーでは、代入や四則演算のほか、論理演算、比較演算、条件付き比較演算、マスク移動など組み込み関数で可能なほぼすべての操作が可能です。詳細については、インテル® C++ コンパイラーのユーザーマニュアルの「コンパイラー・リファレンス」から「インテル® C++ クラス・ライブラリー」を参照してください。

④  配列表記

配列表記(Array Notation)は、インテル® C++ コンパイラーのバージョン 12 でサポートされた新しい言語拡張です。これらの機能を利用した場合、他のコンパイラーではコンパイルできなくなります。

配列表記は、Fortran 90 でサポートされている配列表記を C/C++ に実装したもので、プログラマーから見るとループなしに配列アクセスができソースがシンプルになりますが、コンパイラーから見るとエイリアスや依存性の解析が大幅に軽減されベクトル化したコードを生成しやすくなっています。配列表記を利用する場合、次の表記を利用します。

配列名[ 開始位置 : 要素数 : ストライド]

A[0:10]         // 0 番目の要素から 10 個を処理
A[0:5:2]       // 0 番目の要素から 5 個を、2個づつスキップして処理
A[:]              // すべての要素を処理

配列表記は C++ クラス・ライブラリーと併用することができ、前述のクラス・ライブラリーのサンプルコード(リスト5-4)の for ループ 2 行は、”simd1[:] += 0.5;” 1 行に書き換えることができます。

配列表記を利用した次の行は、配列 A の 0 番目から 64 個の要素に 2 個ずつスキップして 0.5 を掛けています。

A[0:64:2] *= 0.5;             // 配列表記を利用したコード

これは次のC++コードと同等です。

for(int i=0; i<128; i+=2)
     A[i] *= 0.5;             // for ループを利用したコード

しかし、for ループを利用したコードは「非効率なためベクトル化できない」のに対し、配列表記を利用したコードはベクトル化できます。また、配列表記にはリダクション演算をサポートする関数プロトタイプが用意されており、例えば配列Aから最小要素を特定するには次のように記述できます。

__sec_reduce_min(A[:]);    // 最小値を返す

また、配列全体の要素を乗算し合計を求めるには、次のようになります。

__sec_reduce_mul(A[:]);    // すべての値を乗算

配列表記はベクトル化だけではなく、並列化においても有効です。通常の C/C++ コードからベクトル化されたコードを生成するには、O2 以上の最適化オプションが必要ですが、配列表記を利用すると O1 以上でベクトル化コードを生成することができます。配列表記に関する詳細は、インテル® C++ コンパイラーのユーザーマニュアルの「並列アプリケーションの作成」から「インテル® Cilk™ Plus の使用」=>「配列表記 (アレイ・ノーテーション) の拡張」を参照してください。

次回は、いよいよ最終回になります。これまでの説明でもれているベクトル化に関する裏技集を紹介します。

第1回 第2回 第3回 第4回 第5回 最終回

タイトルとURLをコピーしました