インテル® C++ コンパイラー 18.0 デベロッパー・ガイドおよびリファレンス
インテル® C++ コンパイラーのベクトル化機能は、自動的に SIMD (single-instruction multiple data) 処理を活用することを目的としています。コンパイラーに (自動ベクトル化のヒントやプラグマなどを使用して) 追加情報を提供することで、コンパイラーのベクトル化を助けることができます。
このオプションを使用すると、インテル製マイクロプロセッサーおよび互換マイクロプロセッサーの両方で、デフォルトの最適化レベルのベクトル化が有効になります。ベクトル化により呼び出されるライブラリー・ルーチンは、互換マイクロプロセッサーよりもインテル製マイクロプロセッサーにおいてより優れたパフォーマンスが得られる可能性があります。また、ベクトル化は、/arch (Windows*)、-m (Linux* および macOS*)、[Q]x などの特定のオプションによる影響を受けます。
最内ループ本体をベクトル化するガイドラインを次に示します。
好ましいもの
分かりやすいコード (単一の基本ブロック)
ベクトルデータのみ。すなわち、代入文の右辺には、配列と不変式を使用します。
代入式の左辺には配列参照を使用してもかまいません。
代入文のみ
避けるべきもの
関数呼び出し (数学ライブラリー関数の呼び出し以外)
ベクトル化できない演算 (ループがベクトル化できないか、演算がいくつかの命令によってエミュレートされている)
ベクトル化できる複数の型を同じループの中に混在させること (リソース活用の低下を招く)
データ依存性を持つループ出口条件 (ベクトル化の妨げとなる)
ベクトル化可能なコードに変えるには、ループを変更しなければならない場合がよくあります。ベクトル化に必要な変更のみ行い、次のような一般的な変更は避けるべきです。
ループアンロール (コンパイラーにより自動で行われる)
本体の中で複数の文で構成している 1 つのループを複数の単文ループに解体すること
次の制約事項に注意してください。ベクトル化は、ハードウェアとソースコードのスタイルという 2 つの主な要因により制約されます。
要因 |
説明 |
---|---|
ハードウェア |
コンパイラーは、それを動かすハードウェアからいくつか制約を受けます。インテル® ストリーミング SIMD 拡張命令 (インテル® SSE) を実行する場合、ベクトルメモリー演算は、16 バイトでアライメントしたメモリー参照が優先するため、アクセスストライドが 1 に制限されます。つまり、コンパイラーは、たとえループをベクトル化可能と抽象的に認識しても、別のターゲット・アーキテクチャーに対してはベクトル化をしないかもしれません。 |
ソースコードのスタイル |
ソースコードの書き方によってはベクトル化の妨げになるときもあります。例えば、グローバルポインターを使用する場合に、2 つのメモリー参照が別々の場所で行われるかどうかをコンパイラーが判別できないという問題がよく起こります。このような場合、一部の変換処理は順序の並べ替えができなくなります。 |
コンパイラーによる自動ベクトル化を阻害する多くの要因はループ構造の書き方にあります。ループ本体にはキーワード、演算子、ポインター計算、データ参照、およびメモリー演算が含まれており、それらが互いに作用し合うためにループの動作が分かりづらくなります。
これらの制約を理解し、診断メッセージの読み方を知れば、そうした既知の制約条件を克服して効率良くベクトル化できるようプログラムを修正できます。
ベクトル化可能なコードを記述するガイドラインを次に示します。
単純な for ループを使用します。複雑なループの終了条件は避けてください。反復の上限はループ内で不変でなければなりません。ループの入れ子構造の最内ループでは、反復の上限を、外側のループ・インデックスの関数に設定できます。
分かりやすいコードを記述します。switch、goto、return などの分岐文、関数呼び出し、マスク付きの代入として扱えない if 構造は避けてください。
ループ反復間の依存性は避けてください。少なくとも、リードアフターライトの依存性は避けてください。
ポインターを使用する代わりに配列表記を使用するようにしてください。特に C プログラムはポインターの使用についてほとんど制限がありません。エイリアスされたポインターは予期しない依存性を招きます。ヒントなしでは、多くの場合、コンパイラーはポインターを含むコードを安全にベクトル化できるかどうか判断できません。
可能な限り、別のカウンターをインクリメントして配列アドレスとして使用する代わりに、直接ループ・インデックスを配列添字に使用してください。
効率良くメモリーにアクセスします。
ユニットストライドを使用する内側ループを優先します。
間接アドレス指定を最小限にします。
インテル® SSE 命令を利用する場合、データを 16 バイト境界にアライメントする
最適なデータレイアウトを注意して選択します。多くのマルチメディア拡張命令セットはアライメントに影響を受けやすいものです。例えば、インテル® SSE のデータ移動命令は、メモリーで 16 バイト境界にアライメントされているデータでより効率的に実行できます。そのため、ベクトル化コンパイラーの成功は、(ループピーリングのような) コード再構築と併用して、適切なデータレイアウトを選択できるかどうかにも依存します。これにより、プログラム全体でアライメントの合ったメモリーアクセスがもたらされます。
アライメントの合ったデータ構造を使用します。データ構造のアライメントとは、他のオブジェクトと関連したデータ・オブジェクトの調整です。
__declspec(align) 宣言を使用できます。
このヒントは注意して使用してください。アライメントされたデータ移動の誤った使用は、インテル® SSE の例外をもたらします。
構造体配列 (AoS) の代わりに、配列構造体 (SoA) を使用します。配列は、通常のインデックスでアクセス可能な、連続したデータ項目の集合を含む最も一般的なデータ構造で、構造体配列 (AoS)、または配列構造体 (SoA) として整理することができます。AoS はカプセル化には最適ですが、ベクトル化処理の使用の妨げになることがあります。また、ベクトル化の生成コードをより効率的にするには、適切なデータ構造を選択します。
動的なアライメントによる最適化は、ベクトル化されたコード、特に反復回数の多いループのパフォーマンスを向上します。この最適化を無効にすると、パフォーマンスは低下しますが、データ位置が常に同じになるため、結果のビット単位の再現性が向上する可能性があります。
動的なアライメントによる最適化を有効/無効にするには、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 はオフセット 0、Data2 はオフセット 2、Data3 はオフセット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 の例外をもたらします。
最も一般的でよく知られているデータ構造は、通常のインデックスでアクセス可能な、連続したデータ項目の集合を含む配列で、構造体配列 (AoS)、または配列構造体 (SoA) として整理することができます。AoS はカプセル化に最適ですが、ベクトル処理には適していません。
適切なデータ構造を選択することで、ベクトル化でより効率的な生成コードを得ることができます。この点を示す例として、3 次元ポイントのセットの r、g、b コンポーネントを格納する従来の構造体配列 (AoS) と、配列構造体 (SoA) を比較してみます。
AoS を使用した Point 構造体 |
---|
struct Point{ float r; float g; float b; } |
SoA を使用した Points 構造体 |
---|
struct Points{ float* x; float* y; float* z; } |
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 への動的な変換を検討してください。ベクトル化されれば、それだけの価値はあります。
まとめ: