インテル® C++ コンパイラー 18.0 デベロッパー・ガイドおよびリファレンス

ベクトル化のプログラミングにおけるガイドライン

インテル® C++ コンパイラーのベクトル化機能は、自動的に SIMD (single-instruction multiple data) 処理を活用することを目的としています。コンパイラーに (自動ベクトル化のヒントやプラグマなどを使用して) 追加情報を提供することで、コンパイラーのベクトル化を助けることができます。

このオプションを使用すると、インテル製マイクロプロセッサーおよび互換マイクロプロセッサーの両方で、デフォルトの最適化レベルのベクトル化が有効になります。ベクトル化により呼び出されるライブラリー・ルーチンは、互換マイクロプロセッサーよりもインテル製マイクロプロセッサーにおいてより優れたパフォーマンスが得られる可能性があります。また、ベクトル化は、/arch (Windows*)、-m (Linux* および macOS*)、[Q]x などの特定のオプションによる影響を受けます。

最内ループのベクトル化のガイドライン

最内ループ本体をベクトル化するガイドラインを次に示します。

好ましいもの

避けるべきもの

ベクトル化可能なコードに変えるには、ループを変更しなければならない場合がよくあります。ベクトル化に必要な変更のみ行い、次のような一般的な変更は避けるべきです。

制約事項

次の制約事項に注意してください。ベクトル化は、ハードウェアとソースコードのスタイルという 2 つの主な要因により制約されます。

要因

説明

ハードウェア

コンパイラーは、それを動かすハードウェアからいくつか制約を受けます。インテル® ストリーミング SIMD 拡張命令 (インテル® SSE) を実行する場合、ベクトルメモリー演算は、16 バイトでアライメントしたメモリー参照が優先するため、アクセスストライドが 1 に制限されます。つまり、コンパイラーは、たとえループをベクトル化可能と抽象的に認識しても、別のターゲット・アーキテクチャーに対してはベクトル化をしないかもしれません。

ソースコードのスタイル

ソースコードの書き方によってはベクトル化の妨げになるときもあります。例えば、グローバルポインターを使用する場合に、2 つのメモリー参照が別々の場所で行われるかどうかをコンパイラーが判別できないという問題がよく起こります。このような場合、一部の変換処理は順序の並べ替えができなくなります。

コンパイラーによる自動ベクトル化を阻害する多くの要因はループ構造の書き方にあります。ループ本体にはキーワード、演算子、ポインター計算、データ参照、およびメモリー演算が含まれており、それらが互いに作用し合うためにループの動作が分かりづらくなります。

これらの制約を理解し、診断メッセージの読み方を知れば、そうした既知の制約条件を克服して効率良くベクトル化できるようプログラムを修正できます。

ベクトル化可能なコードを記述するためのガイドライン

ベクトル化可能なコードを記述するガイドラインを次に示します。

動的なアライメントによる最適化

動的なアライメントによる最適化は、ベクトル化されたコード、特に反復回数の多いループのパフォーマンスを向上します。この最適化を無効にすると、パフォーマンスは低下しますが、データ位置が常に同じになるため、結果のビット単位の再現性が向上する可能性があります。

動的なアライメントによる最適化を有効/無効にするには、Qopt-dynamic-align[-] (Windows*) または [no-]qopt-dynamic-align[-] (Linux*) オプションを指定します。

アライメントの合ったデータ構造の使用

データ構造のアライメントとは、他のオブジェクトと関連したデータ・オブジェクトの調整です。インテル® C++ コンパイラーは高速なメモリーアクセスのため、各変数が特定のアドレスで始まるようにアライメントします。アライメントされていないメモリーへのアクセスは、ハードウェアでこのようなアクセスをサポートしていない対象プロセッサーでは、パフォーマンスの大幅な低下を招きます。

アライメントとはメモリーアドレスのプロパティーの 1 つで、数値アドレスモジュロ 2 の累乗 (数値アドレスを 2 の累乗で除算した剰余) で表します。各データにはアドレスに加えて、サイズも設定されています。アドレスがデータのサイズに合わせてアライメントされている場合、そのデータは '自然にアライメントされてる' といいます。そうでない場合は 'アライメントされていない' といいます。例えば、8 バイトの浮動小数点データのアドレスが 8 にアライメントされている場合、このデータは自然にアライメントされています。

データ構造は、データを効率良く使用できるようにコンピューターに格納するための方法です。通常、慎重に選択されたデータ構造では、より効率的なアルゴリズムを使用できます。適切に設計されたデータ構造により、最小のリソース (実行時間とメモリー空間の両方) を使用して、さまざまな操作が実行可能になります。

struct MyData{ 
   short   Data1; 
   short   Data2; 
   short   Data3; 
};

上記のデータ構造の例では、short 型が 2 バイトのメモリーに格納されると、データ構造の各メンバーは 2 バイト境界にアライメントされます。Data1 はオフセット 0Data2 はオフセット 2Data3 はオフセット4 です。この構造のサイズは 6 バイトです。通常、構造の各メンバーの型にはアライメント要件があります。つまり、開発者が要求しない限り、あらかじめ決定された境界にアライメントされます。コンパイラーが次善のアライメントを採用した場合、開発者は declspec(align(base,offset)) 宣言を使用して、特定の base からの offset にデータ構造を割り当てられます。(0 <= offset < base で、base は 2 の累乗)。

プログラムの実行時間のほとんどが次のようなループで費やされている例について考えてみてだくさい。

double a[N], b[N]; 
  ... 
