多くの計算が単純なループで実行されるアプリケーションについて、インテル(R) コンパイラーはマルチスレッド・バージョンのアプリケーションを自動的に生成できます。ここで説明する内容は、対称型マルチプロセッサー (SMP)、ハイパースレッディング・テクノロジー (HT テクノロジー) 対応のシステム、およびデュアルコア・プロセッサー・システム向けのアプリケーションを対象にしています。
コンパイラーは、ループ中のデータフローを解析して、安全かつ効率的に並列実行できるループを判断します。自動並列化を行うと、実行時間が短くなることがあります。この自動並列化は、次のような一般的な作業にかかる時間を短縮することができます。
並列実行に適したループ候補を検索する
正しい並列実行を確認するためにデータフロー解析を行う
並列コンパイラー宣言子を手動で追加する
並列化は、次のセクションに記述されている特定の条件を前提としています。-openmp と -parallel (Linux* および Mac OS* X) または /Qopenmp と /Qparallel (Windows*) の両方が同じコマンドラインで指定されると、コンパイラーは OpenMP* 宣言子を含まない関数のみを並列化しようとします。
次のプログラムには、反復カウントの高いループが含まれています。
例 |
---|
#include <math.h> void no_dep() { int a, n = 100000000; float c[n]; for (int i = 0; i < n; i++) { a = 2 * i - 1; c[i] = sqrt(a); } } |
データフローの解析では、ループにデータ依存がないことを確認します。コンパイラーは、ランタイム時にスレッド間で、可能な限り反復を平等に分割するコードを生成します。スレッドの数はデフォルトではプロセッサーの数ですが、OMP_NUM_THREADS 環境変数を使用して設定できます。指定されたループの並列速度の向上は、作業の量、スレッド間のロードバランス、スレッド作成および同期のオーバーヘッドなどに依存しますが、一般的にスレッドの数未満になります。プログラム全体では、速度の向上は直列部分に対する並列部分の比率に依存します。
コンパイルとリンクをそれぞれ別のビルドで行う場合、自動並列化を使用するときは、OpenMP ランタイム・ライブラリーを必ずリンクしてください。この場合、インテル・コンパイラー・ドライバーを使用してリンクすると非常に簡単です。
コンパイラーでループを並列化するには、次の 3 つの要件が満たされなければなりません。
作業を前もって分割できるように、反復の回数はループに入る前に判明していなければなりません。例えば、while ループは通常は並列化できません。
ループへのジャンプ、またはループからのジャンプがあってはなりません。
ループの反復は独立していなければなりません。
つまり、反復が実行される順序に論理的に依存してはなりません。ただし、例えば、同じデータが異なる順で追加された場合、蓄積される丸め誤差の変分はわずかです。配列を合計するようないくつかのケースでは、コンパイラーは単純な変換を行うことで明らかな依存性を排除できることがあります。
また、ポインターや配列参照の潜在的なエイリアスも、安全な並列化にとっては一般的に知られる障害です。2 つのポインターが同じメモリーの場所を指す場合、両方のエイリアスが作成されます。コンパイラーは、2 つのポインターまたは配列参照が同じメモリーの場所を指しているかどうかを判断できません。例えば、関数の引数、ランタイムデータ、または複雑な計算の結果に依存する場合、ポインターあるいは配列参照が安全で反復が独立していることをコンパイラーが証明できなければ、コンパイラーはループを並列化しません (ただし、ランタイム時にエイリアスを明示的にテストするための代替コードパスの生成が有益であると考えられる場合を除きます)。特定のループの並列化が安全で潜在的なエイリアスが無視できることが判明していれば、#pragma parallel プラグマを使用してループを並列化するようにコンパイラーに指示できます。
C でポインターがエイリアスされないことを表明するには、ポインター宣言で restrict キーワードを使用して、-restrict (Linux および Mac OS X) または /Qrestrict (Windows) コマンドライン・オプションを使用します。コンパイラーは、安全が確保されないループを並列化することはありません。
コンパイラーは、相対的に単純な構造のループのみを効率的に解析できます。例えば、コンパイラーは関数呼び出しに依存性をもたらす副作用があるかどうかわからないため、外部関数の呼び出しを含むループのスレッド安全性を判断できません。-ipo (Linux および Mac OS X) または /Qipo (Windows) コンパイラー・オプションを使用すると、プロシージャー間の最適化を行えます。このオプションを使用して、コンパイラーは呼び出された関数の副作用を解析できます。
コンパイラーが自動的にループを並列化できない場合、OpenMP を使用して並列化を行います。開発者は、コンパイラーよりもコードを理解し、より粗い粒度で並列化を表現できるため、OpenMP は、推奨されるソリューションです。一方、自動並列化は、行列乗算のように、入れ子したループに有効です。適度な粗粒度の並列化は、外部ループのスレッド化に起因し、ベクトル化やソフトウェアのパイプライン化を使用して内部ループをより細かい粒度の並列化に最適化できるようにします。
ループが並列化できる場合でも、常に並列化すべきであるとは限りません。コンパイラーは、しきい値パラメーターを使用して、ループを並列化するかどうかを決定します。-par-threshold (Linux および Mac OS X) または /Qpar-threshold (Windows) コンパイラー・オプションは、この動作を調整します。しきい値の範囲は 0 から 100 です。0 は、安全なループを常に並列化するようにコンパイラーに指示します。100 は、パフォーマンスの向上が期待できるループのみを並列化するようにコンパイラーに指示します。ループが並列化されたかどうかを判断するには、-par-report (Linux および Mac OS X) または /Qpar-report (Windows) コンパイラー・オプションを使用します。コンパイラーは、並列化できなかった理由を示し、並列化できなかったループもレポートします。これらのコンパイラー・オプションについての詳細は、「自動並列化: しきい値制御と診断」を参照してください。
次に、これらのオプションを組み合わせて使用する例を示します。次のコードがあると仮定します。
コード例 |
---|
void add (int k, float *a, float *b) { for (int i = 1; i < 10000; i++) a[i] = a[i+k] + b[i]; } |
コマンドラインで以下のコンパイラー・コマンドを入力します。
プラットフォーム |
コマンド例 |
---|---|
Linux および Mac OS X |
icpc -c -parallel -par-report3 add.cpp |
Windows |
icl /c /Qparallel /Qpar-report3 add.cpp |
コンパイラーは、次のような結果を出力します。
サンプル結果 |
---|
add.cpp procedure: add serial loop: line 2 anti data dependence assumed from line 2 to line 2, due to "a" flow data dependence assumed from line 2 to line 2, due to "a" flow data dependence assumed from line 2 to line 2, due to "a" |
コンパイラーは k の値がわからないため、例えば、k が -1 の場合でも、反復は互いに依存すると仮定します。#pragma parallel を挿入して、コンパイラーの仮定を無効にすることができます。
例 |
---|
void add(int k, float *a, float *b) { #pragma parallel for (int i = 0; i < 10000; i++) a[i] = a[i+k] + b[i]; } |
k の値が 10000 未満の場合にこの関数を呼ばないようにするのは、開発者の責任で行ってください。10000 未満の値を渡すと、正しくない結果になります。
スレッドプールは、作業割り当てを待つスレッドのグループで、スレッドを管理する効率的なアプローチを提供します。このアプローチでは、スレッドは、初期化ステップ中に作成され、終了化ステップ中に終了されます。アプリケーション全体を通して、スレッド作成中の失敗を途中で確認する制御ロジックを単純化できるので、アプリケーション全体でスレッド作成に必要なコストを減らすことができます。一旦作成されると、スレッドプールのスレッドは作業が可能になるまで待機します。アプリケーションの他のスレッドは、スレッドプールにタスクを割り当てます。これは、スレッド・マネージャーやディスパッチャーと呼ばれる単一のスレッドです。タスクが完了すると、各スレッドは次の作業まで待機するためにスレッドプールに戻ります。作業量が増える場合、作業割り当てと使用されたスレッド・プール・ポリシーに応じて、スレッドプールに新しいスレッドを追加できます。このアプローチには、次の利点があります。
単純な制御ロジックにより、スレッドを作成できないためにアプリケーション実行の途中で発生するランタイムエラーを回避できる。
スレッド作成からスレッド管理までのコストを最小限にできる。作業の処理に対する応答時間が良くなり、より細かな粒度の作業をマルチスレッド化できる。
スレッドプールを使用する典型的な例として、新しいリクエストごとにスレッドを頻繁に起動するサーバー・アプリケーションがあります。この場合、既存のスレッドプールによって処理するサービスリクエストをキューすると、より効果的です。プールのスレッドは、キューのサービスリクエストを取得して処理し、次の作業のためにキューに戻ります。
スレッドプールは、オーバーラップしている非同期 I/O の実行にも使用できます。Win32* API で提供される I/O 完了ポートでは、スレッドのプールは I/O 完了ポートで待機し、オーバーラップした I/O 操作からのパケットを処理できます。
OpenMP は、厳格な fork/join スレッド化モデルです。一部の OpenMP の実装では、スレッドは並列領域の最初に作成され、並列領域の最後に破棄されます。OpenMP アプリケーションには通常、干渉する直列領域と複数の並列領域があります。特に並列領域がループの内部にある場合、各並列領域でスレッドの作成と破棄を行うと、大量のシステム・オーバーヘッドが発生します。このため、インテルでは、OpenMP の実装にスレッドプールを使用しています。作業スレッドのプールは、最初の並列領域で作成されます。これらのスレッドは、プログラム実行中はずっと存在します。プログラムによって要求された場合、より多くのスレッドが自動的に追加されます。最後の並列領域が実行されるまで、スレッドは破棄されません。
スレッドプールは、スレッド作成 API を使用して Windows および Linux で作成できます。例えば、Win32 スレッドを使用するカスタム・スレッド・プールは、以下のように作成されます。
例 |
---|
// Initialization method/function { DWORD tid; // Create initial pool of threads for (int i = 0; i < MIN_THREADS; i++) { HANDLE *ThHandle = CreateThread (NULL,0,CheckPoolQueue,NULL,0,&tid); if (ThHandle == NULL) // Handle Error else RegisterPoolThread (ThHandle); } } |
プールの各スレッドによって実行される CheckPoolQueue 関数は、作業がキューで利用可能になるまで、待機状態になるように設計されています。スレッド・マネージャーは、キューで待機状態のジョブを追跡し、需要に基づいてプールのスレッドの数を動的に増やします。