2014年4月30日水曜日

C/C++でOpenMPを試してみる

OpenMPとは?


OpenMPとは、並列プログラミング用の 機能で、C/C++, Fortran で使用できます。並列プログラミングには色々とありますが、以下の様な利点があります。
  • 通常のプログラムからOpenMPを使用するように書き直しやすい。
  • ハード(CPUのコア数)に依存しないプログラムが書きやすい。
  • Visual Studio(Pro2005以降), gcc(4.2以降), インテルコンパイラ(V9以降)などある程度マルチプラットフォームで使用できる。

逆に欠点としては以下があります。
  • 記述に#pragmaディレクティブというコンパイラ専用の機能の記述に使用する方法を使用するので、プログラムが見にくくなる。
  • パフォーマンスは特にコア数が増えると今一歩のところがあるらしい。
  • 現在OpenMP4.0まで出ているが、Visual StudioはOpenMP2.0まで、LLVMは現在OpenMP3.1に対応中と対応に差がある。


OpenMPをC/C++で使うには?


OpenMPが使用できるかどうかをチェックするためには、_OEPNMPが定義されているかどうかで判別します。本来ならOpenMPの機能を使う部分はすべて

#ifdef _OEPNMP
#include <omp.h>
#endif

と書き、無視されるようにした方が良いでしょうが、今回の例では省いていますので注意してください。

#pragmaは認識されなかった場合は無視されるので、_OPENMPでの#ifdefは必要ないです。omp_get_thread_num()関数などのOpenMP用の関数はコンパイラが対応していても、OpenMP用のコンパイルオプションを付けないとエラーになります。

コンパイル時には、 VisualStudioの場合、/openmp をつけてコンパイルすれば良いようです。gccの場合は、-fopenmp をつければ良いです。今回はgccで動作確認しています。MinGW環境でも、動作します。

omp_set_num_threads()関数などOpenMPの関数を使用する場合にはヘッダーファイルとしては"omp.h"をインクルードします。


並列実効領域構文 ー parallel構文


OpenMPを使うときには、まずはprallel構文で並列実効領域の指定をします。以下のようになります。

#include <stdio.h>

int main()
{
        #pragma omp parallel
        {
                printf("parallel\n");
        }
        return 0;
}

#pragma omp というのが、OpenMP の指令であることを示しています。-fopenmpを指定しない場合は無視されます。

parallel構文では、{}で括られた部分が、CPU毎にスレッドが作られ実行されます。例えば2つのコアのCPUの場合は、二回printfの実行が行われます。

