はじめに
概要
論理エラー
1. /Qopenmp (-openmp) の追加漏れ
2. parallel の追加漏れ
3. omp の追加漏れ
4. for の追加漏れ
5. 不要な最適化
6. ordered の誤った使用
7. 並列セクションでのスレッド数の再定義
8. 初期化されていないロック変数の使用
9. 別のスレッドによるアンロック
10. バリアとしてのロックの使用
11. スレッド数への依存性
12. 動的スレッド生成の誤った使用
13. 共有リソースの同時使用
14. 保護されていない共有メモリーアクセス
15. 参照型での flush の使用
16. flush の追加漏れ
17. 同期の追加漏れ
18. すべてのユニットで threadprivate として指定されていない外部変数
19. 初期化されていないローカル変数
20. 忘れられた threadprivate 指示句
21. 忘れられた private 節
22. private 変数を使用した誤ったワークシェアリング
23. lastprivate 節の不注意な使用
24. 並列セクション開始時の予期しない threadprivate 変数の値
25. プライベート変数の制約
26. プライベートとして宣言されていないプライベート変数
27. 反復順序が設定されていない配列の並列処理
パフォーマンス・エラー
1. 不要な flush
2. atomic 指示句の代わりとしてのクリティカル・セクションまたはロックの使用
3. 不要なメモリーへの同時書き込み保護
4. 多すぎるクリティカル・セクションでの処理
5. 多すぎるクリティカル・セクションへの入口
まとめ
参考文献
はじめに
マルチコアシステムが急速な広がりをみせており、並列プログラミングへの早急な対応が求められています。しかしながら、経験豊富な多くの開発者にとってもこれは新しい分野です。既存のコンパイラーとコード・アナライザーでも、並列コードの開発中に一部の問題を発見することはできますが、多くの問題は検出できません。この記事では、OpenMP* を使用する並列プログラムが正しく動作しない原因となるさまざまな問題について説明します。
概要
並列プログラミングが登場したのはかなり以前のことです。1960 年代に最初のマルチプロセッサー・コンピューターが登場しましたが、これまでプロセッサーのパフォーマンス向上は、クロック周波数の上昇によるところが大きく、最近になるまでマルチプロセッサー・システムは珍しいものでした。ところが近年では、クロック周波数の上昇幅が小さくなり、プロセッサーのパフォーマンス向上は複数のコアによるところが大きくなってきています。マルチコア・プロセッサーが急速に普及する中、並列プログラミングへの早急な対応が求められています。
以前は、クロック周波数の高い CPU や大きなキャッシュメモリーを搭載することで、プログラムのパフォーマンスを十分に向上することができました。しかし、最近ではこの方法が通用しなくなり、プログラムのパフォーマンスを向上するにはプログラムの変更が不可欠です。
並列プログラミングは最近になってようやく注目され始めたため、既存のアプリケーションの並列化や新しい並列プログラムの作成は、経験豊富な開発者にとっても新しいものであり、課題が山積されています。既存のコンパイラーやコード・アナライザーでは、潜在的な問題のごく一部しか見つけることができません。残りの問題は検出されないままになるため、デバッグとテストにかかる時間が大幅に増加します。さらに、これらの問題のほとんどは常に再現できるものではありません。
ここでは、通常速い動作が求められる C++ プログラムを例として OpenMP* の使用について説明します。また、Visual Studio* 2005/2008/2010 は OpenMP* 2.0 仕様をサポートしているので、OpenMP* テクノロジーについても説明します。OpenMP* は、最小限の労力でコードを並列化できるようにします。/openmp コンパイラー・オプションを有効にし、プログラムの実行フローの並列化方法を指示するコンパイラー指示句をコードに追加するだけです。
この記事では、コンパイラーや静的/動的コード・アナライザーによって検出されない潜在的な問題をいくつか説明します。この記事が、並列プログラム開発の特性を理解し、多くの問題を回避する上で役立つことを願っています。
また、この記事には VivaMP スタティック・アナライザーの開発に使用された調査結果 (http://www.viva64.com/vivamp.php (英語)) が含まれています。このスタティック・アナライザーは、OpenMP* を使用する並列プログラムの問題を検出するように設計されています。この記事についてのフィードバックや、並列プログラミング問題のパターンを是非お寄せください。
ここでは、問題を論理エラーとパフォーマンス・エラーに分けています。参考文献 [1] でも、これと似た方法が使用されています。論理エラーは、予期しない結果を引き起こす (つまり、プログラムが正しく動作しなくなる) 問題を指します。パフォーマンス・エラーは、プログラムのパフォーマンスを低下させる問題を指します。
まず最初に、この記事で使用する用語を以下のように定義します。
「指示句」は、コードの並列化を定義する OpenMP* 指示句を指します。すべての OpenMP* 指示句は #pragma omp … 形式で表されます。
「節」は、OpenMP* 指示句を補助するものです。スレッド間での処理の分配方法、スレッド数、各種アクセスモードなどを定義します。
「並列セクション」は、#pragma omp parallel 指示句が適用されるコード部分を指します。
この記事は、OpenMP* の知識があり、プログラム開発で使用したことがある開発者を対象としています。OpenMP* に詳しくない方は、先にこちらの資料 [2] をご覧になることを推奨します。OpenMP* 指示句、節、関数、および環境変数の詳細は、OpenMP* 2.0 仕様 [3] を参照してください。また、MSDN ライブラリにもこの仕様が掲載されています。
それでは、標準のコンパイラーによって正しく検出されない、あるいは全く検出されない潜在的な問題について説明していきましょう。
論理エラー
1. /Qopenmp (-openmp) の追加漏れ
まずは単純な問題から見てみましょう。コンパイラーの設定で OpenMP* サポートが有効にされていないと、OpenMP* 指示句は無視されます。コンパイラーは問題を報告せず、警告も出力されません。開発者の期待どおりにコードが動作しないだけです。
OpenMP* サポートは、[Projects (プロジェクト)] > [Properties (プロパティ)] > [Configuration Properties (構成プロパティ)] > [C/C++] > [Language (言語)] から有効にすることができます。
2. parallel の追加漏れ
OpenMP* 指示句の形式はやや複雑なため、最初に形式の誤りによる単純な問題を見てみましょう。以下に、同じコードを使って誤った形式と正しい形式を示します。
誤ったコード:
#pragma omp for ... //コード
正しいコード:
#pragma omp parallel for ... // コード
#pragma omp parallel { #pragma omp for ... //コード }
1 つ目のコードは正常にコンパイルされますが、#pragma omp for 指示句はコンパイラーによって無視されます。そのため、ループは 1 つのスレッドにより実行されます。この問題を開発者が見つけるのは困難です。#pragma omp parallel for 指示句だけでなく、#pragma omp parallel sections 指示句で問題が発生することもあります。
3. omp の追加漏れ
OpenMP* 指示句の omp キーワードを追加し忘れると、前述のような問題が発生します [4]。以下の単純な例を見てみましょう。
誤ったコード:
#pragma omp parallel num_threads(2) { #pragma single { printf("me\n"); } }
正しいコード:
#pragma omp parallel num_threads(2) { #pragma omp single { printf("me\n"); } }
“me” という文字列は 1 回ではなく、2 回出力され、コンパイラーにより「警告 C4068: 不明なプラグマ」が報告されます。ただし、この警告はプロジェクト・プロパティーで無効にすることも、あるいは開発者によって無視されることもあります。
4. for の追加漏れ
#pragma omp parallel 指示句は、1 つのコード行に適用することも、コード範囲に適用することもできます。これは、次のような for ループで予期しない動作を引き起こすことがあります。
#pragma omp parallel num_threads(2) for (int i = 0; i < 10; i++) myFunc();
ループを 2 つのスレッドで実行する場合は、#pragma omp parallel for 指示句を使用すべきです。この場合、ループは 10 回実行されます。しかし、上記の例では、スレッドごとにループが 10 回ずつ実行され、myFunc 関数は合計 20 回も呼び出されます。以下に正しいコードを示します。
#pragma omp parallel for num_threads(2) for (int i = 0; i < 10; i++) myFunc();
5. 不要な最適化
#pragma omp parallel 指示句を大きなコード範囲に適用すると、次のように予期しない動作を引き起こすことがあります。
#pragma omp parallel num_threads(2) { ... // N code lines #pragma omp parallel for for (int i = 0; i < 10; i++) { myFunc(); } }
上記のコードでは、並列セクション内であることを忘れてしまったか、あるいは OpenMP* に慣れていないために、ループを 2 つのスレッドで実行しようとして、並列セクション内に parallel キーワードを追加しています。このコードを実行すると、前述の例と同じように myFunc 関数は 10 回ではなく、20 回実行されます。以下に正しいコードを示します。
#pragma omp parallel num_threads(2) { ... // N code lines #pragma omp for for (int i = 0; i < 10; i++) { myFunc(); } }
6. ordered の誤った使用
OpenMP* に慣れていない開発者は、ordered 指示句の使い方を間違えてしまうことがあります [1]。次の例について考えてみましょう。
誤ったコード:
#pragma omp parallel for ordered for (int i = 0; i < 10; i++) { myFunc(i); //スコープが指定されていないため順番は不定 }
正しいコード:
#pragma omp parallel for ordered for (int i = 0; i < 10; i++) { #pragma omp ordered { //この領域を順番通りに実行する myFunc(i); } }
1 つめのコードでは、ordered 節はスコープが指定されていないため無視されます。ループは実行されるものの、反復の順序はランダムです (場合によっては、たまたま昇順で実行されることもあるでしょう)。
7. 並列セクションでのスレッド数の再定義
ここでは、OpenMP* 仕様の理解不足が原因である、もう少し複雑な問題について見てみましょう。OpenMP* 2.0 仕様 [3] では、並列セクション内でスレッド数を再定義することはできません。再定義しようとすると、ランタイムエラーが発生するか、スレッド数の変更は無視されます。これはコンパイラーの実装に依存します。次に例を示します。
誤ったコード:
#pragma omp parallel { omp_set_num_threads(2); #pragma omp for for (int i = 0; i < 10; i++) { myFunc(); } }
正しいコード:
#pragma omp parallel num_threads(2) { #pragma omp for for (int i = 0; i < 10; i++) { myFunc(); } }
正しいコード:
omp_set_num_threads(2) #pragma omp parallel { #pragma omp for for (int i = 0; i < 10; i++) { myFunc(); } }
8. 初期化されていないロック変数の使用
OpenMP* 2.0 仕様 [3] では、すべてのロック変数は (変数の種類に応じて) omp_init_lock または omp_init_nest_lock 関数呼び出しを使用して初期化する必要があります。初期化していないと、ロック変数を使用することはできません。C++ プログラムで初期化されていないロック変数を使用 (set、unset、test) しようとすると、ランタイムエラーが発生します。
誤ったコード:
omp_lock_t myLock; #pragma omp parallel num_threads(2) { ... omp_set_lock(&myLock); ... }
正しいコード:
omp_lock_t myLock; omp_init_lock(&myLock); #pragma omp parallel num_threads(2) { ... omp_set_lock(&myLock); ... }
9. 別のスレッドによるアンロック
1 つのスレッドでロックを設定し、別のスレッドでこのロックを解除しようとすると、予期しない動作を引き起こします [3]。次の例について考えてみましょう。
誤ったコード:
omp_lock_t myLock; omp_init_lock(&myLock); #pragma omp parallel sections { #pragma omp section { ... omp_set_lock(&myLock); ... } #pragma omp section { ... omp_unset_lock(&myLock); ... } }
このコードは、C++ プログラムでランタイムエラーを引き起こします。ロックの設定/解除操作はクリティカル・セクションの開始/終了に似ており、ロックを使用するスレッドが両方の操作を行わなければなりません。以下に正しいコードを示します。
正しいコード:
omp_lock_t myLock; omp_init_lock(&myLock); #pragma omp parallel sections { #pragma omp section { ... omp_set_lock(&myLock); ... omp_unset_lock(&myLock); ... } #pragma omp section { ... omp_set_lock(&myLock); ... omp_unset_lock(&myLock); ... } }
10. バリアとしてのロックの使用
omp_set_lock 関数は、ロック変数が利用可能になるまで (つまり、同じスレッドが omp_unset_lock 関数を呼び出すまで) ほかのスレッドの実行をブロックします。前述したとおり、同じスレッドが両方の関数を呼び出さなければなりません。OpenMP* に慣れていない開発者は、#pragma omp sections 指示句が適用される並列セクション内では #pragma omp barrier 指示句を使用できないため、代わりに omp_set_lock 関数をバリアとして使用し、以下のようなコードを記述することがあります。
誤ったコード:
omp_lock_t myLock; omp_init_lock(&myLock); #pragma omp parallel sections { #pragma omp section { ... omp_set_lock(&myLock); ... } #pragma omp section { ... omp_set_lock(&myLock); omp_unset_lock(&myLock); ... } }
このプログラムは、正しく実行される場合もありますが、そうでない場合もあります。これは、どのスレッドが最初に実行を完了するかによります。ロックを取得したスレッドがロックを解除せずに最初に実行を完了すれば、プログラムは期待どおりに動作します。それ以外の場合は、ロックを誤って使用しているスレッドがそのロックを解除するまで、プログラムは待機し続けます。
同様の問題は、ループ内に omp_test_lock 関数の呼び出しを記述した場合 (これは、通常この関数を呼び出す方法です) にも発生します。この場合は、いつまでもロックが解除されないため、ループによりプログラムがハングアップします。
この問題は前述の問題に似ているため、正しいコードは前述のものと同じです。
正しいコード:
omp_lock_t myLock; omp_init_lock(&myLock); #pragma omp parallel sections { #pragma omp section { ... omp_set_lock(&myLock); ... omp_unset_lock(&myLock); ... } #pragma omp section { ... omp_set_lock(&myLock); ... omp_unset_lock(&myLock); ... } }
11. スレッド数への依存性
プログラムの実行時に生成されるスレッド数は、一般に定数値ではありません。通常、デフォルトではプロセッサー・コア数と同じです。ただし、omp_set_num_threads 関数を使用したり、それよりも優先度の高い num_threads 節を使用したり、あるいは最も優先度の低い OMP_NUM_THREADS 環境変数を使用して、開発者がスレッド数を指定することもできます。そのため、並列セクションを実行中のスレッド数は一定ではありません。また、スレッド数はマシンによっても異なることがあります。コードの動作は、必要な場合を除き、コードを実行するスレッド数に依存すべきではありません。
ある記事 [5] で紹介されている例について考えてみましょう。このプログラムで、開発者はすべてのアルファベットを出力しようとしています。
誤ったコード:
omp_set_num_threads(4); #pragma omp parallel { int LettersPerThread = 26 / omp_get_num_threads(); int ThisThreadNum = omp_get_thread_num(); int StartLetter = 'a' + ThisThreadNum * LettersPerThread; int EndLetter = 'a' + ThisThreadNum * LettersPerThread + LettersPerThread; for (int i=StartLetter; i<EndLetter; i++) printf ("%c", i); }
しかし、このコードでは 26 個のアルファベットのうち 24 個しか出力されません。これは、26 (アルファベットの総数) は 4 (スレッド数) の倍数ではないからです。そのため、最後の 2 文字が出力されません。この問題を修正するには、スレッド数に依存しないようにコードを大幅に変更するか、正しいスレッド数 (例えば 2 スレッド) に処理が分配されるようにします。スレッド数を指定せずに、コンパイラーにスレッド間の処理の分配を任せる場合は、以下のようにコードを修正します。
正しいコード:
omp_set_num_threads(4); #pragma omp parallel for for (int i = 'a'; i <= 'z'; i++) { printf ("%c", i); }
このコードでは、ループのすべての反復が確実に実行されます。schedule 節を使用することで、スレッド間での反復の分配方法を指定することもできます。このコードでは、コンパイラーによりスレッド間の処理の分配が行われるため、最後の 2 回の反復も必ず実行されます。さらに、修正後のコードのほうが簡潔で分かりやすくなっています。
12. 動的スレッド生成の誤った使用
dynamic キーワードは、schedule(dynamic) 節と (より問題の少ない) OMP_DYNAMIC 環境変数という異なる 2 つの OpenMP* のコンテキストで使用されます。この 2 つの相違点を理解することは重要です。schedule(dynamic) 節は OMP_DYNAMIC 変数が true の場合のみ使用できるわけではありません。実は、この 2 つには全く関連性がありません。
schedule(dynamic) 節は、ループの反復をスレッド間で動的に分配されるチャンク (小さな単位) に分けます。チャンクの実行が完了すると、そのスレッドは残りの「部分」の実行を開始します。前述の例にこの節を適用すると、4 つのスレッドはそれぞれ 6 文字ずつ出力し、最初に処理を完了したスレッドが最後の 2 文字を出力します。
OMP_DYNAMIC 変数は、コンパイラーがスレッド数を動的に定義できるかどうか指定します。この変数は num_threads 節よりも優先度が高いため、それが原因で問題が発生する可能性があります。この変数が true に設定されている場合は、num_threads、omp_set_num_threads、および OMP_NUM_THREADS の設定よりも優先され、プログラムの動作がスレッド数に依存している場合は、予期しない結果を引き起こす可能性があります。これも、スレッド数に依存しないコードを作成したほうが良い理由の 1 つです。
Visual Studio* 2008 や 2010 ではデフォルトで OMP_DYNAMIC 環境変数は false に設定されています。ただし、将来このデフォルト設定が変更されない保証はありません。OpenMP* 仕様 [3] では、この変数の値は実装固有とされています。そのため、前述の例でコードを大幅に変更する代わりに、正しいスレッド数を使用するより簡単な方法で修正する場合は、スレッド数が常にコードで使用する値と同じにならなければなりません。そうでない場合は、4 プロセッサーのマシンではコードが正しく動作しません。
正しいコード:
if (omp_get_dynamic()) omp_set_dynamic(0); omp_set_num_threads(2); #pragma omp parallel private(i) { int LettersPerThread = 26 / omp_get_num_threads(); int ThisThreadNum = omp_get_thread_num(); int StartLetter = 'a' + ThisThreadNum * LettersPerThread; int EndLetter = 'a' + ThisThreadNum * LettersPerThread + LettersPerThread; for (i=StartLetter; i<EndLetter; i++) printf ("%c", i); }
13. 共有リソースの同時使用
前述の例を変更して、1 文字ずつランダムに出力する代わりに、一度に少なくとも 2 文字以上を出力するようにすると、共有リソースの同時使用という別の並列プログラミングの問題が発生します。この場合、アプリケーションのコンソールが共有リソースにあたります。ある記事 [6] で紹介されている例に少し手を加えたものについて考えてみましょう。
誤ったコード:
#pragma omp parallel num_threads(2) { printf("Hello World\n"); }
開発者の意図とは異なり、このコードは 2 プロセッサーのマシンで次のような 2 行を出力します。
HellHell oo WorWlodrl d
これは、文字列の出力操作がアトミックに行われていないためで、 2 つのスレッドが同時に文字を出力しているからです。標準出力スレッド (cout) や、共有変数としてスレッドにアクセス可能なその他のオブジェクトを使用する場合も、同じ問題が発生します。この問題は、OS やランタイム・ライブラリーの違いで上記のように再現しないことがあります。
共有オブジェクトの状態を変更するアクションを 2 つのスレッドから実行しなければならない場合は、そのアクションを同時に実行できるスレッドを 1 つに制限する必要があります。そのためには、ロックやクリティカル・セクションを使用します。最良の方法については後述します。
正しいコード:
#pragma omp parallel num_threads(2) { #pragma omp critical { printf("Hello World\n"); } }
14. 保護されていない共有メモリーアクセス
この問題について説明した記事 [1] があります。この問題は、前述の問題に似ており、複数のスレッドが同時に 1 つの変数の値を変更する場合、結果は予測できません。ただし、問題の解決方法は若干異なります。変数に対する操作はアトミックなので、この場合は atomic 指示句を使用したほうが良いでしょう。このアプローチは、クリティカル・セクションよりもパフォーマンスが優れています。共有メモリーの保護に関する推奨事項については後述します。
誤ったコード:
int a = 0; #pragma omp parallel { a++; }
正しいコード:
int a = 0; #pragma omp parallel { #pragma omp atomic a++; }
別の解決方法として、reduction 節を使用することができます。この場合、スレッドごとに変数のプライベート・コピーが作成され、各スレッドでそのコピーに対して必要な操作を行い、その後すべてのコピーをマージするために指定された操作を実行します。
正しいコード:
int a = 0; #pragma omp parallel reduction(+:a) { a++; } printf("a=%d\n", a);
上記のコードは 2 つのスレッドによって実行され、文字列 “a=2” を出力します。
15. 参照型での flush の使用
flush 指示句は、すべてのスレッドに共有変数の値を更新するように指示します。例えば、あるスレッドが共有変数に 1 を設定した場合、その変数を読み取るほかのスレッドが必ず 1 を取得する保証はありません。この指示句は、変数の値のみを更新することに注意してください。アプリケーションのコードにオブジェクトへの共有参照が含まれている場合、flush 指示句はその参照の値 (メモリーアドレス) のみを更新し、オブジェクトの状態は更新しません。また、OpenMP* 仕様 [3] では、flush 指示句の引数は参照であってはならないことが明記されています。
誤ったコード:
MyClass* mc = new MyClass(); #pragma omp parallel sections { #pragma omp section { #pragma omp flush(mc) mc->myFunc(); #pragma omp flush(mc) } #pragma omp section { #pragma omp flush(mc) mc->myFunc(); #pragma omp flush(mc) } }
以下のコードには 2 つの問題があります。前述の共有オブジェクトへの同時アクセスと参照型での flush 指示句の使用です。そのため、myFunc メソッドがオブジェクトの状態を変更すると、コードの実行結果は予測できません。この問題を回避するためには、共有オブジェクトの同時使用を排除しなければなりません。flush 指示句は、クリティカル・セクションの入口と出口で暗黙に実行されます (これについては後述します)。
正しいコード:
MyClass* mc = new MyClass(); #pragma omp parallel sections { #pragma omp section { #pragma omp critical { mc->myFunc(); } } #pragma omp section { #pragma omp critical { mc->myFunc(); } } }
16. flush の追加漏れ
OpenMP* 仕様 [3] では、多くの場合この指示句は暗黙に指定されます (暗黙に指定されるケースのリストは後述します)。そのため、明示的に追加しなければならない場所で、開発者がこの指示句を追加し忘れてしまうことがあります。次の場合には、flush 指示句は暗黙に指定されません。
- for への入口
- master への入口と出口
- sections への入口
- single への入口
- for、single、sections からの出口 (nowait 節が適用される場合。nowait 節は、暗黙の flush と barrier を削除します。)
誤ったコード:
int a = 0; #pragma omp parallel num_threads(2) { a++; #pragma omp single { cout << a << endl; } }
正しいコード:
int i = 0; #pragma omp parallel num_threads(2) { a++; #pragma omp single { #pragma omp flush(a) cout << a << endl; } }
上記のコードは flush 指示句を使用していますが、まだ問題が残っています。同期が追加されていません。
17. 同期の追加漏れ
flush 指示句の使用に加えて、開発者はスレッド間の同期も考慮する必要があります。
前述の正しいコード例では、アプリケーションのコンソールウィンドウに文字列 “2” が必ず出力される保証はありません。セクションを実行するスレッドは、出力操作が実行されたときの変数の値を出力します。しかし、両方のスレッドが同時に single 指示句に到達する保証はありません。そのため、この変数の値は “1” にも、”2″ にもなりえます。
これは、スレッド間の同期が行われていないためです。single 指示句は、1 つのスレッドだけが指示句に続くセクションを実行できることを示します。このセクションが最初のスレッドによって実行される確率は半分で、その場合、文字列 “1” が出力されます。同様の問題を説明した記事 [7] があります。
暗黙の barrier 指示句による暗黙の同期は、nowait 節が適用されていない場合に (nowait 節は暗黙の barrier を削除するため) for、single、sections 指示句からの出口でのみ実行されます。それ以外の場合は、開発者が同期を追加する必要があります。
正しいコード:
int i = 0; #pragma omp parallel num_threads(2) { a++; #pragma omp barrier #pragma omp single { cout<< a <<endl; } }
上記が完全に正しいコードです。このプログラムは常に文字列 “2” を出力します。このコードでは、barrier 指示句に flush 指示句が暗黙に含まれているため、明示的に flush 指示句を指定していません。
次に、別の同期の追加漏れの例を見てみましょう。以下のコード例は、MSDN Library [8] に掲載されているものです。
誤ったコード:
struct MyType { ~MyType(); }; MyType threaded_var; #pragma omp threadprivate(threaded_var) int main() { #pragma omp parallel { ... } }
このコードは正しくありません。並列セクションの出口に同期がないため、アプリケーションのプロセスが実行を完了しても、いくつかのスレッドは破棄されないまま残り、それらのスレッドはプロセスの実行が完了したことを知らせる通知を受け取りません。threaded_var 変数のデストラクターは、メインスレッドでのみ呼び出されます。この変数は threadprivate なので、ほかのスレッドで作成されたこの変数のコピーは破棄されず、メモリーリークが発生します。この問題を回避するには、手動で同期を追加する必要があります。
正しいコード:
struct MyType { ~MyType(); }; MyType threaded_var; #pragma omp threadprivate(threaded_var) int main() { #pragma omp parallel { ... #pragma omp barrier } }
18. すべてのユニットで threadprivate として指定されていない外部変数
ここからは、OpenMP* メモリーモデルに関連した最も厄介な問題について見ていきましょう。共有メモリーへの同時アクセスは共有変数に関連しており、OpenMP* ではデフォルトですべてのグローバルスコープ変数が共有されるため、OpenMP* メモリーモデル関連の問題と見なすことができます。
メモリーモデルの問題を説明する前に、これらの問題はすべて private、firstprivate、lastprivate、および threadprivate 変数に関連していることに注意してください。threadprivate 指示句と private 節を使用しないようにすることで、これらの問題のほとんどを回避することができます。必要な変数は、並列セクションでローカル変数として宣言することを推奨します。
この点を念頭に置いて、メモリーモデルの問題について見ていきましょう。まず、threadprivate 指示句から説明します。この指示句は通常、別のユニットで宣言された外部変数を含む、グローバル変数に適用されます。その場合、変数が使用されるすべてのユニットで、変数にこの指示句を適用しなければなりません。このルールは、前述の MSDN Library の記事 [8] にも明記されています。
上記の記事では、特別なケースとして、LoadLibrary 関数や /DELAYLOAD リンカーオプション (この場合 LoadLibrary 関数は暗黙に使用されるため) によってロードされる DLL で宣言された変数に threadprivate 指示句を適用することはできないという別のルールも説明しています。
19. 初期化されていないローカル変数
スレッドが開始すると、そのスレッドの threadprivate、private、および lastprivate 変数のローカルコピーが作成されます。これらのコピーは、デフォルトでは初期化されていません。そのため、初期化せずにこれらの変数を使用しようとすると、ランタイムエラーが発生します。
誤ったコード:
int a = 0; #pragma omp parallel private(a) { a++; }
正しいコード:
int a = 0; #pragma omp parallel private(a) { a = 0; a++; }
ここでは、各スレッドが変数のローカルコピーを保持しているため、同期や flush 指示句を使用する必要はありません。
20. 忘れられた threadprivate 指示句
threadprivate 指示句は、ユニットの最初で宣言されるグローバル変数に対して一度だけ適用されます。そのため、例えばかなり以前に作成したユニットを変更する場合などは、この指示句が適用されていることを忘れてしまい、開発者はグローバル変数が共有されること (デフォルトの動作) を期待しますが、変数は各並列スレッドに対してローカルとなります。OpenMP* 仕様 [3] では、この場合、並列セクション終了後の変数の値は予測できません。
誤ったコード:
int a; #pragma omp threadprivate(a) int _tmain(int argc, _TCHAR* argv[]) { ... a = 0; #pragma omp parallel { #pragma omp sections { #pragma omp section { a += 3; } #pragma omp section { a += 3; } } #pragma omp barrier } cout << "a = " << a << endl; }
このプログラムは、OpenMP* 仕様のとおりに動作し、コンソールウィンドウへの出力は、場合によって “6” (開発者が期待する値) になることも、”0″ になることもあります。並列セクションに入る前に変数に割り当てられる値が 0 なので、0 のほうがより論理的な結果といえます。理論的には変数 a を private または firstprivate として宣言した場合も同じ動作になるはずですが、実際には threadprivate 指示句を使用した場合のみこの動作が見られます。そのため、上記の例ではこの指示句を使用しています。さらに、これは最も発生しやすいケースでもあります。
だからといって、ほかの 2 つのケース (private または firstprivate を使用する場合) が他のすべての実装で正しく動作するわけではないため、これらのケースについても考慮する必要があります。
上記の例では、threadprivate 指示句を削除するとプログラムの動作が変わってしまい、また threadprivate 変数を共有として宣言することは OpenMP* 構文規則では禁止されているため、良い解決方法がほとんどありません。唯一の回避方法は、別の変数を使用することです。
正しいコード:
int a; #pragma omp threadprivate(a) int _tmain(int argc, _TCHAR* argv[]) { ... a = 0; int b = a; #pragma omp parallel { #pragma omp sections { #pragma omp section { b += 3; } #pragma omp section { b += 3; } } #pragma omp barrier } a = b; cout << "a = " << a << endl; }
上記のコードでは、変数 a が並列セクションで共有変数になっています。これは最良の方法ではありませんが、元のコードの動作は変わらないことが保証されます。
これらの問題を回避するため、OpenMP* にあまり慣れていない場合は default(none) 節を使用することを推奨します。この節を使用することで、並列セクションで使用されるすべてのグローバル変数へのアクセスモードを、開発者が指定できるようになります。これにより、コード量は増えますが、多くの問題を回避できるだけでなく、より分かりやすいコードになります。
21. 忘れられた private 節
前述のケースに似たシナリオについて考えてみましょう。以前に作成したユニットを変更する必要があり、変数のアクセスモードを定義している節が、変更するコード部分からかなり離れている位置にあるとします。
誤ったコード:
int a; #pragma omp parallel private(a) { ... a = 0; #pragma omp for for (int i = 0; i < 10; i++) { a++; } #pragma omp critical { cout << "a = " << a; } }
この問題は、前述の問題と同じように見えますが、実際は違います。前述のケースでは並列セクションの後に結果が表示されていましたが、このケースでは並列セクションから値が出力されています。そのため、ループに入る前の変数の値が 0 の場合、2 プロセッサー・コアのマシンでは “10” ではなく、”5″ が出力されます。
これは、2 つのスレッド間で処理が分配されるためです。各スレッドは変数のローカルコピーを保持し、その変数を 5 回ずつ (予想される 10 回ではなく) インクリメントします。つまり、出力される値は並列セクションを実行するスレッド数に依存します。この問題は、private 節の代わりに firstprivate 節を使用した場合にも発生します。
可能性のある解決方法は、前述のケースと似ています。前述のケースの正しいコードを参照して大幅に変更するか、前述のケースの正しいコードと同じ動作になるように上記のコードを変更します。ここでは、上記のコードを変更したほうが良いでしょう。
正しいコード:
int a; #pragma omp parallel private(a) { ... a = 0; #pragma omp parallel for for (int i = 0; i < 10; i++) { a++; } #pragma omp critical { cout << "a = " << a; } }
22. private 変数を使用した誤ったワークシェアリング
この問題は、前述の問題に似ており、「不要な最適化」での問題と正反対のものです。ここでは、別のシナリオについて見てみましょう。
誤ったコード:
int a; #pragma omp parallel private(a) { a = 0; #pragma omp barrier #pragma omp sections { #pragma omp section { a+=100; } #pragma omp section { a+=1; } } #pragma omp critical { cout << "a = " << a << endl; } }
この例では、sections 指示句を使って変数のローカルコピーの値を 101 ずつインクリメントしていますが、指示句で parallel キーワードが指定されていないため、追加の並列化は行われていません。処理は同じスレッド間で分配されています。その結果、2 プロセッサー・コアのマシンでは、1 つのスレッドが “1” を、もう 1 つのスレッドが “100” を出力します。スレッド数が増加すると、結果を予測することはさらに困難になります。この例では、変数を private として宣言しなければ、コードは正しいものになります。
上記のように変数を private として宣言する場合は、追加の並列化が必要になります。
正しいコード:
int a; #pragma omp parallel private(a) { a = 0; #pragma omp barrier #pragma omp parallel sections { #pragma omp section { a+=100; } #pragma omp section { a+=1; } } #pragma omp critical { cout<< "a = " << a << endl; } }
23. lastprivate 節の不注意な使用
OpenMP* 仕様では、関連するループの最後の反復または字句的に最後の section 指示句からの lastprivate 変数の値が、変数の元のオブジェクトに割り当てられます。対応する並列セクションで lastprivate 変数に値が割り当てられなかった場合は、並列セクション終了後の元の変数の値は不定です。前述の例に似たコードについて考えてみましょう。
誤ったコード:
int a = 1; #pragma omp parallel { #pragma omp sections lastprivate(a) { #pragma omp section { ... a = 10; } #pragma omp section { ... } } #pragma omp barrier }
このコードは、問題を引き起こす可能性があります。テストでは問題を再現することができなかったものの、だからといって、問題が絶対に発生しないわけではありません。
lastprivate 節をどうしても使用する必要がある場合は、開発者は並列セクション終了後の変数の正確な値を知っていなければなりません。一般に、変数に予期しない値が割り当てられると問題が発生します。例えば、開発者は変数の値が最後に実行を完了したスレッドから取得されることを期待しているのに、実際には字句的に最後のスレッドから取得されていることがあります。この問題は、2 つの section のコードを入れ替えるだけで解決できます。
正しいコード:
int a = 1; #pragma omp parallel { #pragma omp sections lastprivate(a) { #pragma omp section { ... } #pragma omp section { ... a = 10; } } #pragma omp barrier }
24. 並列セクション開始時の予期しない threadprivate 変数の値
この問題は、OpenMP* 仕様 [3] でも取り上げられています。並列セクションに入る前に threadprivate 変数の値が変更される場合、並列セクション開始時にその変数の値は不定となります。
Visual Studio* のコンパイラーでは threadprivate 変数の動的な初期化が許可されていないため、OpenMP* 仕様で使用されているコード例は Visual Studio* でコンパイルすることができません。そのため、ここでは別のより単純な例を使用します。
誤ったコード:
int a = 5; #pragma omp threadprivate(a) int _tmain(int argc, _TCHAR* argv[]) { ... a = 10; #pragma omp parallel num_threads(2) { #pragma omp critical { printf("\nThread #%d: a = %d", omp_get_thread_num(),a); } } getchar(); return 0; }
プログラムの実行が完了すると、1 つのスレッドは “5” を、もう 1 つのスレッドは “10” を出力します。ここで変数 a の初期化を削除すると、1 つ目のスレッドは “0” を、2 つ目のスレッドは “10” を出力します。この予期しない動作を回避するには、2 つ目の代入文を削除し、初期化コードは削除せずに残します。そうすると、両方のスレッドが “5” を出力します。もちろん、これらの変更はコードの動作を変えてしまいます。ここでは、OpenMP* の動作を示すことのみを目的として、これらのケースについて説明しています。
ローカル変数を初期化する場合は、コンパイラーに依存しないようにする必要があります。private および lastprivate 変数は、初期化せずに使用しようとすると、前述のようにランタイムエラーが発生します。このエラーは少なくとも簡単に特定することができます。一方、threadprivate 指示句は、エラーや警告を出力せずに、予期しない結果を引き起こす可能性があります。そのため、この指示句は使用しないことを強く推奨します。そうすることで、この例ではコードがより分かりやすくなり、動作も予測しやすくなります。
正しいコード:
int a = 5; int _tmain(int argc, _TCHAR* argv[]) { ... a = 10; #pragma omp parallel num_threads(2) { int a = 10; #pragma omp barrier #pragma omp critical { printf("\nThread #%d: a = %d", omp_get_thread_num(),a); } } getchar(); return 0; }
25. プライベート変数の制約
OpenMP* 仕様には、プライベート変数に関する複数の制約があります。そのいくつかは、コンパイラーによって自動で確認されます。以下は、コンパイラーによって確認されない制約事項のリストです。
- private 変数は参照型であってはなりません。
- lastprivate 変数がクラスのインスタンスの場合は、そのクラスでコピー・コンストラクターが定義されていなければなりません。
- firstprivate 変数は参照型であってはなりません。
- firstprivate 変数がクラスのインスタンスの場合は、そのクラスでコピー・コンストラクターが定義されていなければなりません。
- threadprivate 変数は参照型であってはなりません。
すべての制約は次のいずれかの一般的なルールに分類することができます:
1) プライベート変数は参照型であってはならない。
2) 変数がクラスのインスタンスの場合は、そのクラスでコピー・コンストラクターが定義されていなければならない。
これらの制約が課されている理由は明白です。
プライベート変数が参照型の場合、各スレッドが参照のコピーを保持し、 両方のスレッドがその参照を介して共有メモリーの操作を行うからです。
コピー・コンストラクターに関する制約が設けられている理由も明白です。クラスに参照型のフィールドが含まれている場合、そのクラスのメンバーのインスタンスを正確にコピーするのは不可能です。その結果、1 つ目の制約と同様に、両方のスレッドが共有メモリーの操作を行うことになるからです。
これらの問題を示すプログラム例は非常に大きく、ここでは必要性がないため省略します。ただし、一般的なルールを 1 つ覚えておいてください。ポインターを介してアクセスされるオブジェクト、配列、メモリー位置のローカルコピーを作成する必要がある場合は、そのポインターを共有変数のままにすべきです。変数を private として宣言しても意味がありません。参照されるデータは、明示的にコピーするか (オブジェクトの場合)、コンパイラーがコピー・コンストラクターを使用するのに任せるべきです。
26. プライベートとして宣言されていないプライベート変数
この問題について説明した記事 [1] があります。この問題は、プライベートであるべき変数がプライベートとして宣言されず、すべての変数のデフォルトのアクセスモードに従って共有変数として使用されているのが原因です。
この問題を検出するには、前述の default(none) 節の使用を推奨します。
誤ったコード:
int _tmain(int argc, _TCHAR* argv[]) { const size_t arraySize = 100000; struct T { int a; size_t b; }; T array[arraySize]; { size_t i; #pragma omp parallel sections num_threads(2) { #pragma omp section { for (i = 0; i != arraySize; ++i) array[i].a = 1; } #pragma omp section { for (i = 0; i != arraySize; ++i) array[i].b = 2; } } } size_t i; for (i = 0; i != arraySize; ++i) { if (array[i].a != 1 || array[i].b != 2) { _tprintf(_T("OpenMP Error!\n")); break; } } if (i == arraySize) _tprintf(_T("OK!\n")); getchar(); return 0; }
このプログラムの目的は単純です。2 つのスレッドにより、2 つのフィールドを持つ配列を初期化しています。1 つのスレッドがフィールドの 1 つに 1 を割り当て、もう 1 つのスレッドがもう 1 つのフィールドに 2 を割り当てています。この処理が完了すると、プログラムは配列が正しく初期化されたかどうか確認します。
ここで問題となるのは、両方のスレッドが共有ループ変数を使用していることです。このプログラムでは、場合によっては “OpenMP Error!” 文字列が出力されたり、アクセス違反が発生することがあります。”OK!” 文字列が出力されることはほとんどありません。この問題は、ループ変数をローカルとして宣言するだけで簡単に解決できます。
正しいコード:
... #pragma omp parallel sections num_threads(2) { #pragma omp section { for (size_t i = 0; i != arraySize; ++i) array[i].a = 1; } #pragma omp section { for (size_t i = 0; i != arraySize; ++i) array[i].b = 2; } } } ...
for ループに関連した同様の例を示す記事 [1] もあります (記事では、その例は別のエラーと見なされています)。この記事の著者は、OpenMP* の for 指示句を介して共有される for ループのループ変数はローカルとして宣言すべきだとしています。一見、この例は上記のケースと同じように見えますが、実際は違います。
OpenMP* の仕様により、そのような場合、ループ変数は共有として宣言されていても、暗黙にプライベートへ変換されます。この変換が行われても、コンパイラーは警告を出力しません。記事 [1] ではこのケースについて説明しており、このようなケースでは実際にこの変換が行われます。しかし、上記の例では for 指示句ではなく、sections 指示句を使用してスレッド間でループを共有しており、プライベートへの変換は行われません。
つまり、並列セクションではループ変数を共有にしてはならないことが明白です。for 指示句によりスレッド間でループを共有する場合であっても、暗黙の変換に頼るべきではありません。
27. 反復順序が設定されていない配列の並列処理
これまでの例では (ordered 指示句の構文の例を除き)、並列化された for ループの実行順序は指定されていませんでした。これは、これまでの例ではループの順序を指定する必要がなかったからです。しかし、場合によっては ordered 指示句を使用しなければならないこともあります。特に、ある反復の結果がその前の反復の結果に依存する場合は、この指示句を使用する必要があります (このケースについて説明した記事 [6] があります)。以下の例について考えてみましょう。
誤ったコード:
int* arr = new int[10]; for(int i = 0; i < 10; i++) arr[i] = i; #pragma omp parallel for for (int i = 1; i < 10; i++) arr[i] = arr[i - 1]; for(int i = 0; i < 10; i++) printf("\narr[%d] = %d", i, arr[i]);
理論的には、このプログラムは一連の 0 を出力します。しかし、2 プロセッサーのマシンでは、いくつかの 0 といくつかの 5 が出力されます。これは、たいていの場合、デフォルトではスレッド間で反復が 2 等分されるためです。この問題は、ordered 指示句を使用するだけで簡単に解決できます。
正しいコード:
int* arr = new int[10]; for(int i = 0; i < 10; i++) arr[i] = i; #pragma omp parallel for ordered for (int i = 1; i < 10; i++) { #pragma omp ordered arr[i] = arr[i - 1]; } for(int i = 0; i < 10; i++) printf("\narr[%d] = %d", i, arr[i]);
パフォーマンス・エラー
1. 不要な flush
ここまでは、プログラムのロジックに関連した重要な問題について見てきました。ここからは、プログラムのロジックには関係なく、プログラムのパフォーマンスだけに関連する問題について見ていきましょう。これらの問題について説明した記事 [1] があります。
前述のとおり、ほとんどの場合、flush 指示句は暗黙に指定されています。そのような場合は、flush 指示句を明示的に指定する必要はありません。不要な flush 指示句、特に引数のないもの (この場合、すべての共有メモリーが同期されます) は、プログラムの実行速度を大幅に低下させます。以下の barrier 指示句では、flush 指示句が暗黙に指定されているため、明示的に指定する必要はありません。
- critical への入口と出口
- ordered への入口と出口
- parallel への入口と出口
- for からの出口
- sections からの出口
- single からの出口
- parallel for への入口と出口
- parallel sections への入口と出口
2. atomic 指示句の代わりとしてのクリティカル・セクションまたはロックの使用
アトミック操作の多くはプロセッサー命令に置換できるため、atomic 指示句のほうがクリティカル・セクションよりも高速です。そのため、基本操作で共有メモリーを保護する必要がある場合は、この指示句を適用するほうが良いでしょう。OpenMP* 仕様では、この指示句は次の操作に適用することができます: x binop= expr、x++、++x、x–、–x。ここで x はスカラー変数、expr は x 変数を含まないスカラー文、binop は多重定義されていない演算子 (+、*、-、/、&、^、|、<<、または >>) です。これ以外の場合は、atomic 指示句を使用することはできません (これはコンパイラーによって確認されます)。
共有メモリーを保護する方法には、パフォーマンスの高い順に atomic、critical、omp_set_lock があります。
3. 不要なメモリーへの同時書き込み保護
アトミック操作、クリティカル・セクション、ロックのどれを使用してもプログラムの実行速度は低下するため、必要な場合を除いてメモリーの保護は使用すべきではありません。
次の場合には、変数への同時書き込み保護は不要です。
- スレッドのローカル変数 (threadprivate、firstprivate、private または lastprivate 変数の場合も)
- シングルスレッドにより実行されることが保証されているコード部分 (master または single セクション) での変数へのアクセス
4. 多すぎるクリティカル・セクションでの処理
クリティカル・セクションは、常にプログラムの実行速度を低下させます。クリティカル・セクションでは、スレッドが互いに待機する必要があるため、コードの並列化により得られるパフォーマンスを活用することができません。また、クリティカル・セクションの入口と出口の処理には一定の時間がかかります。
そのため、必要な場合を除いてクリティカル・セクションは使用すべきではありません。クリティカル・セクションでは、複雑な関数の呼び出しは行わないことを推奨します。また、共有変数、共有オブジェクト、共有リソースを使用しないコードはクリティカル・セクションに配置しないことを推奨します。この問題はケースバイケースなので、コードをクリティカル・セクションに追加すべきかどうかは、開発者が個々の状況に応じて判断する必要があります。
5. 多すぎるクリティカル・セクションへの入口
前述のとおり、クリティカル・セクションの入口と出口の処理には一定の時間がかかります。そのため、この処理が頻繁に実行されると、プログラムのパフォーマンスが低下します。クリティカル・セクションへの入口は最小限に抑えることを推奨します。ある記事 [1] で紹介されている例に少し手を加えたものを見てみましょう。
誤ったコード:
#pragma omp parallel for for ( i = 0 ; i < N; ++i ) { #pragma omp critical { if (arr[i] > max) max = arr[i]; } }
この例では、クリティカル・セクションの前に比較を実行することで、ループのすべての反復でクリティカル・セクションを実行しなくてもよくなります。
正しいコード:
#pragma omp parallel for for ( i = 0 ; i < N; ++i ) { #pragma omp flush(max) if (arr[i] > max) { #pragma omp critical { if (arr[i] > max) max = arr[i]; } } }
このような単純な修正により、コードのパフォーマンスが大幅に向上するため、この問題は無視すべきではありません。
まとめ
この記事は、OpenMP* で発生する可能性がある問題の最も完全なリスト (少なくとも、この記事の執筆時点では) を提供しています。ここで紹介したデータは、我々の経験を通して、さまざまな情報源から集められたものです。標準のコンパイラーでは、ここで紹介したすべてのエラーが検出されるわけではないことに注意してください。
エラーはすべて次の 3 つの一般的なカテゴリーに分類することができます。
- OpenMP* 構文の理解不足
- OpenMP* 原則の誤解
- 誤ったメモリー処理 (保護されていない共有メモリーアクセス、同期の欠如、正しくない変数へのアクセスモードなど)
この記事で紹介した問題リストは完全ではありません。ここでは取り上げなかった問題がほかにも多数あります。今後このトピックの新しい記事で、より完全なリストが提供されることでしょう。
ほとんどの問題は、スタティック・アナライザーにより自動検出が可能です。一部の問題は、インテル® Parallel Inspector でも検出されます。しかし、まだ専用のツールは作成されていません。インテル® Parallel Inspector は、特に共有メモリーへの同時アクセス、ordered 指示句の誤った使用、#pragma omp parallel for 指示句の for キーワードの追加漏れの検出に役立ちます [1]。
コードの並列化とアクセスモードの視覚化ツールも開発者にとっては便利ですが、まだ作成されていません。
現在、我々は VivaMP スタティック・アナライザーの開発に取り組んでいます。このアナライザーは、ここで取り上げた問題とそれ以外のいくつかの問題を検出することができるようになる予定です。このアナライザーにより、並列プログラミングのエラー検出 (そのほとんどは常に再現可能ではない) が非常に容易になることでしょう。VivaMP プロジェクトの詳細については、プロジェクトの Web ページ(http://www.viva64.com/vivamp.php (英語)) を参照してください。
参考文献 (英語)
- Michael Suess, Claudia Leopold, Common Mistakes in OpenMP and How To Avoid Them – A Collection of Best Practices, http://www.viva64.com/go.php?url=100.
- OpenMP Quick Reference Sheet, http://www.viva64.com/go.php?url=101.
- OpenMP C and C++ Application Program Interface specification, version 2.0, http://www.viva64.com/go.php?url=102.
- Yuan Lin, Common Mistakes in Using OpenMP 1: Incorrect Directive Format, http://www.viva64.com/go.php?url=103.
- Richard Gerber, Advanced OpenMP Programming, http://www.viva64.com/go.php?url=104.
- Kang Su Gatlin and Pete Isensee.Reap the Benefits of Multithreading without All the Work, http://www.viva64.com/go.php?url=105.
- Yuan Lin, Common Mistakes in Using OpenMP 5: Assuming Non-existing Synchronization Before Entering Worksharing Construct,
コンパイラーの最適化に関する詳細は、最適化に関する注意事項を参照してください。