少し前、ちょっと面白い挙動に出くわしたので、順を追って説明しておこうと思った。知ってる人には当たり前のことが書かれているので下の方まで飛ばしてくれても構わない。
OpenMPはプラグマを書くだけでメモリ共有並列化ができるというやつで、手をつけやすいために広く使われている。例えば以下のような感じのコードが書ける。
double calc_nth_element(std::size_t n); // thread safe int main() { std::vector<double> v(N); #pragma omp parallel for for(std::size_t i=0; i<N; ++i) { v[i] = calc_nth_element(i); } }
メモリは共有されているので、ここで例えばv
の同じ要素に書き込んだりすると詰む。
多くの資料でこのような単純な例は紹介されているが、若干複雑になってきた場合、例えばparallel
領域から関数を読んで、その関数にもpragma omp for
などが付いている時にどう振る舞うのか、というようなことは紹介されていない。
parallel for
からparallel for
が書かれた関数を読んだ場合は紹介されていて、OMP_NESTED
がtrue
なら再帰的にスレッドグループが作られてしまい、ネストが深すぎるとすさまじい数のスレッドが立ち上がりシステムがハングするらしい。false
なら、単にシングルスレッドになるらしい。
だが、今気になっているのはparallel
領域からparallel
のついていないomp for
だけが書かれた関数を読んだ場合だ。
並列化可能で互いに依存しないタスクが複数個あって、どれを実行するかが実行時にしか決まらない場合、そしてそれが複数ある場合、こういうことをしたくなる。
そういうタスクに同じインターフェースを持たせておいて、入力時に切り替えて、それをparallel領域内で呼び出すのだ。
普通のfor
やparallel
領域はそのブロックの終わりでjoinするので、大きなparallel
領域内でomp for nowait
を使ってブロックせずに仕事が終わったスレッドから順に次の関数を実行できて欲しい。
だが、この手の用法を紹介している本や記事が異様に少ない、というか少し見た限りでは存在しない。GitHubでopenmpを使っていそうなプロジェクトを調べてそのレポジトリ内のpragma
で検索しても誰も使っていない。
仕様書のfor
の部分をざっと見た限りでは、関数呼び出しについては何も書いていない。とりあえず動いているので使ったりしているが、心配なことに変わりはない。
何か知っている人がいたら教えて欲しい。
ここまで書いていて、nowait
ありとなしでベンチマークを取っていないことに気付いた。あとでとっとこう。こんなことをする必要がないならしなければよいだけなので。
というわけでどうなるのかいっちょやってみよう。GCC 9.2.1を用意した。
注: 一般に(実装が仕様を兼ねている言語を除いて)、言語仕様上保証されている動作と処理系の動作は必ずしも一致しない。その場合は処理系のバグとして解釈され、修正対象となる。多くの人が使っている処理系は、その「多くの人」の中に言語仕様を理解している人がいる可能性が高くなるため、そのようなバグは修正済みである確率が高く、言語仕様を調べる上でよいヒューリスティックになる。だが、言語仕様上処理系がどのように扱っても構わない場合というのも存在するため(処理系定義・未定義動作など)、言語処理系の動作から言語仕様、特にエッジケースでの挙動を理解したと考えるのは(多くの処理系が規格によく従っており複数の処理系が確立されている現代においては)基本的に愚行である。以下ではこの愚行を行っている点に注意していただきたい。
まず、どのスレッドから何が行われているかを表示するためにプリント関数を用意する。出力の順序がランダムにならないように、ロックを置いて毎回それを取得する。C++11を使っていたので再帰で出力しているが、C++17ならfold expressionを使おう。
std::mutex iomtx; template<typename T1> void print_impl(const T1& arg) { std::cout << arg; return; } template<typename T1, typename T2, typename ... Ts> void print_impl(const T1& arg1, const T2& arg2, const Ts& ... args) { std::cout << arg1 << arg2; print_impl(args...); return; } template<typename ... Ts> void print(Ts&& ... args) { std::lock_guard<std::mutex> iolock(iomtx); std::cout << "from thread " << omp_get_thread_num() << ": "; print_impl(std::forward<Ts>(args) ...); std::cout << std::endl; return; }
ではこれを使って色々どうなるか見てみよう。まず、普通にpragma omp parallel for
をやってみる。あまりコア数が多いと見づらいので4スレッドに設定している。
void foo(const std::size_t n) { #pragma omp parallel for for(std::size_t i=0; i<n; ++i) { print("executing foo ", i, "-th loop"); } } int main(int argc, char **argv) { if(argc != 2) { return 1; } const std::size_t n = std::atoi(argv[1]); foo(n); return 0; }
$ g++-9 -fopenmp -std=c++11 main.cpp $ ./a.out 12 from thread 3: executing foo 9-th loop from thread 3: executing foo 10-th loop from thread 3: executing foo 11-th loop from thread 2: executing foo 6-th loop from thread 2: executing foo 7-th loop from thread 2: executing foo 8-th loop from thread 0: executing foo 0-th loop from thread 0: executing foo 1-th loop from thread 0: executing foo 2-th loop from thread 1: executing foo 3-th loop from thread 1: executing foo 4-th loop from thread 1: executing foo 5-th loop
わかる。特に驚くようなことはない。
では少し書き換えて、parallel領域をmain
の中に作り、pragma omp for
だけが書かれたfoo
を呼んで見よう。
void foo(const std::size_t n) { print("entered into foo"); #pragma omp for for(std::size_t i=0; i<n; ++i) { print("executing foo ", i, "-th loop"); } } int main(int argc, char **argv) { if(argc != 2) { return 1; } const std::size_t n = std::atoi(argv[1]); #pragma omp parallel { foo(n); } return 0; }
parallel
領域では注釈がなければ全ての行を全スレッドが実行するので、全スレッドがfoo
を呼ぶ。出力は以下のようになる。
from thread 0: entered into foo from thread 0: executing foo 0-th loop from thread 0: executing foo 1-th loop from thread 1: entered into foo from thread 1: executing foo 3-th loop from thread 3: entered into foo from thread 3: executing foo 9-th loop from thread 3: executing foo 10-th loop from thread 3: executing foo 11-th loop from thread 1: executing foo 4-th loop from thread 1: executing foo 5-th loop from thread 0: executing foo 2-th loop from thread 2: entered into foo from thread 2: executing foo 6-th loop from thread 2: executing foo 7-th loop from thread 2: executing foo 8-th loop
少し非直感的かも知れないが、期待通りの挙動をした。全スレッドがfoo
の中に入った後、for
文の自分が担当する領域だけを実行して返ってきている。ループ全体が4回実行されるわけではない。
これは、あたかもfoo
がインライン展開されたかのような挙動だ。一応念の為、これがインライン展開されてからOpenMP化されたアーティフアクトでないことを確認する目的でインライン展開を抑制してみる。
void __attribute__((noinline)) foo(const std::size_t n) { print("entered into foo"); #pragma omp for for(std::size_t i=0; i<n; ++i) { print("executing foo ", i, "-th loop"); } }
結果は同じだった。実行順序は違うが、それは並列化しているので当然です。
from thread 1: entered into foo from thread 1: executing foo 3-th loop from thread 1: executing foo 4-th loop from thread 1: executing foo 5-th loop from thread 2: entered into foo from thread 2: executing foo 6-th loop from thread 2: executing foo 7-th loop from thread 0: entered into foo from thread 0: executing foo 0-th loop from thread 0: executing foo 1-th loop from thread 3: entered into foo from thread 3: executing foo 9-th loop from thread 3: executing foo 10-th loop from thread 3: executing foo 11-th loop from thread 2: executing foo 8-th loop from thread 0: executing foo 2-th loop
では、この関数をmaster
領域から呼ぶとどうなるだろうか? master
領域は、マスタースレッド(スレッド番号が0のスレッド)だけが実行することができる。そこからこの関数を呼ぶと?
void foo(const std::size_t n) { print("entered into foo"); #pragma omp for for(std::size_t i=0; i<n; ++i) { print("executing foo ", i, "-th loop"); } } int main(int argc, char **argv) { if(argc != 2) { return 1; } const std::size_t n = std::atoi(argv[1]); #pragma omp parallel { #pragma omp master { foo(n); } } return 0; }
$ ./a.out 12 from thread 0: entered into foo from thread 0: executing foo 0-th loop from thread 0: executing foo 1-th loop from thread 0: executing foo 2-th loop ^C
0番は全体の1/4だけを実行する。残りの3/4は他のスレッドに割り当てられたタスクになるわけだが、この関数はmaster
領域から呼ばれたため、0番以外のスレッドは動くことができない。というわけでこのプログラムはこのままハングしてしまった。
考えてみれば当たり前に思えるが、少し面白い挙動だ。
ちなみに、これを関数に分けずにコンパイルしようとすると、以下のようなエラーが発生する。
$ g++-9 -fopenmp -std=c++11 foo.cpp foo.cpp: In function ‘int main(int, char**)’: foo.cpp:59:9: error: work-sharing region may not be closely nested inside of work-sharing, ‘critical’, ‘ordered’, ‘master’, explicit ‘task’ or ‘taskloop’ region 59 | #pragma omp for | ^~~
もうちょっとだけ静的解析を頑張ってくれていれば関数越しにでもエラーを出せそうなんだが。
この挙動はどうにもコンパイラ側が、pragma omp for
が単独で(omp parallel
領域から呼ばれることを想定した)関数内に登場することを初めから考えていないようにも見える。
一つの関数の中でparallel
領域を閉じたくない場合、OpenMPのtask
を明示的に書いたり、あるいはTBBやThrust、cpp-taskflowなどのライブラリを使ったほうがよさそうな気がしてきた。
とはいえ、これはいち処理系での挙動なので(intelコンパイラでも同じく固まったし、この挙動に筋も通るが)、良い子の皆さんはいち処理系の挙動を調べて満足することなく仕様書を精読しましょう。
細かい状況でOpenMPがどう振る舞うのか概説書なんかを呼んでもよくわからないので、どこかで時間を見つけて仕様書をちゃんと読みたい……。
OpenMPの仕様書はここで見れます: www.openmp.org