より詳細に説明すると以下のようになっています。
  • { は #pragmaと同じ行には書けません。実行が一行のみの場合は {} は省略できます。
  • スレッド数は指定することも可能です。方法はいくつかあります。
    1. 環境変数 OMP_NUM_THREADS に数を指定する。
    2. #pragma omp parallel num_threads(数)と後ろに指示句num_threadsをつける。
    3. omp_set_num_threads(数)関数で指定する
  • omp_get_num_threads(数)関数でスレッドの数を取得したり、omp_get_thread_num()関数でスレッド番号(0,1,...)を得ることで、スレッドごとの処理をすることができます(ただしあまり必要ないです)。


ワークシェアリング構文1 ー for構文



for構文は、後に続くfor文を自動的にスレッドの数に分解して実行してくれます。


#include <stdio.h>
#include <omp.h>

int main()
{
        char data[100];
        int i;

        #pragma omp parallel
        {
                #pragma omp for
                for (i = 0; i < 100; i++) {
                        data[i] = 0;
                }
        }
        return 0;
}


parallel構文で作成されたスレッドが2つの場合、i を 0~49, 50~99 に分けて data[i] = 0; を実行します。

parallel構文とfor構文を合わせて以下のように書くこともできます。

        #pragma omp parallel for
        for (i = 0; i < 100; i++) {
                data[i] = 0;
        }

データをどのようにスレッドで分割するかを指定することもできます。通常は static ですが、dynamic や guided が指定できます。指定は、#pragma omp for schedule(タイプ)とします。
  • static - データをスレッド数で分割して実行します。
  • dynamic - 終わったスレッドからデータを取ってきて実行します。
  • guided- static + dynamic。徐々にstatic分を減らしていく。最も効率的らしい。
  • runtime - 環境変数OMP_SCHEDULEに従って、static,dynamic,guided を切り替え。

以下は、スレッド番号が小さいスレッドほど時間が掛かるようにして、スケジュールの違いを分かるようにしたつもりのプログラムです。

#define DATA_SIZE 20
#include <stdio.h>
#include <omp.h>
#include <unistd.h>

void exec_task(int i)
{
        int id;

        id = omp_get_thread_num();
        printf("%d - start %d\n", id, i);
        usleep(i * 100000);
        printf("%d - end   %d\n", id, i);
}

int main()
{
        int i;

        #pragma omp parallel num_threads(2)
        {
                #pragma omp for schedule(static)
                for(i = 0; i < DATA_SIZE; i++) {
                        exec_task(i);
                }
        }
        return 0;
}

staticの場合には、0番のスレッドで0〜9のデータの処理を行い、1番のスレッドが時間が10〜19のデータの処理を行っていますが、1番スレッドで時間がかかっているのがわかると思います。

staticをdynamicにすると、1番と2番でほぼ交互にデータを分けて処理することがわかると思います。

staticからguiedeにすると、15,16,17のデータは0番のスレッドで処理されていることがわかると思います。
 
どのscheduleも(static, 3)などとチャンクサイズを指定することができます。チャンクサイズでデータの反復数を変更することができます。

ワークシェアリング構文2 ー section構文



複数の処理を平行して実行します。例えば以下のように使います。

int main()
{
        #pragma omp parallel
        {
                #pragma omp sections
                {
                        #pragma omp section
                        function1();
                        #pragma omp section
                        function2();
                        #pragma omp section
                        function3();
                }
        }
        return 0;
}

スレッドの数が二つの場合、function1()とfunction2()を同時に実行します。先に終わった方のスレッドでfunction3()を実行します。

for構文と同じく #pragma omp parallel sections と省略可能です。


共有データとプライベートデータ


前述のワークシェアリング構文1 - for構文の最初の例で i と data[100] の扱いが気になった方は見えますでしょうか?

data[100] はすべてのスレッドで共有のデータになっていますが、i はスレッドごとに別のデータとなります。

基本的に、#pragma omp parallel より前で指定されたデータはすべてのスレッドから参照される共有データ、後で指定されたデータはスレッドごとのプライベートデータとなります。しかし、for文のループインデックスは例外とみなされ、プライベートデータとなります。

共有データとプライベートデータを指定することもできます。

        int a = 1, b = 2, c = 0, d;
 
        #pragma omp parallel private(a), firstprivate(b), shared(c, d)


上記の場合、a, b はプライベートデータと c, d は共有データとなります。

a と b の違いは、b はスレッド開始時にすべてのスレッドで値が初期化されます。a の場合はスレッドによっては不定値となります。

最後のスレッドのプライベートデータをparallel終了時に取得するlastprivate() 構文もあります。例えばfor文のiの値をOpenMPの処理から抜けた後に使用する場合に #pragma omp parallel for lastprivate(i) とします。

また reduction というものもあり、各スレッドにおいてはプライベートとして扱うけれど、最終的にそれらをまとめる変数を指定できます。

reduction(演算または組み込み手続き : 変数)というような指定になり、演算または組み込み手続きには + や.and. や max など様々な方法で値をまとめます。

例えば、和を求めたい場合には、以下のようになります。


#include <stdio.h>

int main()
{
        int i, sum;

        #pragma omp parallel reduction(-:sum)
        {
                #pragma omp for
                for (i = 1; i <= 1000; i++)
                        sum = sum + i;
                printf("sum = %d\n", sum);
        }
        printf("sum_result = %d\n", sum);
        return 0;
}


4コアCPUの場合は以下のような結果になり、4つのスレッドで計算された値が最終的にたされていることがわかります。

sum = 218875
sum = 93875
sum = 31375
sum = 156375
sum_result = 500500


同期構文 ー barrier同期など


parallel構文では複数スレッドが動作します。スレッドが複数動くということは、スレッド同士でデータや処理のタイミング(同期)を考える必要があります。

parallel構文を抜ける時は全てのスレッドが終了してからを抜けるようになっています。このように全てのスレッドの終了を待つのをbarrier同期といいます。


parallel構文の中では複数のワークシェアリング構文が使用できます。ワークシェアリング構文も終了時にbarrier同期が行われます。

OpenMPではワークシェアリング構文の終了時に自動的にbarrier同期が行われますが、待たないようにすることも可能で、その場合はnowait指示句を使用します。

一方、通常の演算や関数等の実行など同期が行われない物もあります。同期を取るようにするにはbarrier構文を使用します。例えば以下のようになります。

        #pragma omp parallel
        {
                #pragma omp for
                        :
                #pragma omp for nowait  ← 前のすべてのスレッドが終わってから始まる
                        :
                #pragma omp sections    ← 前の終わったスレッドから使用して始まる
                        :
                function1();            ← 前のすべてのスレッドが終わってから始まる
                #pragma omp for         ← 前の終わったスレッドから使用して始まる
                        :
                function2();            ← 前のすべてのスレッドが終わってから始まる
                #pragma omp barrier
                #pragma omp for         ← 前のすべてのスレッドが終わってから始まる
                        :                
        }

今までの例のプログラムでもprintf()を使用していましたが、排他制御がされていない関数の場合、問題が起こるかもしれません。そのような場合には、以下のように

        #pragma omp critical
        function(...);

とすれば、排他制御できます。ここで指定した関数を、別のスレッドで実行中のときに 実行しようとすると、別のスレッドの処理が終わるまで待つことになります。

critical(name)の様に名前を指定することもできます。また、{}を使えば複数行を排他制御できます。

そして、共有データの場合には、複数のスレッドからアクセスされることになり、その順番が問題になることがあります。例えばaという変数の場合には、以下のように

        #pragma omp flush(a);
        function(a);

とすることで、aの順番が保証されることになります。barrier構文と同じく、ワークシェアリング構文のfor, sections等の出口で自動的に行われますし、nowaitでは行われません。


その他、以下のような物があります。
  • #pragma omp single   ある一つのスレッドのみで実行する。barrier同期,flashあり
  • #pragma omp master 0番のスレッドのみで実行する。barrier,flash同期なし。
  • #pragma omp atmic 直後の複合代入文をatmicに行います。

とりあえず、説明はこれで終わりです。上記はOpenMP2.0までで、OpenMP3.0では omp taskが追加されたり、最新のgcc4.9で追加されたOpenMP4.0ではSIMD命令が指定できたり更に複雑な処理ができるようです。興味のある方は試してみてください。

0 件のコメント:

コメントを投稿