OpenMPの特徴と基本的な使い方について解説

C/C++

OpenMP(Open Multi-Processing)は、並列プログラミングをサポートするための一般的なライブラリで、C、C++、Fortran言語に対応しています。

データ解析、科学計算、画像処理、シミュレーション、組み込みシステムなどのアプリケーションで、マルチコアプロセッサの能力を活用して高速化するために使用されます。

本記事ではOpenMPについて調べたことを紹介したいと思います。

開発元

OpenMPの開発は、OpenMP Architecture Review Board (ARB) という非営利団体によって主導されています。

この団体は、ハードウェアベンダー、ソフトウェアベンダー、研究者などから成る多様なメンバーによって構成されており、OpenMPの仕様の策定と公開を行っています。

コンパイラによるサポート

OpenMP自体はAPI仕様であり、実際の実装は各コンパイラの開発者によって行われます。多くの主要なコンパイラ(gcc、clang、Intel C++ Compilerなど)は、このオープンソースの仕様に基づいて、自らのシステム上でOpenMPをサポートしています。

例えば、gccでは「-fopenmp」オプションを使用することで、OpenMPが使用されたソースコードをコンパイルすることができます。

特徴

OpenMPには以下のような特徴があります。

  • ディレクティブベースの並列化
  • スレッドベースの並列化
  • プラットフォームの汎用性

ディレクティブベースの並列化

プログラムコードにディレクティブ(特別なコメント)を挿入することで並列処理を実現します。これにより、既存のシーケンシャルなコードを容易に並列化し高速化できます。

例えば、以下のコードでは「pragma omp parallel for」というディレクティブを追加し、ループ処理を並列実行させます。

int main() {
    #pragma omp parallel for
    for (int i = 0; i < 10; i++) {
        printf("スレッド番号: %d, iの値: %d\n", omp_get_thread_num(), i);
    }
    return 0;
}

スレッドベースの並列化

OpenMPではスレッドを使用して並列処理を実現します。プロセスと比較してスレッドは軽量で高いパフォーマンスを実現できます。また、スレッドのためデータの共有や同期が容易になります。

プラットフォームの汎用性

OpenMPは幅広いハードウェアとオペレーティングシステムに対応しています。具体的には、ほとんどの現代のマルチコアプロセッサを含む多様なCPUアーキテクチャに対応し、Windows、Linux、macOSなどの主要なオペレーティングシステムで利用可能です。

また、多くの主要なコンパイラ(gcc、clang、Intel C++ Compilerなど)でサポートされています。これにより、開発したプログラムの移植性が高まります。

基本的な使い方

OpenMPの基本的な使い方は、大きく以下の4つに分類できます。

  • ループの並列化
  • 並列セクション
  • リダクション
  • その他機能

それぞれ解説します。

ループの並列化

以下のコードでは「pragma omp parallel for」というディレクティブを挿入し、ループ処理を複数のスレッドで並列実行させます。

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

int main() {
    #pragma omp parallel for
    for (int i = 0; i < 10; i++) {
        printf("スレッド番号: %d, iの値: %d\n", omp_get_thread_num(), i);
    }
    return 0;
}
omp_get_thread_num()は現在のスレッド番号を返します。

このコードをコンパイルして実行すると以下のようになります。

$ gcc -fopenmp program.c -o program
$ ./program
スレッド番号: 9, iの値: 9
スレッド番号: 8, iの値: 8
スレッド番号: 7, iの値: 7
スレッド番号: 1, iの値: 1
スレッド番号: 5, iの値: 5
スレッド番号: 2, iの値: 2
スレッド番号: 0, iの値: 0
スレッド番号: 3, iの値: 3
スレッド番号: 6, iの値: 6
スレッド番号: 4, iの値: 4

ループごとにスレッドが実行され、並列に動作していることが分かります。デフォルトでは、同時に生成できるスレッド数は基本的には論理プロセッサ数と等しくなります。

私の環境では論理プロセッサ数が12のため、最大12個のスレッドを生成し並列実行することが可能となります。

※最大並列数はomp_set_num_threads関数で調整することが可能です。

ループ回数が最大並列数より大きくなった場合は、ループの反復は複数のスレッドに分割されて処理されます。OpenMPは自動的にループの各反復を分割し、利用可能なスレッドに均等に割り当てます。

例えば、最大並列数が10でループ回数が100の場合、各スレッドに対して10ループずつ割り当てられるイメージですね。(※必ずそのように割り当てられるとは限りません。)

並列セクション

以下のコードでは「pragma omp parallel」というディレクティブを挿入し、そのセクションを並列に(最大並列数分)実行します。

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

int main() {
    #pragma omp parallel
    {
        int thread_id = omp_get_thread_num();
        printf("スレッド番号: %d\n", thread_id);
    }
    return 0;
}

このコードをコンパイルして実行すると以下のようになります。

$ gcc -fopenmp program.c -o program
$ ./program
スレッド番号: 6
スレッド番号: 7
スレッド番号: 0
スレッド番号: 10
スレッド番号: 4
スレッド番号: 2
スレッド番号: 3
スレッド番号: 1
スレッド番号: 5
スレッド番号: 8
スレッド番号: 9
スレッド番号: 11

「#pragma omp parallel」で指定されたセクションが最大並列数分のスレッドで実行されました。

※生成された全てのスレッドの実行が完了した後、並列セクションが終了し次の処理に進みます。

リダクション

リダクション(Reduction)は、並列計算において、複数のスレッドやプロセスが生成したデータを結合し、単一の結果を得る操作です。数値の合計、最大値、最小値などを求める計算を並列処理を用いて高速に行うことができます。

合計値の計算

以下のコードでは、リダクションを利用して値の合計値を求めます。

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

int main()
{
    int sum = 0;
    #pragma omp parallel for reduction(+:sum)
    for (int i = 0; i < 10; i++) {
        sum += i;
    }

    printf("合計値: %d\n", sum);
    return 0;
}

実行すると以下のような実行結果になります。

合計値: 45

この例では、sum変数に対してリダクション操作を行っています。各スレッドはsumの初期値のコピーを持ち、そのコピーに対する計算を行います。ループの終了後、これらのコピーは加算され、最終的な合計がsumに格納されます。

最大値の計算

以下のコードでは、リダクションを利用してある数列の中から最大値を求めます。

#include <omp.h>
#include <stdio.h>
#include <limits.h> // INT_MINのため

int main() {
    int data[] = {1, 3, 5, 7, 9, 2, 4, 6, 8, 10}; // この数列の最大値を求める
    int max_value = INT_MIN; // 最小の整数で初期化

    #pragma omp parallel for reduction(max:max_value)
    for (int i = 0; i < 10; i++) {
        if (data[i] > max_value) {
            max_value = data[i];
        }
    }

    printf("最大値: %d\n", max_value);
    return 0;
}

実行すると以下のような実行結果になります。

最大値: 10

この例では、max_value変数を最小値で初期化し、reduction(max:max_value)句を使って各スレッドの最大値を求めています。各スレッドは、自身が処理するデータの中で最大のものを見つけ、最終的にこれらの最大値が結合され、全体の最大値が得られます。

その他機能

スレッド数の管理

以下の関数でスレッド数の設定、取得を行うことができます。

void omp_set_num_threads(int num_threads)

スレッド数(最大並列数)を設定を行う関数です。引数にスレッド数を設定します。

int omp_get_num_threads()

戻り値でomp_get_num_threads関数実行時のスレッド総数を取得します。

環境情報の取得

以下の関数で環境情報の取得を行うことができます。

int omp_get_max_threads()

並列セクション内で並列実行されるスレッド数の最大値を取得します。

int omp_get_thread_num()

実行中のスレッド番号を返します。 このスレッド番号はシステムレベルのスレッドIDとは異なり、OpenMP環境内でのみ意味を持つものです。通常、0~最大並列数-1 までの値の範囲となります。

同期ディレクティブ

以下は主に並列セクション内で使用される同期に関するディレクティブです。

#pragma omp barrier

使用すると、全てのスレッドが「#pragma omp barrier」ディレクティブに到達するまで待機します。

#pragma omp critical

特定のコードブロックを一度に一つのスレッドだけが実行できるようにします。主に共有の変数へのアクセスに対する排他制御のために使用されます。以下は「#pragma omp critical」ディレクティブを使用したソースコードの例です。

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

int main() {
    int total = 0;

    #pragma omp parallel for
    for(int i = 0; i < 100; i++) {
        #pragma omp critical
        total += 1;
    }
    
    printf("合計: %d\n", total);
    return 0;
}

実行すると以下のような実行結果になります。

合計: 100

しかし、「#pragma omp critical」ディレクティブを外してビルドして実行してみると以下のような実行結果になりました。

合計: 57

このように、「#pragma omp critical」ディレクティブがないと、複数スレッドでtotal変数を同時に更新しようとして競合が発生し、予期しない結果を引き起こしてしまいます。

まとめ

OpenMPについて学んでみて、意外とシンプルで扱いやすいように感じました。特にディレクティブベースなので既存のコードに容易に統合可能なところは非常に便利ですね。今後、高速化が求められるソフトウェアの開発が必要になったときは積極的に活用していきたいと思います。

参考文献

タイトルとURLをコピーしました