web-dev-qa-db-ja.com

実行ポリシーとそれらを使用するタイミングの違い

<algorithm>の大部分の(すべてではないにしても)関数が1つ以上の追加のオーバーロードを取得していることに気付きました。これらの追加のオーバーロードはすべて、特定の新しいパラメーターを追加します。たとえば、std::for_eachは次のようになります。

template< class InputIt, class UnaryFunction >
UnaryFunction for_each( InputIt first, InputIt last, UnaryFunction f );

に:

template< class ExecutionPolicy, class InputIt, class UnaryFunction2 >
void for_each( ExecutionPolicy&& policy, InputIt first, InputIt last, UnaryFunction2 f );

この余分なExecutionPolicyは、これらの関数にどのような影響がありますか?

違いは何ですか:

  • std::execution::seq
  • std::execution::par
  • std::execution::par_unseq

そして、どちらを使用するのですか?

29

seqpar/par_unseqの違いは何ですか?

std::for_each(std::execution::seq, std::begin(v), std::end(v), function_call);

std::execution::seqは順次実行を表します。実行ポリシーをまったく指定しない場合のデフォルトです。これにより、実装はすべての関数呼び出しを順番に実行します。すべてが呼び出しスレッドによって実行されることも保証されています。

対照的に、std::execution::parおよびstd::execution::par_unseqは並列実行を意味します。つまり、特定の関数のすべての呼び出しを、データの依存関係に違反することなく安全に並行して実行できることを約束します。実装は、並列実装の使用を許可されていますが、強制されていません。

parpar_unseqの違いは何ですか?

par_unseqparよりも強力な保証が必要ですが、追加の最適化が可能です。具体的には、par_unseqには、同じスレッドで複数の関数呼び出しの実行をインターリーブするオプションが必要です。

違いを例を挙げて説明します。このループを並列化するとします。

std::vector<int> v = { 1, 2, 3 };
int sum = 0;
std::for_each(std::execution::seq, std::begin(v), std::end(v), [&](int i) {
  sum += i*i;
});

上記のコードを直接並列化することはできません。これは、sum変数にデータ依存関係が導入されるためです。これを回避するには、ロックを導入します。

int sum = 0;
std::mutex m;
std::for_each(std::execution::par, std::begin(v), std::end(v), [&](int i) {
  std::lock_guard<std::mutex> lock{m};
  sum += i*i;
});

これで、すべての関数呼び出しを安全に並列実行できるようになり、parに切り替えてもコードが壊れることはありません。しかし、代わりにpar_unseqを使用すると、1つのスレッドが複数の関数呼び出しを順番にではなく同時に実行する可能性がある場合はどうなりますか?

たとえば、コードが次のように並べ替えられると、デッドロックが発生する可能性があります。

 m.lock();    // iteration 1 (constructor of std::lock_guard)
 m.lock();    // iteration 2
 sum += ...;  // iteration 1
 sum += ...;  // iteration 2
 m.unlock();  // iteration 1 (destructor of std::lock_guard)
 m.unlock();  // iteration 2

標準では、この用語はvectorization-unsafeです。 P0024R2 から引用するには:

標準ライブラリ関数は、別の関数呼び出しと同期するように指定されている場合、またはそれと同期するように別の関数呼び出しが指定されており、メモリ割り当てまたは割り当て解除関数でない場合、ベクトル化は安全ではありません。ベクトル化が安全でない標準ライブラリ関数は、parallel_vector_execution_policyアルゴリズムから呼び出されたユーザーコードによって呼び出すことはできません。

上記のコードをベクトル化に対して安全にする1つの方法は、ミューテックスをアトミックに置き換えることです。

std::atomic<int> sum{0};
std::for_each(std::execution::par_unseq, std::begin(v), std::end(v), [&](int i) {
  sum.fetch_add(i*i, std::memory_order_relaxed);
});

parよりもpar_unseqを使用する利点は何ですか?

実装がpar_unseqモードで使用できる追加の最適化には、ベクトル化された実行とスレッド間での作業の移行が含まれます(後者は、親並列化スケジューラでタスクの並列処理が使用される場合に関連します)。

ベクトル化が許可されている場合、実装は内部でSIMD並列処理(単一命令、複数データ)を使用できます。たとえば、OpenMPは #pragma omp simd annotations を介してサポートします。これは、コンパイラーがより良いコードを生成するのに役立ちます。

いつstd::execution::seqを使いますか?

  1. 正確性(データの競合を回避)
  2. 並列オーバーヘッドの回避(初期費用と同期)
  3. シンプルさ(デバッグ)

データの依存関係が順次実行を強制することは珍しいことではありません。つまり、並列実行によってデータの競合が発生する場合は、順次実行を使用します。

並列実行のためにコードを書き直して調整することは、常に簡単なことではありません。アプリケーションの重要な部分でない限り、順次バージョンから開始して、後で最適化できます。リソースの使用を慎重に行う必要がある共有環境でコードを実行している場合は、並列実行を回避することもできます。

並列処理も無料ではありません。予想されるループの合計実行時間が非常に短い場合、純粋なパフォーマンスの観点からも、シーケンシャル実行が最も優れていると考えられます。データが大きくなり、各計算ステップのコストが高くなるほど、同期オーバーヘッドの重要性は低くなります。

たとえば、上記の例で並列処理を使用しても意味がありません。ベクトルには3つの要素しか含まれておらず、演算は非常に安価です。また、ミューテックスまたはアトミックが導入される前のオリジナルバージョンには、同期オーバーヘッドが含まれていませんでした。並列アルゴリズムのスピードアップを測定する際の一般的な間違いは、ベースラインとして1つのCPUで実行されている並列バージョンを使用することです。代わりに、同期のオーバーヘッドのない最適化された順次実装と常に比較する必要があります。

いつstd::execution::par_unseqを使いますか?

最初に、それが正確さを犠牲にしないことを確認してください:

  • 異なるスレッドで並行してステップを実行するときにデータの競合がある場合、par_unseqはオプションではありません。
  • たとえば、コードがvectorization-unsafeの場合、ロックを取得するため、par_unseqはオプションではありません(ただしparは可能です)。

それ以外の場合、パフォーマンスが重要な部分である場合はpar_unseqを使用し、par_unseqseqよりもパフォーマンスを向上させます。

いつstd::execution::parを使いますか?

ステップを安全に並列実行できるが、par_unseqvectorization-unsafeであるため使用できない場合は、parの候補です。

seq_unseqと同様に、パフォーマンスが重要な部分であり、parseqよりもパフォーマンスが向上していることを確認します。

出典:

13
Philipp Claßen