この記事は、インテル® デベロッパー・ゾーンに掲載されている「Migrate your application to use OpenMP or Intel(R) TBB instead of Intel(R) Cilk(TM) Plus」(https://software.intel.com/en-us/articles/migrate-your-application-to-use-openmp-or-intelr-tbb-instead-of-intelr-cilktm-plus) の日本語参考訳です。
概要
インテル® C++ コンパイラー 18.0 リリースから、インテル® Cilk™ Plus (https://www.cilkplus.org/specs) 構文は非推奨となり将来のリリースで廃止されることが予定されています。2010 年に発表されたインテル® Cilk™ Plus は、プログラムのマルチスレッド化とベクトル化の両方を可能にする画期的なプログラミング・モデルでした。インテル® Cilk™ Plus の仕様は、https://www.cilkplus.org/specs (英語) で公開されています。OpenMP* 4.0 の SIMD 拡張により、私たちはスレッド化と明示的なベクトル化を OpenMP* プラグマによって表現できるようになりました。スレッド化モデルとしてインテル® スレッディング・ビルディング・ブロック (インテル® TBB) を使用してきた C++ プログラマーはスレッド化にインテル® TBB を継続して利用でき、明示的にベクトル化を行うため OpenMP 4.0 SIMD プラグマを使用できます。以下の表には、インテル® Cilk™ Plus から OpenMP* またはインテル® TBB へ移行する際の選択肢を簡単にまとめています。
移行の手引き | インテル® Cilk™ Plus | OpenMP | インテル® TBB |
タスク並列 | cilk_spawn | #pragma omp task | task_group t;
t.run([](){ }) |
cilk_sync | #pragma omp taskwait | t.wait() | |
データ並列 (スレッド化) |
cilk_for | #pragma omp parallel for | tbb::parallel_for() |
データ並列 (明示的なループのベクトル化) |
#pragma simd | #pragma omp simd | インテル® TBB 自体はベクトル化をサポートしません。スレッド化にインテル® TBB を使用し、ベクトル化に OpenMP プラグマを使用します。 #pragma omp simd |
データ並列 (ベクトル関数) |
__declspec(vector()) または __attribute__((vector())) |
#pragma omp declare simd | #pragma omp declare simd |
スレッド数の制御 | __cilkrts_set_param( “nworkers”, nthreads);
nthreads |
omp_set_num_threads( nthreads);
nthreads |
task_schedule_init( nthreads);
global_control::max_ allowed_parallelism. nthreads); |
表に示される各シナリオは、抜粋された簡単なコードの一部を使用して詳しく説明します。
ケース 1: タスク並列
以下に、フィボナッチ数列の合計を求めるシングルスレッドの実装を示します。
int fibonacci(int num) { int a, b; if (num == 0) return 0; if (num == 1) return 1; a = fibonacci(num - 1); b = fibonacci(num - 2); return(a+b); }
上記のカーネルでは、独立して fibonacci(num-1) と fibonacci(num-2) を計算できます。インテル® Cilk™ Plus によるタスク並列の実装を以下に示します。
int fibonacci(int num) { int a, b; if (num == 0) return 0; if (num == 1) return 1; a = cilk_spawn fibonacci(num - 1); b = fibonacci(num - 2); cilk_sync; return(a+b); }
cilk_spawn は、対応するタスクを実行するため新しいワーカースレッドをスポーンする際に使用され、cilk_sync はマスタースレッドが処理を続行する前にスレッドを同期するバリアとなります。
等価な OpenMP* コード:
int fibonacci(int num) { int a, b; if (num == 0) return 0; if (num == 1) return 1; #pragma omp task shared(a) a = fibonacci(num - 1); #pragma omp task shared(b) b = fibonacci(num - 2); #pragma omp taskwait return(a+b); }
OpenMP* 標準では、コードセクションを異なるスレッドで実行する必要があることを指示するため #pragma omp task (cilk_spawn と同等) を提供します。shared 節は、変数 “a” と “b” がスレッド間で共有される必要があることを示します。スレッドのバリアは、#pragma omp taskwait (cilk_sync と同等) を使用して実装されます。
等価なインテル® TBB コード:
#include "tbb\task_group.h" int fibonacci(int num) { int a, b; task_group p; if (num == 0) return 0; if (num == 1) return 1; p.run([&] {a = fibonacci(num - 1); }); p.run([&] {b = fibonacci(num - 2); }); p.wait(); return(a+b); }
インテル® TBB は、task_group クラスのインスタンスを作成することで、タスクグループを生成し、run() メンバー関数により新しい独立したタスクを作成します。task_group の wait() メンバー関数は、バリアの実装に使用されます。
ケース 2: データ並列 (スレッド化)
シリアルにベクトル加算を行う次のループを考えてみます。
void vector_add(float *a, float *b, float *c, int N) { for (int i = 0; i < N; i++) c[i] = a[i] + b[i]; }
上記の操作はデータ並列性が高く、複数のスレッドで実行できます。インテル® Cilk™ Plus は、以下のように使用できる cilk_for キーワードを用意しています。
void vector_add(float *a, float *b, float *c, int N) { cilk_for (int i = 0; i < N; i++) c[i] = a[i] + b[i]; }
cilk_for で for ループを注釈することで、コンパイラーにループのボディーを並列コードとして生成することを通知します。
等価な OpenMP* コード:
void vector_add(float *a, float *b, float *c, int N) { #pragma omp parallel for for (int i = 0; i < N; i++) c[i] = a[i] + b[i]; }
#pragma omp parallel for (cilk_for と同様) は、OpenMP* による等価なループの並列性を表現する構文です。
等価なインテル® TBB コード:
#include "tbb/parallel_for.h" void vector_add(float *a, float *b, float *c, int N) { tbb::parallel_for(0, N, 1, [&](int i) { c[i] = a[i] + b[i]; }); }
tbb::parallel_for は、指定された範囲で並列反復を行うテンプレート関数です。
ケース 3: データ並列 (明示的なベクトル化)
ガウシアンフィルターをシリアルに実行する次のループを考えてみます。
void GaussianFilter(Mat &output, Mat &input) { float filter[3][3] = {{0.04491, 0.12210, 0.04491}, {0.12210, 0.33191, 0.12210}, {0.04491, 0.12210, 0.04491}}; int rows = output.rows; int cols = output.cols; float value; for(int i = 1; i < rows; i++) { int index = i*cols+1; for(int j = index; j < index+cols; j++) { value = 0.0f; for(int k1 = -1; k1 <= 1; k1++) { int index1 = j+(k1*cols); for(int k2 = -1; k2 <= 1; k2++) value += filter[k1+1][k2+1]*input.data[j+k2]; } output.data[j] = value; } } return; }
上記の操作も高いデータ並列性を持っています。デフォルトでは、コンパイラーは常に最内のループをベクトル化しようとしますが、ここでは最内のループの反復はわずか 3 回です。そのため、外部ループをベクトル化することが理にかなっています (インデックス j でループ)。インテル® Cilk™ Plus は、次のようにループを注釈する #pragma simd を提供しています。
void GaussianFilter(Mat &output, Mat &input) { float filter[3][3] = {{0.04491, 0.12210, 0.04491}, {0.12210, 0.33191, 0.12210}, {0.04491, 0.12210, 0.04491}}; int rows = output.rows; int cols = output.cols; float value; for(int i = 1; i < rows; i++) { int index = i*cols+1; #pragma simd private(value) for(int j = index; j < index+cols; j++) { value = 0.0f; for(int k1 = -1; k1 <= 1; k1++) { int index1 = j+(k1*cols); for(int k2 = -1; k2 <= 1; k2++) value += filter[k1+1][k2+1]*input.data[j+k2]; } output.data[j] = value; } } return; }
等価な OpenMP* コード:
void GaussianFilter(Mat &output, Mat &input) { float filter[3][3] = {{0.04491, 0.12210, 0.04491}, {0.12210, 0.33191, 0.12210}, {0.04491, 0.12210, 0.04491}}; int rows = output.rows; int cols = output.cols; float value; #pragma omp parallel for private(value) for(int i = 1; i < rows; i++) { int index = i*cols+1; #pragma omp simd private(value) for(int j = index; j < index+cols; j++) { value = 0.0f; for(int k1 = -1; k1 <= 1; k1++) { int index1 = j+(k1*cols); for(int k2 = -1; k2 <= 1; k2++) value += filter[k1+1][k2+1]*input.data[j+k2]; } output.data[j] = value; } } return; }
OpenMP* 4.0 SIMD 拡張は、インテル® Cilk™ Plus の #pragma simd と等価な #pragma omp simd 句を提供し、ループのベクトル化を強制できす。また、上記の場合、外部ループのベクトル化を明示することで、コンパイラーのデフォルトの解釈を変更できます。
次に示すように、インテル® TBB を使用して外部ループをスレッド化し、ループ・インデックス j のループを OpenMP* プラグマによってベクトル化することも考えられます。
void GaussianFilter(Mat &output, Mat &input) { float filter[3][3] = {{0.04491, 0.12210, 0.04491}, {0.12210, 0.33191, 0.12210}, {0.04491, 0.12210, 0.04491}}; int rows = output.rows; int cols = output.cols; float value; tbb::parallel_for(1, row, 1, [&](int i) { int index = i*cols+1; #pragma omp simd private(value) for(int j = index; j < index+cols; j++) { value = 0.0f; for(int k1 = -1; k1 <= 1; k1++) { int index1 = j+(k1*cols); for(int k2 = -1; k2 <= 1; k2++) value += filter[k1+1][k2+1]*input.data[j+k2]; } output.data[j] = value; } }); return; }
ケース 4: データ並列 (ベクトル関数):
次のコードについて考えてみます。
#include <iostream> #include <stdlib.h> __declspec(noinline) void vector_add(float *a, float *b, float *c, int i){ c[i] = a[i] + b[i]; } int main(int argc, char *argv[]) { float a[100], b[100], c[100]; for (int i = 0; i < 100; i++) { a[i] = i; b[i] = 100 - i; c[i] = 0; } for(int i = 0; i < 100; i++) vector_add(a,b,c,i); std::cout << c[0] << "\n"; return 0; }
上記のループをコンパイルすると、次のようなベクトル化レポートが得られました。
LOOP BEGIN at test.cc(16,2) remark #15543: loop was not vectorized: loop with function call not considered an optimization candidate. LOOP END
関数呼び出しでは、伝統的にスカラー変数を渡し、スカラーの戻り値を返します。関数を SIMD 対応とすることで、ベクトル引数を受け取り、ベクトル値を返すことができます。ベクトル化されたループからこの関数を呼び出すことが可能になります。インテル® Cilk™ Plusは、__declspec(vector(<節>)) (Windows*) と __attribute__((vector(<節>)) (Linux*) を提供し、次のように関数を注釈することでコンパイラーにベクトル版の関数本体も生成することを指示できます。
#include <iostream> #include <stdlib.h> __declspec(noinline, vector(uniform(a,b,c), linear(i:1))) void vector_add(float *a, float *b, float *c, int i){ c[i] = a[i] + b[i]; } int main(int argc, char *argv[]) { float a[100], b[100], c[100]; for (int i = 0; i < 100; i++) { a[i] = i; b[i] = 100 - i; c[i] = 0; } for(int i = 0; i < 100; i++) vector_add(a,b,c,i); std::cout << c[0] << "\n"; return 0; }
等価な OpenMP* コード:
#include <iostream> #include <stdlib.h> #pragma omp declare simd uniform(a,b,c) linear(i) __declspec(noinline) void vector_add(float *a, float *b, float *c, int i){ c[i] = a[i] + b[i]; } int main(int argc, char *argv[]) { float a[100], b[100], c[100]; for (int i = 0; i < 100; i++) { a[i] = i; b[i] = 100 - i; c[i] = 0; } for(int i = 0; i < 100; i++) vector_add(a,b,c,i); std::cout << c[0] << "\n"; return 0; }
OpenMP* 4.0 の SIMD 構文は、#pragma omp declare simd をサポートしており、これにより SIMD 対応関数を作成することができます。
コンパイラーの最適化に関する詳細は、最適化に関する注意事項を参照してください。