目次

5. OpenMP

5.1 OpenMPとは

OpenMP[3][4]とは、共有メモリー環境で並列計算を行うプログラミング技術であり、 複数のコアを持ったCPUで、 同時に複数のスレッドを実行して処理を分割し計算時間を短縮するものです。 1台のコンピュータに複数のCPUが搭載されていればさらにその個数だけ速くなります。
OpenMPは次節で述べるマルチスレッドの一種ですが、 プログラミング方法が異なるので、ここでは特に"OpenMP"と呼びます。

5.2 OpenMPプログラミング

OpenMPでは並列処理を行うfor文のすぐ上に"#pragma omp"で始まる指示文(ディレクティブ)を挿入し、 コンパイルオプションでOpenMPを使用していることを知らせます。
コンパイルオプションがないときは指示文はコメントとみなされ 1コアのみを使用する逐次プログラムができます。
OpenMPは元のソースコードに一切手を加えることなく並列化できますので、 OpenMPで並列化できるアルゴリズムのときは最も簡単にプログラミングできます。 また、並列化したい場所だけを順々に並列化できますのでプログラミングの生産性が高くなります。
OpenMPはOSやコンパイラーに依存しない規格なので、 同じソースコードがさまざまな環境で動きます。

5.3 OpenMPプログラミング例

リスト5-1にベクトル和またはベクトル内積をOpenMPで並列計算するプログラムを示します。
"SIMD"の章で説明したようにベクトル内積はSIMDを用いると高速化されるのでSIMDを使用した関数(sdot_simd)も実装しています。

