概要
アプリケーションをマルチスレッド化してパフォーマンスを向上させるのは骨の折れる作業です。しかし、計算の多くが単純なループで実行されるアプリケーションの場合、インテル® コンパイラーを使用するとマルチスレッド・バージョンのアプリケーションを自動的に生成できます。
インテル®コンパイラーでは、高レベルなコードの最適化のほか、自動並列化や 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 つの要件が満たされなければなりません。1) 処理を前もって分割できるように、ループに入る前に反復回数が判明している (例えば、while ループは通常は並列化できない)。2) ループへのジャンプ、またはループからのジャンプがあってはならない。3) ループの反復処理が独立している (最も重要)。つまり、反復が実行される順序に論理的に依存してはなりません。ただし、例えば、同じデータが異なる順で追加された場合、蓄積される丸め誤差の変分はわずかです。配列を合計するようないくつかのケースでは、コンパイラーは単純な変換を行うことで明らかな依存性を排除できることがあります。
また、ポインターや配列参照の潜在的なエイリアスも、安全な並列化にとっては一般的に知られている障害です。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、Mac OS X) を使用してプローシージャー間の最適化を起動する方法もあります。このオプションを使用すると、コンパイラーは呼び出された関数の副作用を解析できます。
並列で実行しても安全だとプログラマーがわかっていても、ループが複雑なためにコンパイラーによる自動並列化が行われない場合は、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' では、次のようなメッセージが表示されます。
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 or Mac OS X) や /Qparallel (Windows) を使用してビルドすると良いでしょう。-par-report3 (Linux) や /Qpar-report3 (Windows) を有効にしてレポートを生成し、並列化できたループとできなかったループを確認します。並列できなかったループについては、データの依存関係を排除したり、あるいはコンパイラーがエイリアスされたメモリー参照を一義化できるようにします。-O3 でコンパイルすると、高レベルのループの最適化 (ループ融合など) が有効になり、自動並列化の助けとなります。-opt-report-phase hlo を指定するとこのような追加の最適化がコンパイラーの最適化レポートに表示されます。有益な速度向上が得られたかどうかを確認するため、並列化を行った場合と行わない場合のパフォーマンスを常に測定するようにしてください。-openmp と -parallel の両方が同じコマンドラインで指定されている場合、コンパイラーは OpenMP* 宣言子を含まないループのみを並列化しようとします。コンパイルとリンクを別々に行う場合、自動並列化を使用するときは必ず OpenMP ランタイム・ライブラリーをリンクしてください。たとえば icl /Qparallel (Windows) や ifort -parallel (Linux、Mac OS X) を用いて、リンク用のコンパイラードライバーを使うと簡単です。Mac OS X システムでは、OpenMP のダイナミック・ライブラリーが実行時に検知されるように、DYLD_LIBRARY_PATH 環境変数を Xcode 内に設定しなければならないことがあるので注意してください。