インテル® C++ コンパイラー 19.0 デベロッパー・ガイドおよびリファレンス
マルチコアおよびインテル® ハイパースレッディング・テクノロジー (インテル® HT テクノロジー) 対応のプロセッサーでその機能を活用し、パフォーマンスを最大限に引き出すには、アプリケーションを並列実行する必要があります。並列実行にはスレッドが必要です。アプリケーションのスレッド化は容易ではありませんが、OpenMP* を使用することによりプロセスを簡単にすることができます。OpenMP* のプラグマを使用すると、ループ伝播の依存がないほとんどのループは簡単な 1 つの文でスレッド化できます。ここでは、OpenMP* を使用してループを並列化する方法 (ワークシェア) について説明します。
OpenMP* を使用するオプションは、インテル製マイクロプロセッサーおよび互換マイクロプロセッサーの両方で利用可能ですが、両者では結果が異なります。両者の結果が異なる可能性のある OpenMP* 構造および機能の主なリストは次のとおりです: ロック (内部的なものおよびユーザーが利用可能なもの)、SINGLE 構造、バリア (暗黙的および明示的)、並列ループ・スケジュール、リダクション、メモリーの割り当て、スレッド・アフィニティー、バインド。
ほとんどのループは、ループの直前にプラグマを 1 つ挿入することによってスレッド化することができます。また、詳細をインテル® C++ コンパイラーと OpenMP* に任せることにより、どのループをスレッド化すべきか、パフォーマンスを最大限に向上させるにはどのようにアルゴリズムを再編成すべきかといった決断により多くの時間をかけることができます。OpenMP* は、hotspot (アプリケーションで最も時間のかかるループ) のスレッド化に使用されると、最大限のパフォーマンスを実現します。
次の例では、OpenMP* の機能を活用する方法を説明します。以下のループは、32 ビットの RGB (赤、緑、青) ピクセルを 8 ビットのグレースケール・ピクセルに変換します。並列実行に必要なのは、ループの直前に挿入された 1 つのプラグマだけです。
例 |
---|
|
最初に、この例ではワークシェアを使用しています。OpenMP* では、スレッド間で作業を分配することをワークシェアと呼びます。 この例のようにワークシェアを for 構造とともに使用した場合、ループの反復は複数のスレッドに振り分けられます。複数のスレッドが利用可能な場合、ループの各反復は異なる反復で 1 回のみ実行されます。明示的な numthreads 節がないため、OpenMP* は生成するスレッド数、および作成、同期、破棄で最良な方法を決定します。 OpenMP* では、ループのスレッド化において次の 5 つの制約があります。
ループ変数は符号付き/符号なし整数、ランダムアクセス・イテレーター、またはポインターでなければなりません。
比較操作は、互換性のある型の loop_variable <、<=、>、または >= loop_invariant_expression 形式でなければなりません。
for ループの 3 番目にあるループ式 (増分処理) は、ループ不変値による加算または減算でなければなりません。
比較操作が < または <= の場合、ループ変数は各反復時に増分しなければなりません。逆に、比較操作が > または >= の場合、ループ変数は各反復時に減分しなければなりません。
ループ本体の入口と出口はそれぞれ 1 つでなければなりません。アプリケーション全体を終了する exit 文を除き、ループの内側から外側へのジャンプは許可されていません。goto 文や break 文を使用する場合、これらの文はループ内でのみジャンプでき、ループ外にはジャンプできません。同様に、例外処理もループ内で検出する必要があります。
これらの制約に合致しないループでも、多くの場合、制約に従うように書き換えることができます。
OpenMP* プラグマを使用するには、OpenMP* 互換コンパイラーとスレッドセーフ・ライブラリーが必要です。[Q]openmp オプションを追加すると、OpenMP* プラグマに注意し、スレッドを挿入するようにコンパイラーに指示します。[Q]openmp オプションを省略すると、コンパイラーは OpenMP* プラグマを無視します。つまり、ソースコードを変更することなく、シングルスレッド・バージョンを簡単に生成することができます。
条件付きコンパイルでは、コンパイラーは _OPENMP マクロを定義します。必要に応じて、次の例のようにこのマクロをテストすることができます。
例 |
---|
#ifdef _OPENMP fn(); #endif |
次の例では、OpenMP* の使用がどの程度簡単かを示します。通常、その他の問題に対処する必要がありますが、これらの例では基本的な使用について説明します。
例 1 のループは、配列の値を 0 ~ 255 の範囲に限定します。
例 |
---|
|
この例は、OpenMP* プラグマを使用してスレッド化することができます。プラグマをループの直前に挿入します。
例 |
---|
|
例 2 のループは、0 ~ 100 の平方根の表を生成します。
例 |
---|
|
ループ変数を符号付き整数または符号なし整数に変更し、#pragma omp parallel プラグマを挿入して、ループをスレッド化します。
例 |
---|
|
ループが (前述の) 5 つのループ制約をすべて満たし、コンパイラーがループをスレッド化しても、データ依存が原因でループが正しく動作しない場合があります。
データ依存は、ループの異なる反復 (厳密には、異なるスレッドで実行するループの反復) が共有メモリーの同じ位置の読み取りまたは書き込みを行った場合に発生します。階乗を計算する次の例について考えてみます。
例 |
---|
|
コンパイラーはこのループのスレッド化を試みますが、ループの少なくとも 1 つの反復が異なる反復に対してデータ依存しているため、スレッド化は実行に失敗します。このような状態を競合状態と呼びます。競合状態は、共有リソース (メモリーなど) と並列実行を使用した場合にのみ発生します。この問題を解決するには、ループを書き換えるか、競合状態のない別のアルゴリズムを使用します。
システムやケースによっては競合が発生せず、プログラムが正しく動作するため、競合状態を検出するのは困難です。1 度プログラムが動作しても、常に動作するとは限りません。インテル® ハイパースレッディング (HT) テクノロジーに対応したマシンや複数の物理プロセッサーを搭載したマシンなど、さまざまなマシンでプログラムをテストすると、競合状態を識別するのに役立ちます。
従来のデバッガーでは、1 つのスレッドが競合を停止してもその間、他のスレッドは継続的かつ著しくランタイム動作を変更するため、競合状態の検出には役立ちません。競合状態の検出には、スレッド・チェック・ツールが役立ちます。
(実際のアプリケーションでは) ほぼすべてのループがメモリーからの読み取り、またはメモリーへの書き出しを行います。開発者は、どのメモリーをスレッド間で共有し、どのメモリーをプライベートとして保持するのかをコンパイラーに指示する必要があります。メモリーが共有の場合、すべてのスレッドが同じメモリーの場所にアクセスします。メモリーがプライベートの場合、メモリーにアクセスするために、スレッドごとに個別の変数 (プライベート・コピー) が作成されます。ループの最後にプライベート・コピーは破棄されます。デフォルトでは、プライベートなループ変数を除き、すべての変数は共有されます。
プライベートとしてメモリーを宣言するには、2 つの方法があります。
static キーワードを指定しないで、変数をループの内側 (parallel OpenMP* プラグマの内側) で宣言します。
OpenMP* プラグマで private 節を指定します。
次の例では、変数 temp が共有であるためにループは正しく動作しません。これはプライベートにすべきです。
例 |
---|
|
次の 2 つの例では、変数 temp はプライベート・メモリーとして宣言されているため、問題が解決されています。
例 |
---|
|
次の方法で、temp 変数を作成することもできます。
例 |
---|
|
OpenMP* を使用してループを並列化する際は、すべてのメモリー参照 (呼び出された関数による参照を含む) を慎重に検証することを推奨します。並列構造内で宣言された変数は、static 宣言子を使用して宣言されている場合を除き (static 変数はスタックに割り当てられないため)、プライベートとして定義されます。
値を累積するループは一般的ですが、OpenMP* ではこのようなループ専用の節を用意しています。整数の配列の合計を計算する次のループについて考えてみます。
例 |
---|
|
上記のループでは、変数 sum は正しい結果を生成するために共有する必要があります。また、複数のスレッドによるアクセスを許可するためにプライベートにする必要もあります。OpenMP* は、ループの 1 つ以上の変数の演算リダクションを効率的に結合する reduction 節を提供します。次の例では、ループで reduction 節を使用して正しい結果を生成する方法を説明します。
例 |
---|
|
上記の例では、リダクションは各スレッドに対して変数 sum のプライベート・コピーを用意し、スレッドの終了時に値を加算して、その結果を変数 sum のグローバルコピーに格納します。
次の表は、利用可能なリダクション操作とその初期値 (演算識別値でもある) のリストです。
演算 |
private 変数の初期値 |
---|---|
+ (加算) |
0 |
- (減算) |
0 |
* (乗算) |
1 |
& (ビット単位の AND (論理積)) |
~0 |
| (ビット単位の OR (論理和)) |
0 |
^ (ビット単位の XOR (排他的論理和)) |
0 |
&& (条件付き AND) |
1 |
|| (条件付き OR) |
0 |
並列構造で変数とリダクションをカンマ区切りで指定することによって、ループで複数の演算子を使用することもできます。リダクション変数は、次の要件を満たしている必要があります。
1 つの REDUCTION でリストできる
定数として宣言できない
並列構造ではプライベートとして宣言できない
負荷バランス (スレッド間における作業の等分割) は、並列アプリケーションのパフォーマンスにおいて重要な要素の 1 つです。負荷バランスが良いと、稼働率が上がり、プロセッサーはほとんどの場合ビジーとなります。負荷のバランスが悪いと、一部のスレッドは他よりも著しく速く完了し、プロセッサーのリソースがアイドル状態のまま、パフォーマンスが無駄になります。
ループ構造内の負荷の不均衡は、ループの反復における実行時間の変化が原因でよく発生します。通常、ループの反復における実行時間の変化は、ソースコードを検証することによって容易に確認することができます。ほとんどの場合、ループの反復は一定の時間を消費します。そうでない場合、消費時間が類似している反復のセットを見つけられることがあります。例えば、すべての偶数反復とすべての奇数反復の消費時間が同程度の場合があります。同様に、ループの前半と後半の消費時間が同程度の場合もあります。逆に、一定の実行時間をもつ反復を見つけられないこともあります。どのような場合でも、このループ・スケジュールの補足情報を OpenMP* に渡します。これは、最適な負荷のバランスをとるようにループの反復をスレッド (そしてプロセッサー) に振り分ける際に役立ちます。
ループのすべての反復にほぼ同じ時間がかかることが分かっている場合は、OpenMP* の schedule 節を使用し、異なるスケジュール・ポリシーによってループの反復をスレッド間で等分します。さらに、大きなチャンクの使用によりフォルス・シェアリングが発生し、メモリー競合を引き起こす可能性を最小限に抑える必要があります。一般的にループはシーケンシャルにメモリーにアクセスするため、ループを大きな固まりに (2 つのスレッドを使用する場合では、前半と後半というように) 分割すると、メモリーの重複 (オーバーラッピング) の可能性を最小限に抑えることができます。これは、メモリーアクセスの問題に対しては最良の解決策であるかもしれませんが、負荷バランスに対してはそうではないかもしれません。また逆に、負荷バランスに対して最良の解決策が、メモリーのパフォーマンスに対してもそうであるとは限りません。パフォーマンスを測定し、最良の方法を探して、メモリーの使用と負荷のバランスの両方に最適な方法を見つける必要があります。
parallel for 構文を使用して、次のように OpenMP* ランタイムにループをスケジュールするように指示します。
例 |
---|
|
次の表のように、4 つの異なるループ・スケジュールのタイプ (kind) を OpenMP* ランタイムに渡すことができます。オプションの引数 (chunk) は、正の整数で指定します。
種類 |
説明 |
---|---|
static |
ループを同じ大きさのチャンク、または (ループの反復数がスレッド数にチャンクサイズを掛けた値で割り切れない場合は) できるだけ同じ大きさのチャンクに分割します。デフォルトでは、チャンクサイズは loop_count (ループ反復数) を number_of_threads (利用可能なスレッド数) で割った値です。 反復をインターリーブするにはチャンクを 1 に設定します。 |
dynamic |
内部の作業キューを使用して、ループ反復のチャンク・サイズ・ブロックを各スレッドに渡します。スレッドが終了すると、作業キューの一番上から次のブロックを取得します。 デフォルトでは、チャンクサイズは 1 です。このスケジュール方式には余分なオーバーヘッドがかかるため、使用する際は注意してください。 |
guided |
dynamic スケジュールに似ていますが、大きなチャンクサイズから開始して、徐々に小さくしていき、反復間の負荷不均衡を軽減します。オプションの chunk 引数は、使用するチャンクサイズの最小値を指定します。 デフォルトでは、チャンクサイズは loop_count (ループ反復数) を number_of_threads (利用可能なスレッド数) で割った値とほぼ同じです。 |
auto |
schedule (auto) が指定されると、スケジュールに関する決定はコンパイラーが行います。チーム内のスレッドへの反復の割り当てはコンパイラーが選択します。 |
runtime |
OMP_SCHEDULE 環境変数を使用して、3 つのループ・スケジュールのいずれを指定します。 OMP_SCHEDULE は、書式指定された文字列で、parallel 構造にそのまま渡されます。 |
次のループの並列化を行うとします。
例 |
---|
|
ループにはデータ依存が含まれているため、コードを変更せずには並列化できません。次の新しいループでは、同じように配列に格納しますが、データ依存がありません。新しいループは、コンパイラーにより生成される SIMD 命令を使用することでメリットが得られます。
例 |
---|
|
変数 StartVal の値は増分されないため、これらのコードは全く同じではありません。このため、並列ループが終了すると、変数の値はシリアルバージョンのそれとは異なります。ループの後で StartVal の値が必要な場合は、次のように文を追加する必要があります。
例 |
---|
|
OpenMP タスクモデルは、より広範囲のアプリケーションを並列化できるようにします。タスクにはいくつかの OpenMP* プラグマを使用できます。
omp task プラグマ
omp task プラグマの構文を次に示します。
#pragma omp task [clause[[,] clause] ...] new-line
structured-block
clause は次のいずれかです。
if(scalar-expression)
final (scalar expression)
untied
default(shared | none)
mergeable
private(list)
firstprivate(list)
in_reduction(reduction-identifier : list)
shared(list)
depend(dependence-type : list)
priority(priority-value)
#pragma omp task は、次の例のように明示的にタスク領域を定義します。
例 |
---|
|
タスク領域のバインドスレッドは、現在の並列チームです。タスク領域は、最内の囲まれた PARALLEL 領域にバインドします。スレッドがタスク構造に到達すると、その構造内で囲まれた構造化ブロックからタスクが生成されます。到達スレッドは、直ちにタスクを実行するか、または実行を保留します。task 構造は、外側のタスクの中に入れ子されることがありますが、内側タスクのタスク領域は、外側タスクのタスク領域の一部ではありません。
#pragma omp task の節の使用
#pragma omp task プラグマには、オプションで指定した節をカンマ区切りのリストで指定できます。タスクのデータ環境は、タスク構造上のデータ共有属性節と適用されるデフォルトに従って作成されます。次の例は、1 つのスレッドで N タスクを生成し、それらのタスクを並列チーム内のスレッド群で実行する方法を示しています。
例 |
---|
|
タスク・スケジュール
スレッドがタスク・スケジュール・ポイントに到達すると、タスクスイッチを実行し、現在のチームにバインドされている異なるタスクの実行を開始するか、または再開します。タスク・スケジュール・ポイントは次の場所で暗黙的に指定されます。
直後に明示的にタスクが生成されるポイント
タスク領域の最後の命令の後
taskwait 内
暗黙的および明示的なバリア領域内
スレッドがタスク・スケジュール・ポイントに到達すると、次のいずれかを行います。
現在のチームにバインドされるタイドなタスクの実行を開始する
現在のチームにバインドされるタイドな中断タスク領域を再開する
現在のチームにバインドされるアンタイドなタスクの実行を開始する
現在のチームにバインドされるアンタイドな中断タスク領域を再開する
上記の選択肢が複数存在する場合は、どれが選択されるかは不明です。
タスク・スケジュールの制約
if 式が false に評価される if 節を含む構文の明示的タスクは、タスク生成直後に実行されます。
新しいタイドなタスクのほかのスケジュールは、現在、スレッドにタイドされ、またバリア領域で中断されていないタスク領域セットにより制約されます。このセットが空の場合は、任意の新しいタスクがスケジュールされます。そうでない場合は、新しいタイドなタスクは、セット内の各タスクの子孫である場合のみスケジュールされます。タスク・スケジュールについてその他の仮定に基づくプログラムは規格に準拠していません。
タスク・スケジュール・ポイントは、動的にタスク領域をパーツに分割します。それぞれのパーツは、開始から終了まで割り込まれることなく実行されます。同じタスク領域の各パーツは、検出された順に実行されます。タスクの同期化構造がない場合、スレッドが異なるスケジュールのタスクを実行する順番は不定です。
正しいプログラムは、上記の規則に沿った、考えられるすべてのスケジュール・シーケンスで正常かつ一貫した動作を行わなければなりません。
omp taskwait プラグマ
#pragma omp taskwait プラグマは、現在のタスクが開始してから生成された子タスクの完了時点で待機するように指定します。taskwait 領域は現在のタスク領域にバインドします。taskwait 領域のバインドスレッドは、検出したスレッドです。
taskwait 領域には、現在のタスク領域の暗黙のタスク・スケジュール・ポイントが含まれます。現在のタスク領域は、taskwait 領域の前に生成された子タスクの実行がすべて完了するまで、タスク・スケジュール・ポイントで一時停止します。
例 |
---|
|
omp taskyield プラグマ
#pragma omp taskyield プラグマは、現在のタスクをその時点で一時停止し、別のタスクの実行に切り替えられることを指示します。このプラグマを使用して、タスク内の特定のポイントで明示的なタスク・スケジュール・ポイントを提供することができます。