4-6 インテル® Parallel Composer による並列実行コードの準備

概要

コードの並列化にはさまざまな手法があります。この記事では、インテル® Parallel Composer で利用可能な手法の概要を説明し、各手法の主な長所を比較します。インテル®Parallel Composer は Windows* 上の C/C++ を使用した開発のみを対象としていますが、Fortran や Linux* 上での開発についても対象としている手法もあります (特定のコンパイラーが必要な場合があります)。

この記事は、「マルチスレッド・アプリケーションの開発のためのインテル・ガイド」の一部で、インテル® プラットフォーム向けにマルチスレッド・アプリケーションを効率的に開発するための手法について説明します。

はじめに

インテル® コンパイラーには並列に実行できるコード構造を自動的に検出して最適化するいくつかの方法 (自動ベクトル化や自動並列化など) が用意されていますが、ほとんどの手法ではコードの変更が必要です。挿入するプラグマや関数は、並列スレッドの分解やスケジュール実行を実際に行うランタイム・ライブラリー (OpenMP*、インテル® スレッディング・ビルディング・ブロック (インテル® TBB)、Win32* API など) に依存します。各手法の主な違いは、実行のために提供される制御のレベルです。一般に、より多くの制御が提供されるほど、より多くのコードの変更が必要になります。

OpenMP を使用した並列化