for (i = 0; i < N; i++){ a[i+1] = b[i] * 3; }

両方の配列の最初の要素が 16 バイト境界でアライメントされる場合、ベクトル化後に b からのアライメントの合っていない要素のロードか、a へのアライメントの合っていない要素のストアのいずれかが行われます。

この場合、反復を小さくしても意味がありません。

しかし、以下のようにアライメントを指定することで、ベクトル化後に 2 つのアライメントの合ったアクセスパターンがもたらされます (8 バイト・サイズの double の場合)。

アライメントを指定する例

__declspec(align(16, 8))  double a[N]; 
__declspec(align(16, 0))  double b[N]; 
/* または単に "align(16)" */

ポインター変数が使用されると、コンパイラーは通常、コンパイル時にアクセスパターンのアライメントを判断できません。次の単純な fill() 関数について考えます。

void fill(char *x) { 
  int i; 
  for (i = 0; i < 1024; i++){ x[i] = 1; } 
}

追加の情報がない限り、コンパイラーは上記のループでアクセスされるメモリー領域のアライメントについて仮定できません。この時点で、コンパイラーは unaligned データ移動命令を使用してこのループをベクトル化することを決定するか、またはここで示すランタイムのアライメントの最適化を生成します。

peel = x & 0x0f; 
if (peel != 0) {
  peel = 16 - peel; 
  /* ランタイム・ループ・ピーリング */
  for (i = 0; i < peel; i++) { x[i] = 1; } 
} 

/* アライメントされたアクセス */ 
for (i = peel; i < 1024; i++) { x[i] = 1; }

ランタイムの最適化は、コードサイズとテストがやや増えますが、一般にアライメントの合ったアクセスパターンを取得する効率的な方法を提供します。アクセスパターンが 16 バイト境界にアライメントされていることが保証されていれば、関数でヒント __assume_aligned(x, 16); を使用してその情報をコンパイラーに伝え、オーバーヘッドを回避することができます。

例えば、16 バイト境界でアライメントされたアドレス n2 のメモリーブロックが最適化可能な場合、_assume(n2%16==0) を使用できます。

注意

このヒントは注意して使用してください。アライメントされたデータ移動の誤った使用は、インテル® SSE の例外をもたらします。

配列構造体 (SoA) と構造体配列 (AoS) の使用

最も一般的でよく知られているデータ構造は、通常のインデックスでアクセス可能な、連続したデータ項目の集合を含む配列で、構造体配列 (AoS)、または配列構造体 (SoA) として整理することができます。AoS はカプセル化に最適ですが、ベクトル処理には適していません。

適切なデータ構造を選択することで、ベクトル化でより効率的な生成コードを得ることができます。この点を示す例として、3 次元ポイントのセットの rgb コンポーネントを格納する従来の構造体配列 (AoS) と、配列構造体 (SoA) を比較してみます。


AoS の例

AoS を使用した Point 構造体

struct Point{ 
   float r; 
   float g; 
   float b; 
}


SoA の例

SoA を使用した Points 構造体

struct Points{ 
   float* x; 
   float* y; 
   float* z; 
}


再配置された SoA の例

AoS 配置では、次の RGB ポイントに移動する前に 1 つの RGB ポイントのすべてのコンポーネントにアクセスするループは、フェッチされたキャッシュラインのすべての要素を使用するため、高い参照の局所性をもたらします。AoS 配置の短所は、そのようなループの各メモリー参照は非ユニットストライドをもたらすことです。これは一般に、ベクトル・パフォーマンスに不利に働きます。さらに、すべてのポイントの 1 つのコンポーネントにのみアクセスするループは、フェッチされたキャッシュラインの多くの要素が再利用されないため、より低い参照の局所性をもたらします。

逆に SoA 配置では、ユニットストライドのメモリー参照は効率的なベクトル化の恩恵がより受けられ、また 3 つのデータストリームのそれぞれで、高い参照の局所性をもたらします。したがって、ベクトル化コンパイラーでコンパイルする場合、SoA 配置を使用するアプリケーションは、AoS 配置ベースのアプリケーションよりパフォーマンスが優れています (ただし、このパフォーマンスの差は実装の早い段階では明白でないことがあります)。

ベクトル化を開始する前に、次の簡単な規則に従ってください。

例えば、3 次元座標を処理する場合、3 コンポーネント構造体の 1 つの配列 (AoS) を使用する代わりに、各コンポーネントに対する 3 つの異なる配列 (SoA) を使用します。最終的にベクトル化を阻むループ間の依存性を避けるために、3 コンポーネント構造体の 1 つの配列 (AoS) の代わりに、各コンポーネントに対する 3 つの異なる配列 (SoA) を使用します。AoS 配置を使用する場合、各反復は XYZ を計算することによって 1 つの結果を導きますが、4 つ目のコンポーネントが使用されていないため、最大でも SSE の 75%しか使用できません。場合によっては、1 つの要素 (25%) しか使用しないこともあります。SoA 配置を使用すると、各反復は SSE ユニットを 100% 使用し、XXXX、YYYY、および ZZZZ を計算することによって 4 つの結果を導きます。SoA 配置の短所は、コード量がおよそ 3 倍に増えることです。一方、コンパイラーは AoS 配置の SSE コードを全くベクトル化できない可能性があります。

オリジナルのデータレイアウトが AoS 形式であれば、性能に影響を及ぼすループの前に SoA への動的な変換を検討してください。ベクトル化されれば、それだけの価値はあります。

まとめ:

関連情報