リスト5-1 OpenMPプログラム(omp.c)


     1	/*
     2	omp.c (OpenMP)
     3	
     4	VC++ : cl.exe /O2 /openmp omp.c
     5	gcc  : gcc -O2 -fopenmp omp.c -o omp
     6	
     7	Usage :
     8	> omp {1|2} <num> <loop> <thread> [sse|avx]
     9	*/
    10	
    11	#include <stdlib.h>
    12	#include <stdio.h>
    13	#include <string.h>
    14	#include <time.h>
    15	#include <immintrin.h>
    16	
    17	#ifdef _OPENMP
    18	#include <omp.h>
    19	#endif
    20	
    21	#ifndef _WIN32
    22	#include <sys/time.h>
    23	#endif
    24	
    25	static inline void vadd(int n, const float a[], const float b[], float c[])
    26	{
    27		int    i;
    28	#ifdef _OPENMP
    29	#pragma omp parallel for //schedule(static, 1)
    30	#endif
    31		for (i = 0; i < n; i++) {
    32			c[i] = a[i] + b[i];
    33		}
    34	}
    35	
    36	static inline double sdot(int n, const float a[], const float b[])
    37	{
    38		double sum = 0;
    39		int    i;
    40	#ifdef _OPENMP
    41	#pragma omp parallel for reduction(+:sum)
    42	#endif
    43		for (i = 0; i < n; i++) {
    44			sum += a[i] * b[i];
    45		}
    46	
    47		return sum;
    48	}
    49	
    50	static inline double sdot_simd(int n, const float a[], const float b[], int nthread, int simd)
    51	{
    52		double *sum_thread = (double *)malloc(nthread * sizeof(double));
    53		memset(sum_thread, 0, nthread * sizeof(double));
    54	
    55	#ifdef _OPENMP
    56	#pragma omp parallel
    57	#endif
    58		{
    59			int ithread = 0;
    60	#ifdef _OPENMP
    61			ithread = omp_get_thread_num();
    62	#endif
    63			const int lsimd = 4 * simd;  // = 4/8
    64			const int lthread = (n + (nthread - 1)) / nthread;
    65			const int ns = ithread * lthread;
    66			const int ne = ((ns + lthread) > n) ? n : (ns + lthread);
    67			const int nc = ns + ((ne - ns) / lsimd) * lsimd;
    68	
    69			if      (simd == 1) {
    70				// SSE
    71				float  fsum[4];
    72				__m128 vsum = _mm_setzero_ps();
    73				for (int i = ns; i < nc; i += lsimd) {
    74					vsum = _mm_add_ps(vsum,
    75					       _mm_mul_ps(_mm_loadu_ps(a + i),
    76					                  _mm_loadu_ps(b + i)));
    77				}
    78				_mm_storeu_ps(fsum, vsum);
    79				sum_thread[ithread] = fsum[0] + fsum[1] + fsum[2] + fsum[3];
    80			}
    81			else if (simd == 2) {
    82				// AVX
    83				float  fsum[8];
    84				__m256 vsum = _mm256_setzero_ps();
    85				for (int i = ns; i < nc; i += lsimd) {
    86					vsum = _mm256_add_ps(vsum,
    87					       _mm256_mul_ps(_mm256_loadu_ps(a + i),
    88					                     _mm256_loadu_ps(b + i)));
    89				}
    90				_mm256_storeu_ps(fsum, vsum);
    91				sum_thread[ithread] = fsum[0] + fsum[1] + fsum[2] + fsum[3]
    92				                    + fsum[4] + fsum[5] + fsum[6] + fsum[7];
    93			}
    94	
    95			for (int i = nc; i < ne; i++) {
    96				sum_thread[ithread] += a[i] * b[i];
    97			}
    98		}
    99	
   100		double sum = 0;
   101		for (int ithread = 0; ithread < nthread; ithread++) {
   102			sum += sum_thread[ithread];
   103		}
   104	
   105		return sum;
   106	}
   107	
   108	int main(int argc, char **argv)
   109	{
   110		int    calc = 1;
   111		int    nthread = 1;
   112		int    n = 1000;
   113		int    nloop = 1000;
   114		int    simd = 0;
   115		float  *a = NULL, *b = NULL, *c = NULL;
   116	#ifdef _WIN32
   117		clock_t t0, t1;
   118	#else
   119		struct timeval t0, t1;
   120	#endif
   121	
   122		// arguments
   123		if (argc >= 5) {
   124			calc    = atoi(argv[1]);
   125			n       = atoi(argv[2]);
   126			nloop   = atoi(argv[3]);
   127			nthread = atoi(argv[4]);
   128		}
   129		if (argc >= 6) {
   130			if      (!strcmp(argv[5], "sse")) {
   131				simd = 1;
   132			}
   133			else if (!strcmp(argv[5], "avx")) {
   134				simd = 2;
   135			}
   136		}
   137	
   138	#ifdef _OPENMP
   139		// threads
   140		omp_set_num_threads(nthread);
   141	#endif
   142	
   143		// alloc
   144		size_t size = n * sizeof(float);
   145		if (calc == 1) {
   146			a = (float *)malloc(size);
   147			b = (float *)malloc(size);
   148			c = (float *)malloc(size);
   149		}
   150		else if (calc == 2) {
   151			a = (float *)malloc(size);
   152			b = (float *)malloc(size);
   153		}
   154	
   155		// setup problem
   156		for (int i = 0; i < n; i++) {
   157			a[i] = i + 1.0f;
   158			b[i] = i + 1.0f;
   159		}
   160	
   161		// timer
   162	#ifdef _WIN32
   163		t0 = clock();
   164	#else
   165		gettimeofday(&t0, NULL);
   166	#endif
   167	
   168		// calculation
   169		double sum = 0;
   170		if      (calc == 1) {
   171			// add vector
   172			for (int loop = 0; loop < nloop; loop++) {
   173				vadd(n, a, b, c);
   174			}
   175			for (int i = 0; i < n; i++) {
   176				sum += c[i];
   177			}
   178		}
   179		else if (calc == 2) {
   180			// dot product
   181			for (int loop = 0; loop < nloop; loop++) {
   182				if (!simd) {
   183					sum += sdot(n, a, b);
   184				}
   185				else {
   186					sum += sdot_simd(n, a, b, nthread, simd);
   187				}
   188			}
   189			sum /= nloop;
   190		}
   191	
   192		// timer
   193		double cpu = 0;
   194	#ifdef _WIN32
   195		t1 = clock();
   196		cpu = (double)(t1 - t0) / CLOCKS_PER_SEC;
   197	#else
   198		gettimeofday(&t1, NULL);
   199		cpu = (t1.tv_sec - t0.tv_sec) + 1e-6 * (t1.tv_usec - t0.tv_usec);
   200	#endif
   201	
   202		// output
   203		double exact = (calc == 1)
   204			? (double)n * (n + 1)
   205			: (double)n * (n + 1) * (2 * n + 1) / 6.0;
   206		printf("%d N=%d L=%d thread=%d %.6e(%.6e) err=%.1e %.3f[sec]\n",
   207			calc, n, nloop, nthread, sum, exact, fabs(sum - exact) / exact, cpu);
   208	
   209		// free
   210		if      (calc == 1) {
   211			free(a);
   212			free(b);
   213			free(c);
   214		}
   215		else if (calc == 2) {
   216			free(a);
   217			free(b);
   218		}
   219	
   220		return 0;
   221	}

OpenMP指示文
コンパイルオプション/openmpをつけるとマクロ"_OPENMP"が有効になります。
29行目と41行目でfor文を並列計算することを指示しています。
forループをスレッドで分割する方法には図5-1の2通りがあります。
それらを制御するにはschedule指示節を使用します。 schedule指示節がないときはblock型になります。
#pragma omp parallel for schedule(static) (block型)
#pragma omp parallel for schedule(static, 1) (cyclic型)
#pragma omp parallel for (block型)

図5-1 forループのスレッド分割(ループ長=12、スレッド数=3のとき)

reduction指示節
41行目ではベクトルの内積を並列計算するには、 すべてのスレッドが総和変数にアクセスするので以下のreduction指示節が必要になります。
reduction (operatorlist)
ここで operator は加算("+")、乗算("*")などを表し、 list はreduction演算される変数名を表します。

OpenMP関数
140行目の omp_set_num_threads 関数で使用するスレッド数を指定します。 本関数を呼ばないときはスレッド数は論理コア数になります。
61行目の omp_get_thread_num 関数で並列領域のスレッド番号(=0,1,2,...)を取得します。
OpenMP関数を使用するには18行目の "#include <omp.h>" 文が必要です。
OpenMP関数の使用頻度は高くありませんが、詳しくは[3][4]を参考にしてください。

OpenMP並列領域
56行目のように "#pragma omp parallel" を指示したときは、 その下の{}で囲まれた部分が並列領域になります。 この中でスレッド番号に応じた処理を記述します。
本プログラムでは各並列領域でスレッド毎の部分和を計算し、 最後に100-103行で全スレッドの部分和を加えて全体の和を求めています。
各スレッド内では図5-2のように4個(SSE)または8個(AVX)の単位で分割しSIMD演算を行います。 これは図5-1のblock型に相当します。

図5-2 forループのスレッド分割とSIMD分割

計算時間の計測
gccではclock関数は全スレッドの和になるので、 リスト5-1では計算時間を計測するためにgettimeofday関数を使用しています。

コンパイル・リンク方法
VC++ではコンパイルオプションに"/openmp"が必要です。
> cl.exe /O2 /openmp omp.c
gccではコンパイルオプションとリンクオプションに"-fopenmp"が必要です。
> gcc -O2 -fopenmp omp.c -o omp

プログラムの実行方法
プログラムの実行方法は以下の通りです。
> omp {1|2} 配列の大きさ 繰り返し回数 スレッド数 [sse|avx]
例えば以下のようになります。
> omp 1 1000000 1000 4 (ベクトル和を計算するとき)
> omp 2 1000000 1000 4 (ベクトル内積を計算するとき)
> omp 2 1000000 1000 4 avx (ベクトル内積をAVXで計算するとき)
繰り返し回数は計算時間の測定誤差を小さくするためです。
[sse|avx]はベクトル内積のときのみ有効です。
なお、OpenMP対応プログラムの実行にはvcomp140.dllファイルが必要です。 (Microsoft Visual Studio 2022の場合)

5.4 ベクトル和の計算時間

表5-1と図5-3にベクトル和の計算時間を示します。 SIMDは使用していません。 表5-1はblock型、図5-3はblock型とcyclic型です。
配列の大きさ(=N)と繰り返し回数(=L)の積は一定(=1010)です。従って全体の演算量は同じです。
使用メモリーは12Nバイトです。 従って、表の下になるほど使用メモリーが小さくなりキャッシュに乗りやすくなります。
スレッド数が少ないときは、No.3-4はキャッシュに入り、 自動ベクトル化の効果により計算時間が短くなります。
スレッド数が多いときは、No.2とNo.3のみOpenMP並列化の効果があり計算時間が短くなります。
図5-3の右の図からわかるようにcyclic型はスレッド数が増えるとむしろ計算時間が増え、 自動ベクトル化も無効になっています。
以上から、本ケースではblock型が優れていますが、 ループ内の演算量が多いときや一定でないときは結果が変わる可能性があります。

