この記事は、インテル® ソフトウェア・ネットワークに掲載されている「Automatic Parallelization with Intel® Compilers」 (https://software.intel.com/content/www/us/en/develop/articles/automatic-parallelization-with-intel-compilers.html) の日本語参考訳です。
編集注記:
本記事は、2011 年 5 月 4 日に公開されたものを、加筆・修正したものです。
アプリケーションをマルチスレッド化してパフォーマンスを向上させるのは骨の折れる作業です。しかし、計算の多くが単純なループで実行されるアプリケーションでは、インテル® コンパイラーを使用してマルチスレッド・バージョンのアプリケーションを自動的に生成できます。
インテル® コンパイラーは、高レベルなコードの最適化のほか、自動並列化や OpenMP* (英語) を使用してスレッド化することもできます。自動並列化により、安全かつ効率良く並列に実行できるループを検知して、マルチスレッド・コードを生成できます。OpenMP* では、コンパイラーのディレクティブや C/C++ プラグマを使用してプログラマー自身で並列化を表現できます。
この記事は、「マルチスレッド・アプリケーションの開発のためのガイド」の一部で、インテル® プラットフォーム向けにマルチスレッド・アプリケーションを効率的に開発するための手法について説明します。
はじめに
インテル® C++ /Fortran コンパイラーには、ループのデータフローを解析して、どのループが安全で効率良く並列に実行できるかを見極める機能があります。自動並列化により、マルチコアシステムでの実行時間が短縮されたり、次のような処理をプログラマーが行う必要もなくなります。
- 並列実行に適したループの候補を探す
- データフロー解析を行って並列実行が正常かどうかを確認する
- コンパイラーのディレクティブを手動で追加する
プログラマーは、/Qparallel (Windows*) や -parallel (Linux*、Mac OS* X) をコンパイルオプションに追加するだけです。ただし、並列化を成功させるには次のセクションで説明する要件が前提となります。
以下に示す Fortran プログラムには、反復カウントの高いループが含まれています。
PROGRAM TEST PARAMETER (N=10000000) REAL A, C(N) DO I = 1, N A = 2 * I - 1 C(I) = SQRT(A) ENDDO PRINT*, N, C(1), C(N) END
データフローの解析では、ループにデータの依存関係がないことを確認します。コンパイラーは、実行時にスレッド間で、可能な限り反復を均等に分割するコードを生成します。スレッド数はデフォルトではプロセッサーのコア数ですが (インテル® ハイパースレッディング・テクノロジーが有効な場合は物理コアの数よりも多いことがあります)、OMP_NUM_THREADS 環境変数を使用して設定することもできます。
ループの並列化による速度向上は、処理量、スレッド間のロードバランス、スレッドの生成や同期によるオーバーヘッドなどに左右されますが、一般的にスレッド数に応じて直線的に向上するわけではありません。プログラム全体の速度向上は直列コード領域に対する並列コード領域の比率に依存します (「並列パフォーマンスの予測と測定」の説明を参照してください)。
アドバイス
コンパイラーでループを並列化するには、次の 3 つの要件が満たされなければなりません。
- 処理を前もって分割できるように、ループに入る前に反復回数が判明している (例えば、while ループは通常は並列化できない)。
- ループ内への分岐、またはループから外部への分岐があってはならない。
- ループの反復処理が独立している (最も重要)。つまり、反復が実行される順序に論理的に依存してはなりません。
ただし、例えば、同じデータが異なる順で追加された場合、蓄積される丸め誤差の変分はわずかです。配列を合計するようないくつかのケースでは、コンパイラーは単純な変換を行うことで明らかな依存関係を排除できることがあります。
また、ポインターや配列参照の潜在的なエイリアスも、安全な並列化にとってはよく知られている障害です。2 つのポインターが同じメモリーの場所を指す場合、両方のエイリアスが作成されます。例えば、関数の引数、ランタイムデータ、または複雑な計算の結果に依存する場合、コンパイラーは、2 つのポインターまたは配列参照が同じメモリーの場所を指しているかどうかを判断できません。ポインターあるいは配列参照が安全で反復が独立していることをコンパイラーが証明できなければ、コンパイラーはループを並列化しません (ただし、実行時にエイリアスを明示的にテストするための代替コードパスの生成が有益であると考えられる場合を除きます)。
ループを並列化しても安全で、潜在的なエイリアスを無視できることがわかっている場合は、C プラグマ (#pragma parallel) や Fortran ディレクティブ (!DIR$ PARALLEL) でコンパイラーに指示することができます。C では、ポインター宣言に restrict キーワードを使用して、/Qrestrict (Windows) や -restrict (Linux、Mac OS* X) コンパイラー・オプションを指定する方法もあります。ただし、コンパイラーは安全が確保されないループを並列化することはありません。
コンパイラーは、相対的に単純な構造のループのみを効率的に解析できます。例えば、コンパイラーは関数呼び出しに依存関係につながる副作用があるかどうか分からないため、外部関数の呼び出しを含むループのスレッド化の安全性を判断できません。
Fortran 90 の開発者は、PURE 属性を使用して、サブルーチンと関数に副作用がないことを表明できます。また、C や Fortran では、コンパイラー・オプションの /Qipo (Windows*) や -ipo (Linux* または、macOS*) を使用してプローシージャー間の最適化を適用する方法もあります。このオプションを使用すると、コンパイラーは呼び出された関数の副作用を解析できます。
並列に実行しても安全だとプログラマーがわかっていても、ループが複雑なためにコンパイラーによる自動並列化が行われない場合は、OpenMP* を使用すると良いでしょう。コンパイラーよりもプログラマーの方がコードをよく理解できるため、より粗い粒度で並列化を表現できます。一方、自動並列化は行列乗算などの入れ子のループに有効です。適度な粗粒度の並列化は、外部ループのスレッド化に起因し、ベクトル化やソフトウェアのパイプライン化を使用して内部ループをより細かい粒度の並列化に最適化できるようにします。
ループが並列化できる場合でも、常に並列化すべきであるとは限りません。コンパイラーは、しきい値パラメーターを使用したコストモデルで、ループを並列化するかどうかを決定します。このパラメーターは、コンパイラー・オプションの /Qpar-threshold[n] (Windows) と -par-threshold[n] (Linux) で調整できます。
n 値の範囲は 0 から 100 です。0 を指定すると安全なループは常に並列化され、100 を指定するとパフォーマンスの向上が確実であるループのみが並列化されます。n のデフォルト値は 100 です。しきい値を 99 に落とすだけで、並列化されるループの数が大幅に増えることもあります。pragma #parallel always (Fortran の場合は !DIR$ PARALLEL ALWAYS) を使用して個々のループのコストモデルを無効にできます。
/Qpar-report[n] (Windows) スイッチや -par-report[n] (Linux) スイッチ (n = 1 ~ 3) を指定すると、並列されたループが示されます。次のようなメッセージが表示されます。
test.f90(6) : (col. 0) remark: ループが自動並列化されました。
また、次の例のように並列化できなかったループとその理由もレポートされます。
serial loop: line 6 行 7 から行 8 までデータフロー依存関係があります。"c" により、並列モードでプログラムが正しく実行されないことがあります。
次のような例です。
void add (int k, float *a, float *b) { for (int i = 1; i < 10000; i++) a[i] = a[i+k] + b[i]; }
コンパイルコマンド ‘icl /c /Qparallel /Qpar-report3 add.cpp‘ では、次のようなメッセージが表示されます (最新のコンパイラーでは、/Qpar-report3 の代わりに、/Qopt-report3 /Qopt-report-phase=par を使用してください)。
procedure: add test.c(7): (col. 1) remark: 並列依存関係: ANTI の依存関係が a 行 7 と a 行 7 の間に仮定されました。 ... test.c(7): (col. 1) remark: 並列依存関係: FLOW の依存関係が a 行 7 と b 行 7 の間に仮定されました。
コンパイラーは k の値がわからないため、例えば、k が -1 の場合でも反復処理が互いに依存すると仮定します。プログラマーにはアプリケーションの知識 (k は常に 10000 よりも大きいなど) があるため、プラグマを挿入することでコンパイラーの指示を無効にできます。
void add (int k, float *a, float *b) { #pragma parallel for (int i = 1; i < 10000; i++) a[i] = a[i+k] + b[i]; }
次のように、ループが並列化されたことを示すメッセージが表示されます。
procedure: add test.c(6): (col. 1) remark: ループが自動並列化されました。ただし、誤った結果を招く可能性もあります。k の値が 10000 未満の場合にこの関数を呼ばないようにするのは、自身の責任で行ってください。
利用ガイド
計算が多用されるアプリケーションのカーネルには、コンパイラー・オプションの -parallel (Linux* または macOS*) や /Qparallel (Windows*) を使用してビルドすると良いでしょう。-par-report3 (Linux*) や /Qpar-report3 (Windows*) を有効にしてレポートを生成し、並列化できたループとできなかったループを確認します (最新のコンパイラーでは、/Qpar-report3 の代わりに、/Qopt-report3 /Qopt-report-phase=par を使用してください)。並列できなかったループについては、データの依存関係を排除したり、あるいはコンパイラーがエイリアスされたメモリー参照を一義化できるようにします。
/O3 でコンパイルすると、高レベルのループの最適化 (ループ融合など) が有効になり、自動並列化の助けとなります。/Qopt-report-phase:hlo を指定するとこのような追加の最適化がコンパイラーの最適化レポートに表示されます。有益な速度向上が得られたかどうかを確認するため、並列化を行った場合と行わない場合のパフォーマンスを常に測定するようにしてください。
/Qopenmp と /Qparallel の両方が同じコマンドラインで指定されている場合、コンパイラーは OpenMP* ディレクティブを含まないループのみを並列化しようとします。コンパイルとリンクを別々に行う場合、自動並列化を使用するときは必ず OpenMP* ランタイム・ライブラリーをリンクしてください。例えば、icl /Qparallel (Windows*) や ifort -parallel (Linux* または macOS*) を用いて、リンク用のコンパイラー・ドライバーを使うと簡単です。macOS* システムでは、OpenMP* のダイナミック・ライブラリーが実行時に検知されるように、DYLD_LIBRARY_PATH 環境変数を Xcode* 内に設定しなければならないことがあるので注意してください。
関連情報
- 『インテル® C++ コンパイラー・デベロッパー・ガイドおよびリファレンス』 (英語) または『インテル® Fortran コンパイラー・デベロッパー・ガイドおよびリファレンス』 (英語) の「最適化とプログラミング・ガイド: 自動並列化」