OpenMPの挙動を見る

少し前、ちょっと面白い挙動に出くわしたので、順を追って説明しておこうと思った。知ってる人には当たり前のことが書かれているので下の方まで飛ばしてくれても構わない。

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_NESTEDtrueなら再帰的にスレッドグループが作られてしまい、ネストが深すぎるとすさまじい数のスレッドが立ち上がりシステムがハングするらしい。falseなら、単にシングルスレッドになるらしい。

だが、今気になっているのはparallel領域からparallelのついていないomp forだけが書かれた関数を読んだ場合だ。 並列化可能で互いに依存しないタスクが複数個あって、どれを実行するかが実行時にしか決まらない場合、そしてそれが複数ある場合、こういうことをしたくなる。 そういうタスクに同じインターフェースを持たせておいて、入力時に切り替えて、それをparallel領域内で呼び出すのだ。 普通のforparallel領域はそのブロックの終わりでjoinするので、大きなparallel領域内でomp for nowaitを使ってブロックせずに仕事が終わったスレッドから順に次の関数を実行できて欲しい。

だが、この手の用法を紹介している本や記事が異様に少ない、というか少し見た限りでは存在しない。GitHubopenmpを使っていそうなプロジェクトを調べてそのレポジトリ内の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領域を閉じたくない場合、OpenMPtaskを明示的に書いたり、あるいはTBBやThrust、cpp-taskflowなどのライブラリを使ったほうがよさそうな気がしてきた。

とはいえ、これはいち処理系での挙動なので(intelコンパイラでも同じく固まったし、この挙動に筋も通るが)、良い子の皆さんはいち処理系の挙動を調べて満足することなく仕様書を精読しましょう。

細かい状況でOpenMPがどう振る舞うのか概説書なんかを呼んでもよくわからないので、どこかで時間を見つけて仕様書をちゃんと読みたい……。

OpenMPの仕様書はここで見れます: www.openmp.org