OpenMP と C++
手間をかけずにマルチスレッドの恩恵を受ける
Kang Su Gatlin and Pete Isensee
この記事は、Visual C++ 2005 のプレリリース版に基づいています。すべての情報は変更される可能性があります。
|
この記事で取り上げる話題:
|
|
目次Visual C++ での OpenMP の有効化 |
並列計算の分野では、"並列計算は、将来的には脚光を浴びる。いつになっても将来的に。" などと言われることもあります。これは、ここ数十年においては真実でした。同じようなことはコンピュータ アーキテクチャの業界にもあり、プロセッサ クロックの高速化はすぐに限界に達するといつも言われていますが、実際には、今も高速化が続いています。マルチコア革命は、こうした並列処理分野での楽観と、アーキテクチャ分野での悲観の衝突と言えます。
主要な CPU ベンダは、クロック速度の増加から、マルチコア プロセッサによるオンチップでの並列処理サポートの提供へと方向性を変えています。考え方は単純で、1 つのチップに複数の CPU コアを搭載しようというものです。これにより、1 つのプロセッサに 2 つのコアを搭載して、システムをデュアル プロセッサ コンピュータのように動作させることができます。さらに、1 つのプロセッサに 4 つのコアを搭載すれば、システムはクワド プロセッサのように動作します。この方法により、CPU ベンダは、高速化を進める中での技術的な壁を避けつつも、よりパフォーマンスの高いプロセッサを提供することができます。
ここまではすばらしいことのように聞こえますが、アプリケーションがマルチコアを活用しなければ、動作はまったく速くなりません。ここで、OpenMP の登場です。OpenMP により、C++ 開発者は、マルチスレッド アプリケーションをよりすばやく作成できます。
OpenMP は、大規模で機能豊富な API であるため、これについて 1 つの記事で語るのは非常に困難です。このため、この記事では、導入として、OpenMP のさまざま機能を使用してマルチスレッド アプリケーションをすばやく作成する方法を紹介します。詳細については、OpenMP Web site (英語) で読みやすい仕様が入手可能です。
Visual C++ での OpenMP の有効化
OpenMP 標準は、1997 年に、ポータブルなマルチスレッド アプリケーションを作成するための API として定められました。最初は、Fortran ベースの標準でしたが、後に、C および C++ を含むように拡張されました。現在のバージョンは OpenMP 2.0 です。Visual C++ 2005 は、この標準を完全にサポートします。OpenMP は、また Xbox 360 プラットフォームでもサポートされます。
コードの詳細な説明に入る前に、コンパイラでの OpenMP 機能の有効化について説明します。Visual C++ 2005 では、コンパイラが OpenMP ディレクティブを解釈できるようにする /openmp コンパイラ スイッチが新しく提供されています。(また、プロジェクトのプロパティ ページでも OpenMP ディレクティブを有効にできます。[構成プロパティ] 、[C/C++]、[言語] とクリックし、[OpenMP サポート] プロパティを変更します。) /openmp スイッチが呼び出された場合、コンパイラは、シンボル _OPENMP を定義します。これは、#ifndef _OPENMP を使用して OpenMP が 有効にされているのを検出するのに使用されます。
OpenMP は、インポート ライブラリ vcomp.lib によってアプリケーションにリンクされます。対応のランタイム ライブラリは vcomp.dll です。このインポート ライブラリ、およびランタイム ライブラリのデバッグ バージョン (それぞれ、vcompd.lib と vcompd.dll) には、特定の不正な操作のときに発生する追加のエラー メッセージがあります。Visual C++ は、OpenMP ランタイムの静的リンクはサポートしていないことに注意してください。ただし、Xbox 360 では静的リンクがサポートされます。
OpenMP での並列処理
OpenMP アプリケーションは、まず 1 つのスレッド、つまりマスタ スレッドから作成します。プログラムを実行すると、アプリケーションはマスタ スレッドがスレッド チーム (マスタ スレッドも含む) を作成する並列領域に入ります。並列領域が終わると、スレッド チームは停止し、マスタ スレッドが実行を続けます。1 つの並列領域には、ネストされた複数の並列領域が存在することができます。ネストされた並列領域では、元の並列領域の各スレッドがスレッド チームのマスタとなります。ネストされた並列処理は、さらに別の並列領域をネストすることができます。
図 1 OpenMP の並列セクション図 1 に、OpenMP の並列処理の動作を示します。最も左の黄色の線がマスタ スレッドです。このスレッドは、1 のポイントで最初の並列領域が始まるまではシングル スレッド アプリケーションとして実行されます。並列領域に入ると、マスタ スレッドがスレッド チーム (黄色およびオレンジ色の線で構成される) を作成します。これらのスレッドは、すべて同時に並列領域で実行されます。 2 のポイントでは、並列領域で実行されている 4 つのスレッドのうち 3 つのスレッドが新しいスレッド チーム (ピンク色、緑色、青色) をネストされた並列領域に作成しています。チームを作成した黄色およびオレンジのスレッドは、それぞれ自身のチームのマスタです。各スレッドは、新しいチームをそれぞれ別の時点で作成できることに注意してください。これができなければ、並列領域がネストされることはありません。 3 のポイントで、並列領域は終了します。ネストされた並列領域はそれぞれ、その領域内のスレッドを同期しますが、別の領域とは同期されないことに注意してください。4 のポイントでは、最初の並列領域が終了し、5 のポイントではまた新しい並列領域が開始しています。5 のポイントの新しい並列領域では、各スレッドのスレッド ローカル データは、前の並列領域から持続しています。 これで、実行モデルの基本的理解が得られたと思います。ここから実際に、並列アプリケーションの作成に関する説明に入ります。 OpenMP の構成 |
図 1 OpenMP の並列セクション |
OpenMP は使いやすく、たった 2 つの基本的な構成体、プラグマ、およびランタイム ルーチンで構成されます。OpenMP プラグマは、通常、コードのセクションを並列化するようにコンパイラに指示します。すべての OpenMP プラグマは、#pragma omp で始まります。どのプラグマの場合でも、これらのディレクティブは、その機能、ここでは OpenMP をサポートしていないコンパイラでは無視されます。
OpenMP ランタイム ルーチンは、主に環境に関する情報を設定、および取得するために使用されます。また、同期の特定のタイプ用の API もあります。OpenMP ランタイムの関数を使用するには、ブログラムで OpenMP ヘッダーファイル omp.h をインクルードする必要があります。アプリケーションがプラグマのみを使用する場合は、omp.h を省略することができます。
OpenMP によってアプリケーションに並列処理を追加するのは簡単であり、プラグマを追加し、必要な場合には、OpenMP ランタイムから OpenMP 関数を呼び出すだけです。これらのプラグマには、次の形式が使用されます。
#pragma omp <directive> [clause[ [,] clause]...]
ディレクティブには、parallel、for、parallel for、section、sections、single、master、critical、flush、ordered、および atomic が入ります。これらのディレクティブは、ワークシェアリング、または同期の構成体のいずれかを指定します。この記事では、これらのディレクティブのほとんどについて説明します。
句は、ディレクティブのオプションの修飾子で、ディレクティブの動作に影響を与えます。ディレクティブには、さまざまな句を組み合わせて使用することができます。また、5 つのディレクティブ (master、critical、flush、 ordered、および atomic) は、句を使用しません。
並列処理の指定
多くのディレクティブがありますが、少しずつ始めましょう。最もよく使用され、かつ重要なディレクティブは、parallel です。このディレクティブは、ディレクディブの後に続く構造化ブロックの動的範囲を示す並列領域を作成します。たとえば、次のようになります。
#pragma omp parallel [clause[ [, ]clause] ...]
structured-block
このディレクティブは、構造化ブロックが複数のスレッドで並列に処理されることをコンパイラに伝えます。各スレッドは、同じ命令ストリームを実行しますが、命令の同じセットである必要はありません。これは、if-else などの制御フロー ステートメントによって決まります。
ここで、おなじみの "Hello World" プログラムのサンプルを示します。
#pragma omp parallel
{
printf("Hello World\n");
}
2 つのプロセッサの場合、次のような出力がされることを期待するでしょう。
Hello World
Hello World
しかし、次のようになる可能性もあります。
HellHell oo WorWlodrl
d
2 番目の出力は、2 つのスレッドが並列に実行され、その両方が同時に出力しようとした結果です。複数のスレッドが 1 つの共有リソース (この場合の共有リソースは、コンソール ウィンドウ) を読み込んだり、変更したりする場合、競合状態が発生する可能性があります。これは、アプリケーションの中では非決定的なバグとなり、見つけるのが困難になる場合もあります。プログラマは、このようなバグが発生しないようにする必要があります。通常は、ロックを使用するか、または可能な限り共有リソースを避けます。
次に、もう少し役立つサンプルを紹介します。1 つの配列に含まれる 2 つの値の平均を計算し、その値を別の配列にセットします。ここでは、新しい OpenMP の構成体、#pragma omp for を使用します。これは、ワークシェアリング ディレクティブです。ワークシェアリング ディレクティブは、並列処理を作成するのではなく、スレッド チームをロジカルに分配し、後ろに続く制御フローの構成体を実装します。#pragma omp for ワークシェアリング ディレクティブは、以下のコードに示す for ループが、並列領域から呼び出された場合、繰り返しをスレッド チームで分割することを OpenMP に伝えます。
#pragma omp parallel
{
#pragma omp for
for(int i = 1; i < size; ++i)
x[i] = (y[i-1] + y[i+1])/2;
}
このケースで、size の値を 100 とし、4 つのプロセッサを搭載するコンピュータで実行すると、ループの繰り返しは、プロセッサ 1 に繰り返しの 1 ~ 25、プロセッサ 2 に繰り返しの 26 ~ 50、プロセッサ 3 に繰り返しの 51 ~ 75、そしてプロセッサ 4 に繰り返しの 76 ~ 99 がそれぞれ割り当てられます。これは、スケジューリング ポリシーが static スケジューリングであることを前提とします。スケジューリング ポリシーについては、この記事の後で詳細に説明します。
このプログラムでは明示的ではありませんが、並列領域の最後にはバリア同期があります。すべてのスレッドは、最後のスレッドが完了するまで、ここでブロックされます。
以下のように、前に示したコードの #pragma omp for を使用しないと、スレッドがそれぞれ for ループ全体を実行するので、各スレッドの処理は冗長となります
#pragma omp parallel // おそらく意図的なものではない
{
for(int i = 1; i < size; ++i)
x[i] = (y[i-1] + y[i+1])/2;
}
並列ループは、最もよく使用される並列化可能なワークシェアリングの構成体であるので、OpenMP には、#pragma omp parallel の後に #pragma omp for を記述する省略形式が提供されています。次のようになります。
#pragma omp parallel for
for(int i = 1; i < size; ++i)
x[i] = (y[i-1] + y[i+1])/2;
ここで、ループ搬送依存がないことに注意してください。つまり、ループの 1 つの繰り返しが、そのループの別の繰り返しに依存していません。たとえば、次の 2 つのループには、ループ搬送依存の特性が 2 つあります。
for(int i = 1; i <= n; ++i) // ループ (1)
a[i] = a[i-1] + b[i];
for(int i = 0; i < n; ++i) // ループ (2)
x[i] = x[i+1] + b[i];
ループ 1 を並列処理するのには問題があります。ループの i の繰り返しを実行するために、i-1 の結果を取得する必要があるためです。i の繰り返しは、i-1 の繰り返しに依存します。ループ 2 を並列処理するのも問題です。ただし、理由は違います。このループでは、x[i-1] の値を計算する前に、x[i] を計算することができます。しかし、x[i] を計算すると x[i-1] の値が変わってしまいます。i-1 の繰り返しは、i の繰り返しに依存します。
ループを並列処理する場合は、ループの繰り返しに依存関係が存在しないようにします。ループの繰り返しに依存がない場合、コンパイラは、そのループをどのような順序でも、つまり並列に実行することができます。これは、コンパイラではチェックされない重要な要件です。事実上、開発者が、並列処理されるループにはループ搬送依存が含まれないことをコンパイラに断言するしかありません。ループが依存関係を持つ場合に、コンパイラに並列処理をするように設定すると、コンパイラは設定されたとおりに実行し、最終的に間違った結果となってしまいます。
これ以外にも、OpenMP では、#pragma omp for、または #pragma omp parallel for のブロック内で可能な for ループの形式に制限があります。ループは次の形式である必要があります。
for([integer type] i = loop invariant value;
i {<,>,=,<=,>=} loop invariant value;
i {+,-}= loop invariant value)
これは、OpenMP が、ループの最初で、ループが実行する繰り返しの回数を判断するために必須です。
OpenMP と Win32 スレッドの比較
前に示した #pragma omp parallel for のサンプルと、Windows API を使用してスレッド化するため必要なことを比較してみることわかることがいくつかあります。図 2 から、同じ結果を得るためにより多くのコードが必要であることがわかります。また、ここには、水面下で行われるトリックのようなものも存在します。たとえば、ThreadData のコンストラクタは、スレッド呼び出しごとに開始と終了を計算します。OpenMP は、これらの詳細を自動的に処理します。さらに、プログラマが並列領域、およびコードの構成を調整することも可能にします。
共有データとプライベート データ
並列プログラムを作成する際は、パフォーマンスの向上のためだけでなく、正常な処理が行われるようにするために、どのデータが共有され、どのデータがプライベートとなるかを理解しておくことが非常に重要です。OpenMP は、この区別をプログラマにはっきりと示します。また、手動で操作することもできます。
共有変数は、スレッド チームのすべてのスレッドで共有されます。つまり、1 つのスレッドで共有変数に変更を加えると、その変更は、その並列領域内の他のスレッドから認識されます。一方、プライベート変数は、スレット チームのスレッドごとにプライベートに作成されます。このため、1 つのスレッドで変更を加えても、その変更は他のスレッドのプライベート変数からは認識されません。
既定で、並列領域内のすべての変数は共有です。ただし、例外が 3 つあります。1 つ目は、parallel for ループにおけるループのインデックスで、これはプライベートです。図 3 のサンプルで、i 変数はプライベートです。j 変数は既定ではプライベートではありませんが、firstprivate 句を使用して明示的にプライベートにされています。
2 つ目として、並列領域のブロックにローカルな変数はプライベートになります。図 3 の変数 doubleI は、並列領域で宣言されているためプライベートです。myMatrix::GetElement で宣言された非静的変数、および非メンバ変数はいずれもプライベートになります。
3 つ目の例外は、private、firstprivate、lastprivate、または reduction の句にリストされた変数で、これらはすべてプライベートとなります。図 3 の変数 i、j、および sum は、スレッド チームの各スレッドでプライベートになります。これは、これらの変数のコピーをスレッドごとに別々に作成することにより実現されます。
4 つの句は、いずれも変数のリストを受け取りますが、それらのセマンティクスはすべて異なります。private 句は、リスト内の各変数のプライベートなコピーがスレッドごとに作成されることを指定します。このプライベート コピーは、既定値で初期化されます(適切な場合には、既定のコンストラクタが使用されます)。たとえば、int 型の変数の既定値は 0 です。
firstprivate 句は、セマンティクスは private とほぼ同じですが、並列領域が各スレッドに入る前にプライベート変数の値をコピーします。適切な場合には、コピー コンストラクタが使用されます。
lastprivate 句は、セマンティクスは private とほぼ同じですが、最後の繰り返し、またはワークシェアリングの構成体の最後のセクションで、lastprivate 句にリストされた変数の値がマスタ スレッドの変数に割り当てられます。適切な場合には、オブジェクトをコピーするのにコピー割り当て演算子が使用されます。
reduction 句は、private のセマンティクスに似ています。ただし、変数と演算子の両方を受け取ります。演算子の組み合わせは、図 4 に一覧した演算子に限られます。reduction 変数は、スカラ変数 (たとえば、float、int、または long などであり、std::vector、int [] などではない変数) である必要があります。reduction 変数は、スレッドごとに表に一覧した値に初期化されます。コード ブロックの最後で、reduction 演算子が、変数の各プライベート コピーに適用され、さらに変数の元の値にも適用されます。
図 3 のサンプルでは、sum の値は各スレッドで暗黙的に 0.0f で初期化されます。(表の正規化値は 0 であることに注意してください。これは、sum の型が float であるために 0.0f になります。) #pragma omp for ブロックが完了した後、スレッドは + 演算子をすべてのプライベート sum の値、および元の値 (sum の元の値、このサンプルでは 10.0f) に適用します。結果は、元の共有 sum 変数に割り当てられます。
ループ以外の並列処理
OpenMP は、ループ レベルでの並列処理によく使用されますが、関数レベルでの並列処理もサポートしています。このメカニズムは、OpenMP sections で呼び出されます。sections の構造は簡単で、多くの場合に有用です。
コンピュータ科学において最も重要なアルゴリズムであるクイックソートについて考えてみましょう。ここで取り上げる例は、一連の整数を対象とする単純な再帰的クイックソート アルゴリズムです。単純にするため、一般な形式のバージョンは使用せず、同じ OpenMP の考え方を採用します。図 5 のコードは、sections を使用するクイックソートのメインとなる関数です (ここでは、簡単にするために Partition 関数については省略します)。
このサンプルでは、最初の #pragma でセクションの並列領域を作成しています。ディレクティブ #pragma omp section の後ろに各セクションが指定されます。並列領域のセクションがそれぞれ、スレッド チーム内の 1 つのスレッドに指定されるので、すべてのセクションを同時に処理することができます。各並列セクションが、それぞれ QuickSort を再帰的に呼び出します。
#pragma omp parallel for 構成体の場合と同じように、セクションが並列に処理されるためには、各セクションが他のセクションに依存しないようにする必要があります。セクションが、共有リソースへのアクセスを同期せずにリソースを更新すると、結果は保証されません。
このサンプルでは、#pragma omp parallel for と同じように省略形式の #pragma omp parallel sections が使用されていることに注意してください。また、#pragma omp for と同様に、#pragma omp sections を並列領域内で単独のディレクティブとして使用することもできます。
図 5 に示した実装について、いくつか注意する点を説明します。まず、並列セクションは再帰的に呼び出されます。再帰呼び出しは、並列領域、具体的にはこの場合は、並列セクションでサポートされます。またネストを可能にすれば、プログラムが再帰的に QuickSort を呼び出すときに新しいスレッドが生成されます。これはアプリケーション プログラマが意図的にそうしている場合もありますが、結果的に生成されるスレッドは相当多くなります。プログラムは、スレッドの数を抑えるためにネストを不可にすることができます。ネストが不可である場合、このアプリケーションは、2 つのスレッドで QuickSort を再帰的に呼び出します。再帰的であっても 2 つ以外のスレッドが生成されることはありません。
さらに、このアプリケーションを /openmp スイッチなしでコンパイルすると、完全に正常な順次実装が生成されます。OpenMP を解釈しないコンパイラとの共存は、OpenMP の利点の 1 つです。
同期プラグマ
複数のスレッドが同時に実行される場合、たいていは、1 つのスレッドが他のスレッドと同期をとる必要があります。OpenMP は、さまざまな状況に対応できるように、いくつかの種類の同期を提供しています。
その 1 つがバリア同期です。各並列領域の最後には、その並列領域内のすべてのスレッドに対する暗黙のバリア同期が存在します。バリア同期は、必ずすべてのスレッドがこのポイントに到達してから、次に進むようにします。
#pragma omp for、#pragma omp single、#pragma omp sections の各ブロックの最後にも、暗黙のバリア同期が存在します。これらの 3 つのタイプのワークシェアリング ブロックからバリア同期を除去するには、次のようにします。
#pragma omp parallel
{
#pragma omp for nowait
for(int i = 1; i < size; ++i)
x[i] = (y[i-1] + y[i+1])/2;
}
ここからわかるように、ワークシェアリング ディレクティブの nowait 句でスレッドが for ループの最後で同期しないことを指示しています。ただし、スレッドは並列領域の最後では同期します。
もう 1 つのタイプは、明示的なバリア同期です。並列領域の最後以外に、バリア同期を配置する場合があります。この場合は、#pragma omp barrier というコードで配置します。
クリティカル セクションは、バリアとして使用することができます。Win32 API では、クリティカル セクションに、EnterCriticalSection、および LeaveCriticalSection によって出入りします。OpenMP は、#pragma omp critical [name] で同じ機能を提供します。このセマンティクスは、Win32 のクリティカル セクションと同じであり、水面下で EnterCriticalSection が使用されます。名前付きのクリティカル セクションを使用できます。この場合コードのブロックは、同じ名前の他のクリティカル セクションとのみ相互に排他的になります (これは、全プロセスにわたって適用されます)。名前を指定しない場合は、何らかのユーザー未指定の名前が付けられます。名前を指定ないクリティカル セクションは、すべてに相互排他的な領域です。
並列領域では、コードのセクションへのアクセスを特定の単一のスレッドに制限することがよくあります。たとえば、並列領域の中でファイルに書き込む場合などです。こうした場合多くは、このコードを実行するスレッドが 1 つであれば、それがどのスレッドであっても構いません。OpenMP には、これを実現する #pragma omp single があります。
ただし、並列領域のコードのセクションを、この single ディレクティブを使用して単一スレッド実行に指定するだけでは不十分な場合もあります。よくあるのは、必ずマスタ スレッドがコードのセクションを実行するスレッドとなるようにすることです。たとえば、マスタ スレッドが GUI スレッドでり、その GUI スレッドで処理を行う必要がある場合などです。これは、#pragma omp master で行います。single と異なり、マスタ ブロックの最初と最後には暗黙のバリアはありません。
メモリ フェンスは、#pragma omp flush で実装されます。このディレクティブは、プログラムにメモリ フェンスを作成します。これは、_ReadWriteBarrier そのものの動作と実際には同等です。
スレッド チーム内のすべてのスレッドが、OpenMP プラグマに同じ順序で到達する (または、一切到達しない) 必要があることに注意してください。つまり、次のコードは正しくなく、ランタイムの動作は保証されません (この場合においては、クラッシュや停止が通常の程度を超えることはありません)。
#pragma omp parallel
{
if(omp_get_thread_num() > 3)
{
#pragma omp single // すべてのスレッドによってアクセスされない
x++;
}
}
実行環境ルーチン
OpenMP には、ここまで説明してきたプラグマの他に、OpenMP アプリケーションを作成する上で有用な実行ルーチンがあります。使用できるルーチンは大きくわけて、実行環境ルーチン、ロックおよび同期ルーチン、およびタイミング ルーチンの 3 種類です。(この記事ではタイミング ルーチンについては省略します。) OpenMP のすべてのランタイム ルーチンは、omp_ で始まり、ヘッダー ファイル omp.h で定義されます。
実行環境ルーチンが提供する機能により、OpenMP の稼働環境のさまざまな設定を照会したり、変更したりすることができます。omp_set_ で始まる関数は、並列領域の外側でのみ呼び出すことができます。その他のすべての関数は、並列領域、および非並列領域の両方で使用することができます。
スレッド チーム内のスレッド数を取得、または設定するには、omp_get_num_threads、および omp_set_num_threads のルーチンを使用します。omp_get_num_threads は、現在のスレッド チーム内のスレッド数を返します。並列領域にないスレッドで呼び出すと、1 が返されます。omp_set_num_thread は、スレッドが次に到達する並列領域で使用されるスレッドの数を設定します。
しかし、これでスレッド数が完全に決まるわけではありません。並列領域で使用されるスレッド数は、これ以外にも OpenMP 環境の 2 つの設定、動的スレッド、およびネストに影響されます。
動的スレッドは、Boolean プロパティで、既定では無効です。このプロパティが無効の状態でスレッドが並列領域に到達すると、OpenMP ランタイムは、omp_get_max_threads で指定されたスレッド数でスレッド チームを作成します。omp_get_max_threads は、既定で、コンピュータのハードウェア スレッドの数、または OMP_NUM_THREADS 環境変数の値に設定されています。動的スレッドが有効な場合、OpenMP ランタイムは、omp_get_max_ threads を超えない可変の数のスレッドでスレッド チームを作成します。
もう 1 つのネストも、Boolean プロパティで、既定では無効です。並列領域のネストは、既に並列領域内にあるスレッドが別の並列領域に到達した場合に発生します。ネストが有効な場合、新しいスレッド チームは、前の動的スレッドのセクションで指定したルールに従って形成されます。ネストが無効の場合、スレッド チームは 1 つのスレッドで形成されます。
動的スレッド、およびネストのステータスは、ランタイム ルーチン omp_set_dynamic、omp_get_dynamic、omp_set_nested、および omp_get_nested を使用して、照会、および設定できます。各スレッドもその環境を照会することができます。スレッドは、スレッド チーム内から omp_get_thread_num 呼び出しによって自分のスレッド番号を認識することができます。これは、Windows のスレッド ID ではなく、0 から omp_get_num_threads より 1 少ない値となります。
スレッドは、omp_in_parallel によって、正常に並列領域内で実行されているかを認識することができます。omp_get_num_procs では、スレッドがコンピュータのプロセッサ数を認識できます。
さまざまな環境ルーチンとのやりとりを明確にするため、図 6 を見てみましょう。このサンプルには、4 つの別個の並列領域と、2 つのネストした並列領域があります。
通常のデュアル プロセッサ コンピュータで実行すると、Visual Studio 2005 を使用してコンパイルされたプログラムは、このコードから次のような出力を行います。
Num threads in dynamic region is = 2
Num threads in non-dynamic region is = 10
Num threads in nesting disabled region is = 1
Num threads in nesting disabled region is = 1
Num threads in nested region is = 2
Num threads in nested region is = 2
最初の並列領域では、動的スレッドを有効にし、スレッド数を 10 に設定しています。プログラムの出力から、動的スレッドが有効な場合、OpenMP ランタイムは、スレッド チームに割り当てるスレッドを 2 つだけと決めていることがわかります。これは、コンピュータに 2 つのプロセッサが搭載されているためです。2 つめの並列領域では、動的スレッドが無効であるため、OpenMP が 10 個のスレッドをスレッド チームに割り当てています。
3 つ目、および 4 つ目の並列領域では、ネストの設定の有効、または無効の影響を確認することができます。並列領域 3 では、ネストを無効にしています。この場合、ネストされた並列領域には新しいスレッドが作成されません。つまり、ネストの外側の並列領域、およびネストされた並列領域の両方に対して、合計で 2 つのスレッドとなります。並列領域 4 では、ネストを有効にしています。ネストされた並列領域は、2 つのスレッドを持つスレッド チームを作成します (合計で、ネストされた並列領域には 4 つのスレッドが存在します)。ネストされた並列領域ごとにスレッドが倍増するこのプロセスは、スタック スペースを使い切るまで続きます。実際には、数百個のスレッドを生成することができますが、オーバーヘッドがパフォーマンスの利益を簡単に上回る可能性もあります。
また、並列領域 3、および 4 では、動的スレッドが有効です。次のように、動的スレッドを無効にしてコードを実行するとどうなるでしょうか。
omp_set_dynamic(0);
omp_set_nested(1);
omp_set_num_threads(10);
#pragma omp parallel
{
#pragma omp parallel
{
#pragma omp single
printf("Num threads in nested region is = %d\n",
omp_get_num_threads());
}
}
以下が予想される動作です。最初の並列領域は、10 個のスレッドを持つスレッド チームで開始します。それに続くネストされた並列領域は、外側の並列領域の 10 個のスレッドについて、それぞれ 10 個のスレッドで開始します。つまり、ネストされた並列領域内では合計 100 個のスレッドが実行され、出力は次のようになります。
Num threads in nested region is = 10
Num threads in nested region is = 10
Num threads in nested region is = 10
Num threads in nested region is = 10
Num threads in nested region is = 10
Num threads in nested region is = 10
Num threads in nested region is = 10
Num threads in nested region is = 10
Num threads in nested region is = 10
Num threads in nested region is = 10
同期およびロックのルーチン
OpenMP には、コードの同期を支援するランタイム ルーチンがあります。ロックには、単純、およびネスト可能の 2 種類があります。それぞれは、初期化済み、ロック状態、およびロック解除状態の 3 つのいずれかのステータスで存在します。
単純ロックは (omp_lock_t) では、同じスレッドからでも、複数回ロックすることはできません。ネスト可能ロック (omp_nest_lock_t) は、単純ロックと同じですが、スレッドが既にロックされているロックを設定しようとしてもそれは中止されません。さらに、ネスト可能なロックは、それが何回ロックされたかを数え、その回数を保持するリファレンスでもあります。
同期ルーチンは、これらのロックに基づいて動作します。それぞれのルーチンに、単純ロック バリアント、およびネスト可能ロック バリアントがあります。ロックで実行できるアクションには、初期化、設定 (ロックの取得)、解除 (ロックの解除)、テスト、および破棄の 5 つがあります。これらはすべて、Win32 クリティカル セクション ルーチンに厳密にマッピングされており、実際には、OpenMP は、これらのルーチンのシン ラッパーとして実装されます。図 7 に、OpenMP ルーチンと Win32 ルーチンのマッピングを示します。
開発者は、同期ランタイム ルーチン、または同期プラグマのどちらを使用するか選択することができます。プラグマの長所は、きわめて構造化されている点です。このため理解しやすく、プログラムを見て、同期される領域の始めと終わりの位置を判断するのも簡単になります。
ランタイムの長所は、その柔軟性です。ロックを別の関数に送り、その関数の中でロックを設定したり、解除したりすることができます。これは、プラグマではできません。一般的に、ランタイム ルーチンでのみ使用できる柔軟性を必要としない場合は、同期プラグマを使用します。
データ構造解析の並列実行
図 8 のコードに、並列な for ループを実行する例を 2 つ示します。このループでは、ランタイムは、最初にループに到達したときに、これから実行する繰り返しの回数を認識していません。最初の例は、STL std::vector コンテナの解析です。2 つ目は、標準的なリンクのリストです。
サンプルの STL コンテナの部分では、スレッド チームのすべてのスレッドが for ループを実行し、各スレッドがそれぞれ反復子を保持します。しかし、繰り返しごとに 1 つのスレッドのみが、1 つのブロックに入ります (これが single のセマンティクスです)。OpenMP ランタイムは、1 つのブロックが 1 回だけ、および繰り返しごとに 1 回だけ実行されるように制御します。この繰り返しの方法は、ランタイムのオーバーヘッドが大きくなるため、 process の関数で多くの処理が発生する場合にのみ有用です。リンク リストの例もこれと同じロジックを使用しています。
また、STL ベクトルの部分で、ループに入る前に必要な繰り返しの回数を判断するために、std::vector.size を使用するようにコードを書き換えることができます。これにより、ループを正規化された OpenMP for ループの形式にできます。これを次のコードに示します。
#pragma omp parallel for
for(int i = 0; i < xVect.size(); ++i)
process(xVect[i]);
この方法は、OpenMP ランタイムのオーバーヘッドがより少ないため、配列、ベクトル、および正規化された OpenMP for ループの形式で処理できる他のすべてのコンテナについて、この方法を推奨します。
拡張スケジューリング アルゴリズム
既定では、OpenMP の並列化された for ループは、static スケジューリングと言われるアルゴリズムでスケジュールされます。これは、スレッド チームの各スレッドに、同じ個数の繰り返しが指定されることを意味します。n 個の繰り返しがあり、スレッド チーム内に T 個のスレッドが存在する場合、各スレッドは n/T 個の繰り返しを実行します (もちろん、OpenMP は n が T で割り切れない場合でも正常に処理します)。OpenMP のスケジューリングには、これ以外にも、多くの状況に対応できるように、dynamic、runtime、および guided のメカニズムがあります。
これらのいずれかのスケジューリング メカニズムを指定するには、#pragma omp for、または #pragma omp parallel for ディレクティブに schedule 句を使用します。schedule 句はの形式は次のとおりです。
schedule(schedule-algorithm[, chunk-size])
以下に、プラグマの使用の例をあげます。
#pragma omp parallel for schedule(dynamic, 15)
for(int i = 0; i < 100; ++i)
{ ...
#pragma omp parallel
#pragma omp for schedule(guided)
dynamic スケジューリングは、各スレッドが、chunk-size で指定されたスレッドの数を実行するようにスケジュールします (chunk-size が指定されない場合は、既定で 1 が指定されます)。スレッドは、指定された繰り返しの実行が終了すると、chunk-size の個数のセットで次の繰り返しを要求します。これが、すべての繰り返しが完了するまで続きます。繰り返しの最後のセットの個数は、chunk-size より少なくなる場合があります。
guided スケジューリングは、各スレッドが次の式に従ってスレッドの数を実行するようにスケジュールします。
iterations_to_do = max(iterations_not_assigned/omp_get_num_threads(),
chunk-size)
スレッドは、指定された繰り返しの実行が終了すると、次の繰り返しを iterations_to_do 式に基づいた個数のセットで要求します。つまり、各スレッドに割り当てられる繰り返しの個数は、徐々に少なくなります。スケジュールされる繰り返しの最後のセットの個数は、iterations_to_do 関数で定義された値よりも少なくなる場合があります。
以下に、100 回の繰り返しを行う for ループを、#pragma omp for schedule(dynamic, 15) を使用して 4 つのスレッドにスケジュールした場合の処理を示します。
スレッド 0 が 繰り返し 1 から 15 を取得
スレッド 1 が 繰り返し 16 から 30 を取得
スレッド 2 が 繰り返し 31 から 45 を取得
スレッド 3 が 繰り返し 46 から 60 を取得
スレッド 2 が終了
スレッド 2 が 繰り返し 61 から 75 を取得
スレッド 3 が終了
スレッド 3 が 繰り返し 76 から 90 を取得
スレッド 0 が終了
スレッド 0 が 繰り返し 91 から 100 を取得
次に、100 回の繰り返しを行う for ループを、#pragma omp for schedule(guided, 15) を使用して 4 つのスレッドにスケジュールした場合の処理を示します。
スレッド 0 が 繰り返し 1 から 25 を取得
スレッド 1 が繰り返し 26 から 44 を取得
スレッド 2 が繰り返し 45 から 59 を取得
スレッド 3 が繰り返し 60 から 64 を取得
スレッド 2 が終了
スレッド 2 が繰り返し 65 から 79 を取得
スレッド 3 が終了
スレッド 3 が繰り返し 80 から 94 を取得
スレッド 2 が終了
スレッド 2 が繰り返し 95 から 100 を取得
dynamic スケジューリング、および guided スケジューリングは、どちらも、各繰り返しでの処理量が一定でない場合、またはプロセッサの速度が同じでない場合に適したメカニズムです。static スケジューリングには、繰り返しの負荷分散を行う手段はありまません。dynamic スケジューリング、および guided スケジューリングは、それぞれの動作の特徴に応じて、自動的に繰り返しの負荷分散を行います。通常、guided スケジューリングの方が dynamic スケジュールよりも、スケジューリングに関連するオーバーヘッドが少ないため、パフォーマンスが良くなります。
最後のスケジューリング アルゴリズムは、runtime スケジューリングです。これは、実際には、スケジューリング アルゴリズムというよりは、前に説明した 3 つのスケジューリング アルゴリズムを動的に選択する手法です。schedule 句に runtime が指定された場合、OpenMP ランタイムは、その for ループに、OMP_SCHEDULE 環境変数に指定されたスケジューリング アルゴリズムを使用します。OMP_SCHEDULE 環境変数の形式は、type[,chunk size] です。次のようにします。
set OMP_SCHEDULE=dynamic,8
runtime スケジューリングを使用することにより、エンド ユーザーが、使用されるスケジューリングの種類をある程度自由に決めることができます。既定の設定は、static スケジューリングです。
OpenMP を使用する判断
どのようなときに OpenMP を使用すればよいかを理解しておくことは、どのように使用するかを理解すること同じくらい重要です。一般的に、この判断に際して役立つガイドラインを次にあげます。
対象プラットフォームがマルチコア、またはマルチプロセッサである このような場合、アプリケーションが 1 つのコア、または 1 つのプロセッサに対して飽和状態であるなら、アプリケーションを OpenMP でマルチスレッド化することにより、多くの場合は、アプリケーションのパフォーマンスが向上します。
クロスプラットフォームなアプリケーションである OpenMP は、クロスプラットフォームであり、広くサポートされている API です。API はプラグマによって実装されるので、OpenMP 標準を認識しないコンパイラでもアプリケーションはコンパイルできます。
ループが並列可能である OpenMP は、ループの並列化に最も適しています。ループの繰り返しに依存がないループがアプリケーションに含まれる場合には、OpenMP の使用は最適な選択となります。
最後の最適化が必要である OpenMP は、アプリケーションの設計変更を必要としないので、付加的なパフォーマンス改善のために外側から少し変更するのに最適なツールです。
しかし、OpenMP は、マルチスレッドに関するすべての問題を解決することを目的とはしていません。OpenMP の性質には、いくつかの傾向があります。OpenMP は、ハイパフォーマンス コンピューティング分野での使用を対象として開発されているので、共有配列のデータを処理するループの負荷が大きくなるようなプログラミングでの使用に最も適しています。
普通のスレッドの作成にコストがかかるように、OpenMP 並列領域の作成にもコストがかかります。OpenMP でパフォーマンスを向上させるには、領域を並列化することによって得られる高速化が、スレッド チームの開始にかかるコストを上回らなければなりません。Visual C++ 実装は、最初の並列領域に到達したときにスレッド チームを作成します。その後は、スレッド チームは、再び必要となるまで停止されています。この水面下で、OpenMP は、Windows スレッド プールを使用します。図 9 に、OpenMP を使用して、2 つのプロセッサのシステムで 処理を行った際の繰り返しの回数による加速の様子を示します。この実験には、記事の最初に紹介した簡単なプログラムを使用しています。パフォーマンスのピークは 1.7x のあたりです。これは、2 つのプロセッサ システムでの一般的な値です。(パフォーマンスの違いをよりはっきりさせるため、y 軸には、順次処理のパフォーマンスと並列処理のパフォーマンスの比率を表示しています。) 繰り返しが 5,000 回を超えたあたりで、並列バージョンの速さが順次バージョンと同じになっています。しかし、これではまだ最悪のケースです。ループの並列を増やせば、より少ない回数で、順次バージョンの速度より速くなります。これは、単純に、各繰り返しが実行する処理の量によって決まります。しかし、このグラフは、パフォーマンスの測定がなぜ重要であるかを示しています。OpenMP の使用は、パフォーマンスの向上を保証するものではありません。
図 9 2 つのプロセッサでの順次と並列のパフォーマンス
OpenMP プラグマは、使用するのは簡単ですが、エラーが発生した場合のフィードバックにはあまり優れていません。たとえば、ミッションクリティカルなアプリケーションを作成し、欠陥がある場合にはそれが検出され、さらに十分にリカバーされるようにする必要がある場合には、おそらく、OpenMP は適したツールではありません (少なくとも、OpenMP の現在の形ではそうです)。たとえば、OpenMP が並列領域のスレッドを作成できない場合、またはクリティカル セクションを作成できない場合、その動作は定義されていません。Visual C++ 2005 では、OpenMP ランタイムは、最終的にこれを回避する前に、しばらくトライを繰り返します。OpenMP の今後のバージョンに求められるものの 1 つが、エラーをレポートするメカニズムです。
もう 1 つ注意する必要があるのは、Windows スレッドを OpenMP スレッドと一緒に使用する場合です。OpenMP スレッドは、Windows スレッドの上に作成されるので、同じプロセス内でも正常に実行できます。問題は、OpenMP が作成していない Windows スレッドを、OpenMP が認識しないことです。これにより、2 つの問題が発生します。OpenMP ランタイムは、他の Windows スレッドを "count" の一部に保持しません。また、OpenMP 同期ルーチンは、Windows スレッドがスレッド チームに含まれないため、Windows スレッドを同期しません。
アプリケーションで OpenMP を使用する際の注意点
OpenMP では簡単にアプリケーションに並列処理を追加できますが、注意すべき点がいくつかあります。最も外側にあるループのインデックス変数は、プライベートですが、ネストされているループのインデックス変数は、既定で共有です。ループをネストさせる場合、通常は、内部のループのインデックス変数をプライベートにします。これらの変数を指定するときには、private 句を使用してください。
OpenMP アプリケーションでは、C++ 例外をスローするときに注意する必要があります。具体的には、アプリケーションが並列領域で例外をスローする場合、それは、同じ並列領域の同じスレッドによって処理される必要があります。つまり、例外で領域を出るべきではありません。一般的には、並列領域で例外の可能性がある場合には、そのキャッチを存在させる必要があります。例外がスローされた並列領域でキャッチされない場合、アプリケーションはおそらく停止します。
構造化ブロックを開始する #pragma omp <directive> [clause] ステートメントの最後は、改行であり、かっこではありません。かっこで終わるディレクティブは、コンパイラ エラーとなります。次に例を示します。
// 間違っているかっこ
#pragma omp parallel
{
// コード (コンパイルされない)
}
// 正しいかっこ
#pragma omp parallel
{
// コード
}
Visual Studio 2005 での OpenMP アプリケーションのデバッグは、複雑になる場合があります。特に、F10 および F11 での並列領域に対するステップ イン、およびステップ オーバーは、あまり使いやすいとは言えません。これは、コンパイラがランタイムの呼び出し、およびスレッド チームの開始を行うためにコードを追加するためです。デバッガは、これを認識していないので、プログラマには奇妙な動きに見えます。並列領域にブレークポイントを置き、F5 を使用してそこまで行くことをお勧めします。並列領域を抜けるには、並列領域の外にブレークポイントを置き、F5 を使用します。
並列領域内からは、デバッガ "Threads Window" に、スレッド チームで実行されているさまざまなスレッドが表示されます。スレッド ID は、OpenMP スレッド ID とは関係がありませんが、OpenMP スレッドは Windows スレッドの上に作成されるので、その Windows スレッド ID を反映しています。
OpenMP は、現在 Profile Guided Optimization (PGO) にはサポートされていません。OpenMP は、プラグマに基づいているので、/openmp、または PGO の両方でアプリケーションをコンパイルし、どの方法のパフォーマンスが最も高いかを判断することができます。
OpenMP と .NET
ハイパフォーマンス コンピューティングと .NET という 2 つ言葉は、あまりイメージがつながりませんが、Visual C++ 2005 はこの分野において大きく進歩しています。この 1 つとして、OpenMP がマネージ C++ コードを処理できるようになりました。/openmp スイッチは、/clr、および /clr:OldSyntax に対応しています。つまり、OpenMP を使用して .NET タイプのメソッドから並列処理を行うことができ、これは、ガーベジ コレクションの対象となります。/openmp は、現在は、/clr:safe、または /clr:pure に対応していないことに注意してください。しかし、今後は対応する予定です。
OpenMP とマネージ コードの使用については、注意すべき制限があります。OpenMP を使用するアプリケーションは、単一アプリケーション ドメインのプログラム内でのみ使用される必要があります。既にロードされている OpenMP ランタイムで別のアプリケーション ドメインがプロセスにロードされると、アプリケーションが停止する可能性があります。
OpenMP は、アプリケーションを並列化するための簡単で強力なテクノロジです。データ処理ループや、データの関数ブロックを並列化することができます。既存のアプリケーションに簡単に組み込むことができ、コンパイラ スイッチだけで使用するか、しないかを切り替えられます。OpenMP を使用すれば、簡単に、マルチコア CPU の処理性能を十分に活用することができます。詳細については、OpenMP の仕様を参照することを強くお勧めします。マルチスレッドを楽しんでみましょう。
Kang Su Gatlin は、マイクロソフトの Visual C++ チームのプログラム マネージャとして、プログラムをより高速にする体系的な方法の開発に携わっています。マイクロソフトの前には、ハイパフォーマンスなグリッド コンピューティングに関連する仕事をしていました。
Pete Isensee は、マイクロソフトの Xbox アドバンスト テクノロジ グループの開発マネージャです。彼は、12 年間にわたりゲーム業界で仕事をしており、最適化とパフォーマンスについてカンファレンスなどで講演を行っております。
この記事は、 MSDN マガジン - 2005 年 10 月号からの翻訳です。 .
Back to top |