OpenMP は、移植性に優れたマルチスレッド・アプリケーション開発のための業界標準規格です。インテル® C++ コンパイラーは、OpenMP C/C++ バージョン 3.0 の仕様をサポートしています。詳細は、OpenMP Web サイト (http://www.openmp.org/) を参照してください。OpenMP を使用した並列化は、ユーザーが OpenMP 宣言子を使用して制御します。このアプローチは細粒度 (ループレベル) および粗粒度 (関数レベル) のスレッド化に効果的です。OpenMP 宣言子は、シリアル・アプリケーションを並列アプリケーションに変換する容易でパワフルな方法を提供し、マルチコアシステムの並列実行から大きなパフォーマンス・ゲインを引き出す可能性をもたらします。宣言子は、/Qopenmp コンパイラー・オプションを指定すると有効になります。このコンパイラー・オプションを指定しなかった場合、宣言子は無視されます。つまり、同じソースコードからアプリケーションのシリアルバージョンと並列バージョンの両方をビルドできます。共有メモリー並列コンピューターでは、シリアル実行と並列実行の単純比較ができます。

次の表に、一般的に使用される OpenMP 宣言子を示します。

宣言子 説明
#pragma omp parallel for [clause] ... for - loop プラグマ直後のループを並列化します。
#pragma omp parallel sections [clause] ... { [#pragma omp section structured-block] ... } 並列チームのスレッドに異なるセクションの実行を分配します。各構造ブロックは、チームの 1 つのスレッドにより、その暗黙的なタスクのコンテキスト内で一度実行されます。
#pragma omp master structured-block マスター構造内に含まれるコードをスレッドチームのマスタースレッドで実行します。
#pragma omp critical [ (name) ] structured-block 構造ブロックへの排他制御アクセスを提供します。プログラムの任意の場所で、一度に 1 つのクリティカル・セクションのみ実行できます。
#pragma omp barrier 並列領域内の複数のスレッドの実行を同期させます。バリアーの前のすべてのコードが全スレッドで完了するまで待機します。同期が完了するまでどのスレッドも barrier 宣言子の後のコードは実行しません。
#pragma omp atomic expression-statement ハードウェア同期プリミティブによる排他制御を提供します。クリティカル・セクションはコードのブロックに対する排他制御アクセスを提供しますが、atomic 宣言子は単一のステートメントに対する排他アクセスを提供します。
#pragma omp threadprivate (list) スレッドごとに 1 つのインスタンスの (つまり、各スレッドは変数の個々のコピーで動作) 複製するグローバル変数のリストを指定します。

例 1:
void sp_1a(float a[], float b[], int n) {
int i;
#pragma omp parallel shared(a,b,n) private(i)
{
#pragma omp for
for (i = 0; i < n; i++)
a[i] = 1.0 / a[i];
#pragma omp single
a[0] = a[0] * 10;
#pragma omp for nowait
for (i = 0; i < n; i++)
b[i] = b[i] / a[i];
}
} 
icl /c /Qopenmp par1.cpp
par2.cpp(5): (col. 5) remark: OpenMP 定義ループが並列化されました。
par2.cpp(10): (col. 5) remark: OpenMP 定義ループが並列化されました。
par2.cpp(3): (col. 3) remark: OpenMP 定義領域が並列化されました。

/Qopenmp-report[n] (n は 0 から 2) コンパイラー・オプションは、OpenMP パラレライザーの診断メッセージのレベルを制御します。このオプションを使用するには、/Qopenmp オプションを指定する必要があります。n を指定しない場合、デフォルトの /Qopenmp-report1 が使用され、正常に並列化されたループ、領域、セクションを示す診断メッセージが表示されます。

コードには宣言子のみが挿入されるため、インクリメンタルにコードを変更することができます。インクリメンタルなコードの変更は、シリアルの一貫性の維持に役立ちます。コードを 1 つのプロセッサーで実行すると、変更前のソースコードを実行したときと同じ結果が得られます。OpenMP は、複数のプラットフォームとオペレーティング・システムをサポートするシングル・ソースコード・ソリューションです。また、OpenMP ランタイムにより適切なコア数が自動的に選択されるため、コア数を特定する必要はありません。

OpenMP バージョン 3.0 では、OpenMP が最もよく使用されるループレベルの並列化に加え、新しくタスクレベルの並列化構造が追加され、関数の並列化が容易になりました。タスクモデルでは、効率的に並列化することが困難な、再帰などの不規則なパターンの動的データ構造や複雑な制御構造を含むプログラムを並列化できます。task プラグマは並列領域のコンテキスト内で動作し、明示的なタスクを作成します。並列領域内に task プラグマが存在すると、タスクブロックの内側のコードは、概念的には並列領域を実行するスレッドのうちの 1 つによって実行されるようにキューイングされます。シーケンシャルなセマンティクスを保持するために、並列領域内でキューイングされているすべてのタスクは並列領域の最後までに完了します。開発者は、明示的なタスク間、および明示的なタスクの内側と外側のコード間で依存性が存在しないこと、または適切に同期されることを確認する必要があります。

例 2:
#pragma omp parallel
#pragma omp single
{
  for(int i = 0; i < size; i++)
  {
    #pragma omp task
    setQueen (new int[size], 0, i, myid);
  }
}

インテル® C++ コンパイラーの並列化用言語拡張

インテル® コンパイラーは、C/C++ 言語拡張を使用して、並列プログラミングを容易にします。このバージョンのコンパイラーには、4 つのキーワードが用意されています。

  • __taskcomplete
  • __task
  • __par
  • __critical

アプリケーションでこれらのキーワードを使用して並列化を行うには、コンパイル時に /Qopenmp コンパイラー・スイッチを指定する必要があります。コンパイラーは、適切なランタイム・サポート・ライブラリーにリンクします。実際の並列化のレベルは、ランタイムシステムによって管理されます。この並列拡張機能は、OpenMP 3.0 ランタイム・ライブラリーを利用しますが、OpenMP プラグマと宣言子を使用することなく、より自然な C/C++ コードを保つことができます。並列拡張機能と OpenMP 構造のマッピングは次のとおりです。

並列拡張 OpenMP
__par #pragma omp parallel for
__critical #pragma omp critical
__taskcomplete S1 #pragma omp parallel #pragma omp single { S1 }
__task S2 #pragma omp task { S2 }

キーワードは、文のプリフィックスとして使用されます。

例 3:
__par for (i = 0; i < size; i++) 
setSize (new int[size], 0, i)
__taskcomplete {
__task sum(500, a, b, c);
  __task sum(500, a+500, b+500, c+500)
)
if ( !found )
__critical item_count++;

インテル® スレッディング・ビルディング・ブロック (インテル® TBB)

インテル®TBB は、C++ プログラムを並列化する豊富な手法を提供する、マルチコア・プロセッサーのパフォーマンスの活用に役立つライブラリーです。プラットフォームの詳細を抽象化する、より高いレベルのタスクベースの並列化と、パフォーマンスとスケーラビリティーのためのスレッド化メカニズムを示します。オブジェクト指向や C++ の汎用フレームワークにも適合します。インテル®TBB は、ランタイムベースのプログラミング・モデルを使用し、開発者に標準テンプレート・ライブラリー (STL) と同じようなテンプレート・ライブラリーをベースとした汎用並列アルゴリズムを提供します。

例 4:
#include "tbb/ParallelFor.h"
#include "tbb/BlockedRange2D.h"
void solve()
{
  parallel_for (blocked_range<size_t>(0, size, 1), [](const blocked_range<int> &r)
  {
    for (int i = r.begin(); i != r.end(); ++i)
      setQueen(new int[size], 0, (int)i);
  }
}

インテル®TBB タスク・スケジューラーはロードバランスを自動的に行うため、開発者が複雑なタスクを実行する必要はありません。プログラムを小さなタスクに分割することによって、インテル®TBB スケジューラーは作業が均等に分散されるようにタスクをスレッドに割り当てます。

インテル®C++ コンパイラーとインテル®TBB はどちらも、新しい C++0x ラムダ関数をサポートしています。これにより、STL とインテル®TBB のアルゴリズムがより使いやすくなります。インテルが提供するラムダ式を使用するには、/Qstd=c++0X コンパイラー・オプションを指定してコードをコンパイルしてください。

Win32 スレッド化 API と Pthreads*

場合によっては、ネイティブスレッド化 API の柔軟性を利用したいこともあるでしょう。この手法の主な利点は、この記事でこれまでに説明した抽象化手法よりも、スレッド化をより柔軟により細かく制御できることです。ただし、他の手法ではランタイムシステムが制御する作成、スケジューリング、同期、ローカルストレージ、ロードバランス、破棄などのスレッド実装作業をこの手法ではすべて開発者が行う必要があるため、実装に必要なコード量は多くなります。さらに、正しい数のスレッドを作成するために、利用可能なコア数を特定する必要があります。特にこの作業は、プラットフォームに依存しないソリューションでは非常に複雑になります。

例 5:
void run_threaded_loop (int num_thr, size_t size, int _queens[])
{
  HANDLE* threads = new HANDLE[num_thr];
  thr_params* params = new thr_params[num_thr];
  for (int i = 0; i < num_thr; ++i)
  {
    // Give each thread equal number of rows
    params[i].start = i * (size / num_thr);
    params[i].end = params[i].start + (size / num_thr);
    params[i].queens = _queens;
    // Pass argument-pointer to a different 
    // memory for each thread's parameter to avoid data races
    threads[i] = CreateThread (NULL, 0, run_solve, 
      static_cast<void *> (&params[i]), 0, NULL);
  }
  // Join threads: wait until all threads are done
  WaitForMultipleObjects (num_thr, threads, true, INFINITE);
  // Free memory
  delete[] params;
  delete[] threads;
}

スレッド化ライブラリー

アプリケーションに並列化を施す別の方法は、インテル® マス・カーネル・ライブラリー (インテル® MKL。インテル®Parallel Composer には含まれていません) やインテル® インテグレーテッド・パフォーマンス・プリミティブ (インテル® IPP) などのスレッド化ライブラリーを使用することです。インテル®MKL は、スレッド化に OpenMP を使用して、パフォーマンスを最大限に引き出すように高度に最適化されたスレッド化演算ルーチンを提供します。スレッド化されたインテル®MKL 関数を利用するには、OMP_NUM_THREADS 環境変数に 2 以上の値を設定して指定するだけです。インテル®MKL には、シリアルと並列のどちらで計算を実行するかを決定する内部的なしきい値があります。開発者は OpenMP API の omp_set_num_threads 関数を使用してしきい値を手動で設定することもできます。インテル®MKL の並列化については、インテル®MKL 9.0 Windows 版のオンライン・テクニカル・ノート (英語) やインテル® MKL 10.0 のスレッド化に関する別の記事 (英語) も参照してください。

インテル®IPP は、マルチメディア、データ処理、通信アプリケーション向けに高度に最適化された、ソフトウェア関数の広範囲なマルチコア対応ライブラリーです。インテル®IPP も、スレッド化に OpenMP を使用しています。インテル®IPP のスレッド化と OpenMP のサポートについては、別の記事 (英語) も参照してください。

インテル®C++ コンパイラーも、数学演算と超越演算のデータ並列パフォーマンスにはインテル®IPP を使用して STL valarray を実装しています。C++ の valarray テンプレート (http://www.aoc.nrao.edu/~tjuerges/ALMA/STL/html/classstd_1_1valarray.html) には、ハイパフォーマンス・コンピューティング向けの配列演算が含まれています。これらの演算は、ベクトル化などの低レベルのハードウェア機能を活用するように設計されています。インテルの valarray は、最適化された代替の valarray ヘッダーファイルを使用して、インテル®IPP 最適化バージョンの valarray にリンクするように実装されているので、ソースコードを変更する必要はありません。インテルのパフォーマンス最適化ヘッダーファイルを使用して valarray ループを最適化するには、/Quse-intel-optimized-headers コンパイラー・オプションを指定します。

自動並列化

自動並列化はインテル®C++ コンパイラーの機能です。自動並列化モードでは、コンパイラーはプログラムの本来の並列性を自動的に検出します。自動パラレライザーは、アプリケーション・ソースコード中のループのデータフローを解析して、安全かつ効率的に並列実行可能なループに対するマルチスレッド・コードを生成します。データの依存性が存在する場合、ループを自動並列化するためにはループを再構成する必要があります。

自動並列化モードでは、並列化に関するすべての決定はコンパイラーによって行われ、開発者が並列化するループを制御することはできません。自動並列化を OpenMP と組み合わせると、より高いパフォーマンスを得ることができます。OpenMP と自動並列化を組み合わせた場合、OpenMP 宣言子を含むループの並列化には OpenMP が、OpenMP 以外のループの並列化には自動並列化がそれぞれ使用されます。自動並列化を有効にするには、/Qparallel コンパイラー・オプションを指定してください。

例 6:
#define N 10000
float a[N], b[N], c[N];
void f1() {
  for (int i = 1; i < N; i++)
  c[i] = a[i] + b[i];
}
> icl /c /Qparallel par1.cpp
par1.cpp(5): (col. 4) remark: ループが自動並列化されました。

デフォルトでは、自動パラレライザーは正常に自動並列化されたループを表示します。/Qpar-report[n] オプション (n は 0 から 3) を指定すると、自動パラレライザーは自動並列化されたループと自動並列化に失敗したループに関する診断メッセージを表示します。例えば、/Qpar-report3 を指定すると、正常に自動並列化されたループと自動並列化に失敗したループの診断メッセージに加えて、自動並列化を妨げると判明または想定した依存関係に関する追加情報を表示します。この診断情報は、自動並列化するループを再構成するときに役立ちます。

自動ベクトル化

ベクトル化は、インテル® プロセッサー上でループのパフォーマンスを最適化する手法です。ベクトル化手法で定義される並列化は、プロセッサーの SIMD ハードウェアで実現可能なベクトルレベルの並列処理 (VLP) に基づきます。インテル®C++ コンパイラーの自動ベクトライザーは、並列で実行できるプログラム内の低レベルの演算を検出して、1 つの演算で 1、2、4、8、または 16 バイト (将来のプロセッサーでは 32 および 64 バイトに拡張) のデータ要素を処理するようにシーケンシャル・コードを変換します。コンパイラーで自動的にベクトル化するには、ループは独立している必要があります。自動ベクトル化は、前述した自動並列化や OpenMP などの他のスレッドレベルの並列化手法と組み合わせて使用できます。ほとんどの浮動小数点アプリケーションと一部の整数アプリケーションは、ベクトル化によってパフォーマンスが向上します。デフォルトのベクトル化レベルは /arch:SSE2 で、インテル® ストリーミング SIMD 拡張命令 2 (インテル® SSE2) 向けのコードを生成します。デフォルト以外のターゲットで自動ベクトル化を有効にするには、/arch (例: /arch:SSE4.1) または /Qx (例: /QxSSE4.2, QxHost) コンパイラー・オプションを指定してください。

下記の図の左は、ベクトル化なしでループの反復をシリアル実行しているため、SIMD レジスターの多くが使用されていません。図の右は、ベクトル化によりループの各反復について配列の 4 つの要素が並列に実行され、SIMD レジスターがすべて使用されています。

図 1. ループの反復とベクトル化

例 7:
#define N 10000 
float a[N], b[N], c[N]; 
void f1() {
  for (int i = 1; i < N; i++)
  c[i] = a[i] + b[i];
}
> icl /c /QxSSE4.2 par1.cpp 
par1.cpp(5): (col. 4) remark: ループがベクトル化されました。

デフォルトでは、ベクトライザーはベクトル化されたループを表示します。/Qvec-report[n] オプション (n は 0 から 5) を指定すると、ベクトライザーはベクトル化されたループとベクトル化されなかったループ、その理由などの診断情報を表示します。たとえば、/Qvec-report5 オプションを指定すると、ベクトル化されなかったループとベクトル化されなかった理由を表示します。この診断情報は、ベクトル化するループを再構成するときに役立ちます。

アドバイス/利用ガイド

異なる手法間のトレードオフ

さまざまな並列化手法は、抽象化、制御、単純性の点から分類できます。インテル®TBB と API モデルでは特定のコンパイラー・サポートは必要ありませんが、OpenMP では必要です。OpenMP を使用するためには、OpenMP 指示句を認識できるコンパイラーを使用する必要があります。API ベースのモデルでは、開発者は手動で並列タスクをスレッドにマップする必要があります。スレッド間には明示的な親子関係はありません。すべてのスレッドが対等です。このようなモデルは、スレッドの作成、管理、同期におけるすべての低レベルの局面で制御能力を開発者に与えます。この柔軟性が、ライブラリー・ベースのスレッド化手法の鍵となる長所です。この柔軟性を得るためのトレードオフは、大幅なコードの変更と大量のコーディングが必要になることです。並列タスクは、スレッドにマップできる関数にカプセル化しなければなりません。別の短所は、ほとんどのスレッド化 API が難解な呼び出し規則を使用しており、1 つの引数しか受け付けないことです。その結果、関数のプロトタイプとデータ構造の変更がしばしば必要になり、プログラム設計における抽象化が損なわれます。このため、オブジェクト指向の C++ アプローチよりも C に適しています。

コンパイラー・ベースのスレッド化手法として、OpenMP は根底のスレッド・ライブラリーに対する高レベルなインターフェイスを提供します。OpenMP では、開発者は OpenMP 宣言子を使用してコンパイラーに並列化を指示します。コンパイラーが詳細な処理を行うため、明示的なスレッド化手法における複雑な作業を行う必要がなくなります。並列化に対してインクリメンタルなアプローチをとるため、アプリケーションのシリアル構造は完全な状態が維持され、大幅なソースコードの変更は必要ありません。OpenMP 非対応コンパイラーは、単純に OpenMP 指示句を無視して、シリアルコードをそのまま残します。

ただし、OpenMP を使用すると、スレッドに対して細かく調整する制御力は損なわれます。また、OpenMP では、スレッドの優先度の設定やイベントベースまたはプロセス間の同期を実行する方法が開発者に提供されません。OpenMP は、スレッド間の明示的なマスターワーカーの関係に基づく fork-join スレッド化モデルです。このため、OpenMP が適合する問題の範囲は限定されます。一般に、OpenMP はデータ並列化の表現に最適で、明示的なスレッド化 API 手法は機能分割に最適です。OpenMP がループ構造や C コードをサポートしていることはよく知られていますが、C++ に対しては特定のサポートがありません。OpenMP バージョン 3.0 では、while ループや再帰構造などの不規則な構造に対するサポートの追加により OpenMP を拡張するタスク処理がサポートされています。しかし、通常、OpenMP は、C++ を最小限にサポートする単純な C および Fortran プログラミングを連想させることに変わりはありません。

インテル®TBB は、STL のような標準 C++ コードを使用して、汎用のスケーラブルな並列プログラミングをサポートしています。特定の言語やコンパイラーは不要です。抽象的でさらに汎用的なオブジェクト指向アプローチに適合する、柔軟で高レベルな並列化アプローチが必要な場合は、インテル®TBB が最適な選択肢です。インテル®TBB は共通の並列反復パターンにテンプレートを使用し、入れ子の並列化によりスケーラブルなデータ並列プログラミングをサポートします。API アプローチとは異なり、スレッドではなくタスクを指定し、インテル®TBB ランタイムを使用して、効率的な方法でライブラリーによりタスクをスレッドにマップします。インテル®TBB スケジューラーは、スケジューリングに対してはシングルの自動的な分割統治法を優先します。スケジューラーは、ロードされたコアから休止状態のコアにタスクを移動するタスクスチールを実装しています。OpenMP と比較すると、インテル®TBB に実装されている汎用アプローチでは、ビルトイン型に限定されない、開発者が定義した並列化構造で作業することが可能です。

次の表は、インテル®Parallel Composer で利用可能なスレッド化手法を比較したものです。

手法 説明 長所 注意
明示的スレッド化 API 低レベル (低水準) のマルチスレッド・プログラミング用の Win32 スレッド化 API や Pthreads などの低レベル API
  • 最大限の制御と柔軟性
  • 特別なコンパイラー・サポートは不要
  • コードの記述、デバッグ、保守が複雑で、非常に時間がかかる
  • スレッド管理と同期はすべて開発者が行う
OpenMP
(/Qopenmp コンパイラー・オプションを指定)
API とコンパイラー宣言子を使用して C/C++ および Fortran での共有メモリーの並列プログラミングをサポートする、OpenMP.org により定義されている仕様
  • 比較的少ない労力で大幅にパフォーマンスが向上する可能性
  • ラピッド・プロトタイピングに最適
  • C/C++ および Fortran で使用可能
  • コンパイラー宣言子を使用したインクリメンタル並列化が可能
  • ユーザーが並列化するコードを制御
  • 複数のプラットフォーム用のシングルソース・ソリューション
  • シリアルバージョンと並列バージョンの両方で同じコードベース
スレッドの優先順位の設定、イベントベースの実行、プロセス間の同期などのスレッドに対する制御をユーザーがあまり行えない
並列拡張
(/par コンパイラー・オプションを指定)
並列プログラミングを単純化するインテル® C++ コンパイラーの言語拡張 (__tasktemplate, __task, __par, __critical)
  • 単純な構文で並列化を表現可能
  • C++ アプリケーションでは OpenMP よりも構文の使用が容易
  • コンパイラー・サポートが必要
  • Fortran は未サポート
  • OpenMP 構文を拡張するため、OpenMP と同じ制限が適用される
インテル® スレッディング・ビルディング・ブロック (インテル®TBB) スレッド化の実装にかかる時間を軽減する並列アルゴリズムとコンカレント・データ構造の提供により、パフォーマンス目的のスレッド化作業を単純化するインテルの C++ ランタイム・ライブラリー
  • 特別なコンパイラー・サポートは不要
  • STL のように標準 C++ コードを使用
  • 自動のスレッド作成、管理、スケジューリング
  • スレッドではなくタスクの点からさまざまな並列化手法が可能
  • C++ プログラムに最適
  • Fortran は未サポート
自動並列化
(/Qparallel コンパイラー・オプションを指定)
プログラム中のループ伝播の依存がないループを自動的に並列化するインテル® C++ コンパイラーの機能
  • 並列化が可能なループのマルチスレッド・コードをコンパイラーが自動的に生成
  • 他のスレッド化手法と組み合わせて使用可能
コンパイラーが静的に処理できるループはデータ依存性とエイリアス解析により並列化可能
自動ベクトル化
(/arch: および /Qx オプションを指定)
シーケンシャルな命令を一度に複数のデータ要素で動作可能な SIMD 命令に変換することにより、インテル® プロセッサーのベクトルレベルの並列化を利用してループのパフォーマンスを最適化する手法
  • ベクトルレベルの並列化はコンパイラーが自動的に行う
  • 他のスレッド化手法と組み合わせて使用可能
プロセッサー固有のオプションが使用されている場合、生成されたコードはすべてのプロセッサーで動作しない可能性がある

Solve the N-Queens problem in parallel」では、このドキュメントで説明されている各並列化手法を、N クイーン問題 (エイト・クイーン・パズルのより一般的なバージョン) に適用して、並列ソリューションを実装するハンズオン・トレーニングを提供しています。その他のサンプルは、インテル® C++ コンパイラーのインストール・フォルダー「Samples」サブフォルダーにあります。