依存関係のないコード並列化
はじめに
この記事の第1部では、個々の反復間に依存関係がなく、正しく並列実行できる場合の成功例でのループの並列処理へのアプローチについて説明します。
第2部では、このような並列化を管理するために.NET 4.0で登場したメカニズムを検討し、これらのメカニズムの複雑さを明らかにします。
データが処理されるサイクルを検討してください。
-
for ( int i = 0 ; i < upperBound ; i ++ ) { // ... }
-
for ( int i = 0 ; i < upperBound ; i ++ ) { // ... }
-
for ( int i = 0 ; i < upperBound ; i ++ ) { // ... }
-
for ( int i = 0 ; i < upperBound ; i ++ ) { // ... }
サイクルの本体のロジックが、特定の反復での計算の結果が他の反復の計算の結果に依存しないようなものである場合、すべての反復を並列で実行できるため、このサイクルは「完全に並列」です。プロセッサ上のこのコア。
同じ定義を同様のforeachループに適用できます。 それから、反復計算の順序は重要ではありません。
並列ループのアプローチ
メソッド自体を実装してサイクルを並行して実行し、どのような困難に遭遇するかを見てみましょう。
そのため、次のようなメソッドを作成します。
- public static void ParallelFor ( int from、 int to、Action < int > body ) ;
toからtoまでのすべての値(後者は含まない)に対してbodyを実行し、最大のパフォーマンスを達成するために複数のスレッド間で実行を並列化する必要があります。 並列化はどのくらい可能ですか?
並列度の決定
このマシンのプロセッサにあるコアと同じ数のスレッドを使用するのが論理的であるという妥当な判断があります。 この決定により、各コアが完全にロードされます。 スレッドが増えると、スレッド間の切り替えのオーバーヘッドが少なくなり、コアの一部がアイドル状態になります。
このアプローチ-多くの場合、コアへのフローの観点からは、すべてのスレッドがプロセッサ上の計算のみに従事している理想的な計算モデルに基づいていますが、本当です。 ただし、スレッドを増やすと便利な場合があります。 たとえば、スレッドが作業の半分をリソースの待機に費やしている場合、この時点ではプロセッサはまだアイドル状態であり、ワーカースレッドの数が増えるとパフォーマンスが向上する可能性があります。
したがって、「コアごとに1つのスレッド」という単純なオプションから開始する価値がありますが、他の戦略を検討する準備ができていることがわかります。
使用可能なコアの数を取得するには、System.Environment.ProcessorCountプロパティを使用できます。 ハイパースレッディングを考慮に入れたコアの数が返されることに注意してください。つまり、存在する場合、すでに2倍になった「論理的な」コアの数が返されます。
上記に基づいて、論理的な(やや単純ですが)実装を次に示します。
- public static void ParallelFor ( int from、 int to、Action < int > body )
- {
- //スレッドの数と各スレッドのデータブロックのサイズを決定します
- intサイズ= to - from ;
- int numProcs =環境。 ProcessorCount
- int range = size / numProcs ;
- //データを分割し、すべてのスレッドを開始して完了を待ちます
- var threads = new List < Thread > ( numProcs ) ;
- for ( int p = 0 ; p < numProcs ; p ++ )
- {
- int start = p * range + from ;
- int end = ( p == numProcs - 1 ) ?
- to : start + range ;
- スレッド。 追加 ( 新しいスレッド( ( ) => {
- for ( int i = start ; i < end ; i ++ ) body ( i ) ;
- } ) ) ;
- }
- foreach (スレッド内の var thread )スレッド。 開始 ( ) ;
- foreach (スレッド内の var thread )スレッド。 参加 ( ) ;
- }
このバージョンの重要な欠点は、毎回新しいスレッドが作成/完了することです。これはかなり高価な操作であるためです(特に、現在実行されている関数がない場合でも、各スレッドは1Mのメモリをスタックに予約します)。
別の問題は、新しいスレッドの作成からも発生します。 本文には、ParallelFor呼び出しも含むコードが与えられているとします。 この場合、実行プロセス中に作成されるスレッドはnumProcsを超えず、2倍になり、スレッド間の切り替えのコストが高すぎる状況が発生する可能性があります(「過剰なマルチスレッド」)。
静的反復分布
したがって、スレッドを手動で作成する代わりに、スレッドプールを使用することをお勧めします。
- public static void ParallelFor ( int from、 int to、Action < int > body )
- {
- intサイズ= to - from ;
- int numProcs =環境。 ProcessorCount
- int range = size / numProcs ;
- int remaining = numProcs ;
- //同期オブジェクト。作業の完了を判断します
- 使用 ( ManualResetEvent mre = new ManualResetEvent ( false ) )
- {
- //すべてのタスクを作成します
- for ( int p = 0 ; p < numProcs ; p ++ )
- {
- int start = p * range + from ;
- int end = ( p == numProcs - 1 ) ? to : start + range ;
- スレッドプール QueueUserWorkItem ( デリゲート {
- for ( int i = start ; i < end ; i ++ )
- ボディ( i ) ;
- //最後のタスクが完了したかどうかを確認します
- if (インターロック。 減少 ( ref remaining ) == 0 )
- mre。 セット ( ) ;
- } ) ;
- }
- //すべてのタスクが完了するまで待ちます
- mre。 WaitOne ( ) ;
- }
- }
このソリューションは、すでに過度のマルチスレッド化やスレッド作成のコストから解放されています。 この状況でこのサイクルの可能な限り速い開発を防ぐことができるものは他にありますか?
すべての反復が時間的に短く、ほぼ同等のコストがかかる場合、上記のアプローチは最適に近いです。
そして、いくつかの反復が迅速に完了し、何倍も長くなると想像すると、プールからのスレッドは、「長い」反復の実行者として幸運ではなかったが、他のスレッドよりも数倍長く動作します。 同時に、並列動作全体の動作時間は最も遅いコンポーネントの動作時間によって決定されるため、結果として、残りのスレッドはすべての「作業」を行ってアイドル状態になり、一方「不運な」スレッドは全員を遅延させます。
このような状況での最小稼働時間を確保するために、
- または、反復の性質を把握して、すべてのスレッドが「長い」反復で均等に分散されるようにします。
- または、スレッド間でタスクを共有する一般的なアプローチを変更します。
動的反復分布
静的な分離から動的に移行することができます。つまり、各スレッドに特定の「部分」の作業を行い、それを行った後、元に戻す作業がまだ残っている場合は次の部分を受け取ります。
このアプローチは、次のコードで説明できます。
- public static void ParallelFor ( int from、 int to、Action < int > body )
- {
- int numProcs =環境。 ProcessorCount
- //残りの数
- int remainingWorkItems = numProcs ;
- int nextIteration = from ;
- 使用 ( ManualResetEvent mre = new ManualResetEvent ( false ) )
- {
- //タスクを作成します
- for ( int p = 0 ; p < numProcs ; p ++ )
- {
- スレッドプール QueueUserWorkItem ( デリゲート
- {
- intインデックス;
- //実行ごとに1つの要素を選択します
- while ( ( index = Interlocked。Increment ( ref nextIteration ) -1 ) < to )
- {
- ボディ(インデックス) ;
- }
- if (インターロック。 デクリメント ( ref remainingWorkItems ) == 0 )
- mre。 セット ( ) ;
- } ) ;
- }
- //すべてのタスクが完了するまで待つ
- mre。 WaitOne ( ) ;
- }
- }
このコードは、予測不可能なランタイムでの長い反復には適していますが、高速の反復では同期コストが多すぎます。
バランスの取れたアプローチ
したがって、並列化されたサイクルの性質によっては、スレッド間のタスクを静的に分離する戦略と動的な戦略の両方が有利であることがわかります。 いくつかのバランスの取れた戦略が中間の場合に成功するかもしれないと仮定することは論理的です。
以下はそのコードの変形です。 アイデアは、「部分」を1つの要素だけではなく、静的分布の場合よりも幾分多くすることです。
- public static void ParallelFor ( int from、 int to、Action < int > body )
- {
- int numProcs =環境。 ProcessorCount
- int remainingWorkItems = numProcs ;
- int nextIteration = from ;
- //データの「部分」のサイズ
- const int batchSize = 3 ;
- 使用 ( ManualResetEvent mre = new ManualResetEvent ( false ) )
- {
- for ( int p = 0 ; p < numProcs ; p ++ )
- {
- スレッドプール QueueUserWorkItem ( デリゲート {
- intインデックス;
- while ( ( index = Interlocked。Add ( ref nextIteration、batchSize ) -batchSize ) < to )
- {
- int end = index + batchSize ;
- if ( end > = to )
- end = to ;
- for ( int i = index ; i < end ; i ++ )
- ボディ( i ) ;
- }
- if (インターロック。 デクリメント ( ref remainingWorkItems ) == 0 )
- mre。 セット ( ) ;
- } ) ;
- }
- mre。 WaitOne ( ) ;
- }
- }
ここで、データ部分のサイズは定数によって設定され、それを変更して、タスクの静的分散と動的分散の間で必要なレベルを選択します。
おわりに
いずれにしても、特定のケースの最適な設定は、計算の性質によって決まります。 しかし、ほとんどの場合、多かれ少なかれ大規模な「サービス」の動的な分散との妥協はまったく受け入れられるかもしれません。 これが、.NET 4での並列ループ実行のためのライブラリメソッドの実装方法です。これについては、記事の第2部で説明します。
PSこの記事は、「
並列プログラミングのパターン:.NET FRAMEWORK 4およびVISUAL C#を使用した並列パターンの理解と適用 」という本の影響を受けて書かれたもので、処理を伴う無料翻訳と見なすことができます。
______________________
テキストは©SoftCoder.ruによってブログエディターで作成されます。
UPD:第二部-実用的: habrahabr.ru/blogs/net/104103