表5-1 ベクトル和の計算時間(OpenMP、block型、SIMDなし、VC++、単精度、()内は1スレッドとの速度比)
No.配列の大きさN繰り返し回数L 1スレッド 2スレッド 4スレッド 8スレッド 16スレッド
1 10,000,000 1,0005.53秒(1.0)4.70秒(1.18)4.78秒(1.16)4.81秒(1.15)5.34秒(1.04)
2 1,000,000 10,0005.93秒(1.0)4.79秒(1.24)3.59秒(1.65)0.98秒(6.05)1.16秒(5.11)
3 100,000 100,0001.13秒(1.0)0.57秒(1.98)0.27秒(4.19)0.21秒(5.38)0.24秒(4.71)
4 10,000 1,000,0000.94秒(1.0)0.73秒(1.29)0.83秒(1.13)0.84秒(1.12)1.14秒(0.82)

図5-3 ベクトル和の計算時間(OpenMP、SIMDなし、VC++、単精度)(左はblock型、右はcyclic型)

5.5 ベクトル内積の計算時間

表5-2~表5-4にベクトル内積の計算時間を示します。 それぞれ、SIMDなし、SSE、AVXです。 ループ分割はblock型です。
図5-4にそれぞれの計算時間を示します。
配列の大きさ(=N)と繰り返し回数(=L)の積は一定(=1010)です。従って全体の演算量は同じです。
使用メモリーは8Nバイトです。 従って、表の下になるほど使用メモリーが小さくなりキャッシュに乗りやすくなります。
スレッド数とSIMDの効果が複雑な特性を示します。
ベクトル内積では一般にSIMDの効果があり、さらにスレッド数を増やすとある程度速くなります。

表5-2 ベクトル内積の計算時間(OpenMP、SIMDなし、VC++、単精度、()内は1スレッドとの速度比)
No.配列の大きさN繰り返し回数L 1スレッド 2スレッド 4スレッド 8スレッド 16スレッド
1 10,000,000 1,0007.18秒(1.0)3.68秒(1.95)1.99秒(3.61)1.86秒(3.86) 1.94秒(3.70)
2 1,000,000 10,0007.85秒(1.0)4.11秒(1.91)2.23秒(3.52)1.05秒(7.48) 0.87秒(9.02)
3 100,000 100,0007.09秒(1.0)3.63秒(1.95)1.89秒(3.75)1.87秒(3.79) 5.51秒(1.29)
4 10,000 1,000,0007.12秒(1.0)3.98秒(1.79)5.41秒(1.32)9.72秒(0.73)65.74秒(0.11)

表5-3 ベクトル内積の計算時間(OpenMP、SSE、VC++、単精度、()内は1スレッドとの速度比)
No.配列の大きさN繰り返し回数L 1スレッド 2スレッド 4スレッド 8スレッド 16スレッド
1 10,000,000 1,0003.04秒(1.0)2.11秒(1.44)1.92秒(1.58)1.78秒(1.71) 1.90秒(1.60)
2 1,000,000 10,0003.19秒(1.0)2.13秒(2.42)0.95秒(1.96)0.25秒(12.76)0.20秒(15.95)
3 100,000 100,0001.80秒(1.0)0.93秒(1.94)0.49秒(3.67)0.35秒(5.14) 0.30秒(6.00)
4 10,000 1,000,0001.87秒(1.0)1.26秒(1.48)0.91秒(2.05)1.31秒(1.43) 1.60秒(1.17)

表5-4 ベクトル内積の計算時間(OpenMP、AVX、VC++、単精度、()内は1スレッドとの速度比)
No.配列の大きさN繰り返し回数L 1スレッド 2スレッド 4スレッド 8スレッド 16スレッド
1 10,000,000 1,0002.76秒(1.0)1.98秒(1.39)1.86秒(1.48)1.79秒(1.54) 1.89秒(1.46)
2 1,000,000 10,0002.78秒(1.0)2.05秒(1.36)0.76秒(3.66)0.16秒(17.38)0.17秒(16.35)
3 100,000 100,0000.91秒(1.0)0.48秒(1.90)0.27秒(3.37)0.21秒(4.33) 0.21秒(4.33)
4 10,000 1,000,0000.99秒(1.0)0.77秒(1.29)0.67秒(1.48)1.16秒(0.85) 1.50秒(0.66)

図5-4 ベクトル内積の計算時間(OpenMP、VC++、単精度)(左から、SIMDなし/SSE/AVX)