web-dev-qa-db-ja.com

より多くのスレッドがすべてを高速化する必要があるため、常にParallel.Foreachを使用する必要がありますか?

すべての通常のforeachでparallel.foreachループを使用することは意味がありますか?

1,000,000アイテムのみを反復するparallel.foreachの使用をいつ開始する必要がありますか?

45
Elisabeth

いいえ、すべてのforeachに意味があるわけではありません。いくつかの理由:

  • あなたのコードは実際には並列化できないかもしれません。たとえば、次の反復で「これまでの結果」を使用していて、順序が重要である場合)
  • 集計(値の合計など)する場合は、Parallel.ForEachを使用する方法がありますが、盲目的に行うだけではいけません
  • とにかくあなたの仕事が非常に速く完了するなら、何の利益もありません、そしてそれは物事を遅くするかもしれません

基本的に、スレッドのnothingは盲目的に実行する必要があります。 senseが実際に並列化する場所を考えてください。ああ、そして影響を測定して、メリットが追加された複雑さの価値があることを確認してください。 (それはwillデバッグのようなものにとってはより困難です。)TPLは素晴らしいですが、無料のランチではありません。

73
Jon Skeet

いいえ、絶対にやるべきではありません。ここで重要な点は、実際には反復回数ではなく、実行する作業です。作業が本当に単純な場合、1000000デリゲートを並列で実行すると、大きなオーバーヘッドが追加され、従来のシングルスレッドソリューションよりも遅くなる可能性があります。データを分割することでこれを回避できるため、代わりに一連の作業を実行します。

例えば。以下の状況を考慮してください。

Input = Enumerable.Range(1, Count).ToArray();
Result = new double[Count];

Parallel.ForEach(Input, (value, loopState, index) => { Result[index] = value*Math.PI; });

ここでの操作は非常に単純なので、これを並行して実行するオーバーヘッドにより、複数のコアを使用するメリットが小さくなります。このコードは、通常のforeachループよりも大幅に遅く実行されます。

パーティションを使用することで、オーバーヘッドを削減し、実際にパフォーマンスの向上を観察できます。

Parallel.ForEach(Partitioner.Create(0, Input.Length), range => {
   for (var index = range.Item1; index < range.Item2; index++) {
      Result[index] = Input[index]*Math.PI;
   }
});

ここでの話の士気は、並列処理が難しいことであり、これは手近な状況を注意深く見た後でのみ使用する必要があります。さらに、並列処理を追加する前後の両方でコードをプロファイルする必要があります。

潜在的なパフォーマンスの向上に関係なく、並列処理は常にコードに複雑さを加えるため、パフォーマンスが既に十分である場合、複雑さを加える理由はほとんどありません。

19
Brian Rasmussen

短い答えはnoです。使用可能な各ループでParallel.ForEachまたは関連する構成体を使用するだけではいけません。 Parallelにはいくつかのオーバーヘッドがありますが、これは少数の高速な反復を伴うループでは正当化されません。また、breakは、これらのループ内ではかなり複雑です。

Parallel.ForEachは、ループ内の反復数、ハードウェア上のCPUコアの数、およびハードウェア上の現在の負荷に基づいて、タスクスケジューラが適切と考えるループをスケジュールするためのリクエストです。実際の並列実行は常に保証されているわけではなく、コアの数が少ない場合、反復回数が少ない場合、または現在の負荷が高い場合の可能性は低くなります。

Does Parallel.ForEachはアクティブなスレッドの数を制限しますか? および Does Parallel.Forは反復ごとに1つのタスクを使用しますか? も参照してください。

長い答え:

ループは、2つの軸にどのように分類されるかによって分類できます。

  1. いくつかの反復から多くの反復まで。
  2. 各反復は高速で、各反復は低速です。

3番目の要因は、タスクの期間が大きく異なる場合です。たとえば、マンデルブロ集合でポイントを計算する場合、いくつかのポイントは計算が速く、いくつかははるかに長くかかります。

高速で反復が少ない場合、並列化を使用する価値はないでしょう。おそらくオーバーヘッドのために遅くなるでしょう。並列化によって特定の小さくて高速なループが高速化されても、関心が寄せられる可能性はほとんどありません。ゲインは小さく、アプリケーションのパフォーマンスのボトルネックではないため、パフォーマンスではなく可読性を最適化します。

ループの反復が非常に少なく、より多くの制御が必要な場合は、次のようにタスクを使用してループを処理することを検討できます。

var tasks = new List<Task>(actions.Length); 
foreach(var action in actions) 
{ 
    tasks.Add(Task.Factory.StartNew(action)); 
} 
Task.WaitAll(tasks.ToArray());

多くの反復がある場合、Parallel.ForEachはその要素にあります。

Microsoftのドキュメント は、

並列ループが実行されると、TPLはデータソースを分割して、ループが複数のパーツを同時に操作できるようにします。タスクスケジューラは背後で、システムリソースとワークロードに基づいてタスクを分割します。ワークロードが不均衡になると、スケジューラーは可能であれば、複数のスレッドとプロセッサー間で作業を再分散します。

このパーティショニングと動的な再スケジューリングは、ループの反復回数が減少するにつれて効果的に実行することが難しくなり、反復の継続時間が変化し、同じマシンで実行されている他のタスクが存在する場合にさらに必要になります。

いくつかのコードを実行しました。

以下のテスト結果は、他に何も実行されていないマシンと、使用中の.Netスレッドプールからの他のスレッドを示しています。これは一般的ではありません(実際、Webサーバーのシナリオでは、非常に非現実的です)。実際には、少数の反復で並列化が行われない場合があります。

テストコードは次のとおりです。

namespace ParallelTests 
{ 
    class Program 
    { 
        private static int Fibonacci(int x) 
        { 
            if (x <= 1) 
            { 
                return 1; 
            } 
            return Fibonacci(x - 1) + Fibonacci(x - 2); 
        } 

        private static void DummyWork() 
        { 
            var result = Fibonacci(10); 
            // inspect the result so it is no optimised away. 
            // We know that the exception is never thrown. The compiler does not. 
            if (result > 300) 
            { 
                throw new Exception("failed to to it"); 
            } 
        } 

        private const int TotalWorkItems = 2000000; 

        private static void SerialWork(int outerWorkItems) 
        { 
            int innerLoopLimit = TotalWorkItems / outerWorkItems; 
            for (int index1 = 0; index1 < outerWorkItems; index1++) 
            { 
                InnerLoop(innerLoopLimit); 
            } 
        } 

        private static void InnerLoop(int innerLoopLimit) 
        { 
            for (int index2 = 0; index2 < innerLoopLimit; index2++) 
            { 
                DummyWork(); 
            } 
        } 

        private static void ParallelWork(int outerWorkItems) 
        { 
            int innerLoopLimit = TotalWorkItems / outerWorkItems; 
            var outerRange = Enumerable.Range(0, outerWorkItems); 
            Parallel.ForEach(outerRange, index1 => 
            { 
                InnerLoop(innerLoopLimit); 
            }); 
        } 

        private static void TimeOperation(string desc, Action operation) 
        { 
            Stopwatch timer = new Stopwatch(); 
            timer.Start(); 
            operation(); 
            timer.Stop(); 

            string message = string.Format("{0} took {1:mm}:{1:ss}.{1:ff}", desc, timer.Elapsed); 
            Console.WriteLine(message); 
        } 

        static void Main(string[] args) 
        { 
            TimeOperation("serial work: 1", () => Program.SerialWork(1)); 
            TimeOperation("serial work: 2", () => Program.SerialWork(2)); 
            TimeOperation("serial work: 3", () => Program.SerialWork(3)); 
            TimeOperation("serial work: 4", () => Program.SerialWork(4)); 
            TimeOperation("serial work: 8", () => Program.SerialWork(8)); 
            TimeOperation("serial work: 16", () => Program.SerialWork(16)); 
            TimeOperation("serial work: 32", () => Program.SerialWork(32)); 
            TimeOperation("serial work: 1k", () => Program.SerialWork(1000)); 
            TimeOperation("serial work: 10k", () => Program.SerialWork(10000)); 
            TimeOperation("serial work: 100k", () => Program.SerialWork(100000)); 

            TimeOperation("parallel work: 1", () => Program.ParallelWork(1)); 
            TimeOperation("parallel work: 2", () => Program.ParallelWork(2)); 
            TimeOperation("parallel work: 3", () => Program.ParallelWork(3)); 
            TimeOperation("parallel work: 4", () => Program.ParallelWork(4)); 
            TimeOperation("parallel work: 8", () => Program.ParallelWork(8)); 
            TimeOperation("parallel work: 16", () => Program.ParallelWork(16)); 
            TimeOperation("parallel work: 32", () => Program.ParallelWork(32)); 
            TimeOperation("parallel work: 64", () => Program.ParallelWork(64)); 
            TimeOperation("parallel work: 1k", () => Program.ParallelWork(1000)); 
            TimeOperation("parallel work: 10k", () => Program.ParallelWork(10000)); 
            TimeOperation("parallel work: 100k", () => Program.ParallelWork(100000)); 

            Console.WriteLine("done"); 
            Console.ReadLine(); 
        } 
    } 
} 

4コアのWindows 7マシンでの結果は次のとおりです。

serial work: 1 took 00:02.31 
serial work: 2 took 00:02.27 
serial work: 3 took 00:02.28 
serial work: 4 took 00:02.28 
serial work: 8 took 00:02.28 
serial work: 16 took 00:02.27 
serial work: 32 took 00:02.27 
serial work: 1k took 00:02.27 
serial work: 10k took 00:02.28 
serial work: 100k took 00:02.28 

parallel work: 1 took 00:02.33 
parallel work: 2 took 00:01.14 
parallel work: 3 took 00:00.96 
parallel work: 4 took 00:00.78 
parallel work: 8 took 00:00.84 
parallel work: 16 took 00:00.86 
parallel work: 32 took 00:00.82 
parallel work: 64 took 00:00.80 
parallel work: 1k took 00:00.77 
parallel work: 10k took 00:00.78 
parallel work: 100k took 00:00.77 
done

.Net 4と.Net 4.5でコンパイルされたコードを実行しても、ほとんど同じ結果になります。

一連の作業の実行はすべて同じです。スライス方法は関係なく、約2.28秒で実行されます。

1反復の並列処理は、並列処理がない場合よりも少し長くなります。 2項目の方が短いため、3項目であり、4回以上の反復ではすべて約0.8秒です。

すべてのコアを使用していますが、100%の効率ではありません。連続作業がオーバーヘッドなしで4つの方法で分割された場合、0.57秒で完了します(2.28/4 = 0.57)。

他のシナリオでは、2〜3回の並列処理でスピードアップはまったく見られませんでした。 Parallel.ForEachで細かく制御することはできず、アルゴリズムがそれらを1つのチャンクに「分割」して、マシンがビジー状態の場合は1コアで実行することを決定する場合があります。

15
Anthony

並列操作を実行するための下限はありません。作業するアイテムが2つしかなく、それぞれに時間がかかる場合でも、Parallel.ForEachを使用することは意味があるかもしれません。一方、1000000個のアイテムがあり、それらがあまり機能していない場合、並列ループは通常のループよりも速くならない可能性があります。

たとえば、外側のループがforループとParallel.ForEachの両方で実行される入れ子ループの時間を計測する簡単なプログラムを作成しました。私は4 CPU(デュアルコア、ハイパースレッド)ラップトップで計時しました。

作業する項目が2つだけの実行ですが、それぞれに時間がかかります。

 2回の外部反復、100000000回の内部反復:
 forループ:00:00:00.1460441 
 ForEach:00:00:00.0842240 

作業する数百万のアイテムを含む実行を次に示しますが、それらはあまり機能しません。

 100000000外部反復、2内部反復:
 forループ:00:00:00.0866330 
 ForEach:00:00:02.1303315 

知る唯一の本当の方法はそれを試すことです。

9
Gabe

一般に、コアあたりのスレッドを超えると、操作に関連する追加の各スレッドにより、処理が遅くなるが、速くはならない。

ただし、各操作の一部がブロックする場合(典型的な例はディスクまたはネットワークI/Oで待機しており、別の操作は互いに同期していないプロデューサーとコンシューマーです)、コアよりも多くのスレッドが速度を上げ始める可能性があります。 I/O操作が戻るまで、他のスレッドが進行できないときにタスクを実行できるためです。

このため、シングルコアマシンが標準である場合、マルチスレッドで実際に正当化されるのは、I/Oが導入するソートがブロックされるか、応答性を改善することでした(タスクの実行は少し遅くなりますが、はるかに高速です)ユーザー入力への応答を再開します)。

それでも、最近のシングルコアマシンはますます珍しくなっているため、並列処理ですべてを少なくとも2倍の速さで処理できるはずです。

これは、順序が重要である場合、またはタスクに固有の何かが同期したボトルネックを強制する場合、または操作の数が非常に少ないため、並列処理による速度の増加が、その並列処理を設定します。 (ロック競合の程度に応じて)共有リソースが同じ並列操作を実行する他のスレッドでスレッドをブロックする必要がある場合は、そうでない場合があります。

また、コードが最初から本質的にマルチスレッド化されている場合は、基本的に自分でリソースを奪い合う状況になる可能性があります(古典的なケースは、同時要求を処理するASP.NETコードです)。ここで並列操作の利点とは、4コアマシンでの単一のテスト操作がパフォーマンスの4倍に近づくことを意味しますが、同じタスクを実行する必要のあるリクエストの数が4に達すると、これらの4つのリクエストはそれぞれそれぞれのコアを使用しようとすると、それぞれにコアがある場合よりも少し良くなります(おそらく少し良い、おそらく少し悪い)。したがって、使用が単一要求テストから実際の多数の要求に変わると、並列操作の利点はなくなります。

1
Jon Hanna

アプリケーションのすべてのforeachループを並列のforeachで盲目的に置き換えるべきではありません。スレッド数が増えても、アプリケーションの動作が速くなるとは限りません。複数のスレッドから本当にメリットを得たい場合は、タスクを小さなタスクにスライスして、並行して実行できるようにする必要があります。アルゴリズムが並列化できない場合は、何のメリットもありません。

1
Darin Dimitrov

これらは、さまざまなレベルのパーティショニングとともに、純粋なシリアルが最も遅いことを示す私のベンチマークです。

class Program
{
    static void Main(string[] args)
    {
        NativeDllCalls(true, 1, 400000000, 0);  // Seconds:     0.67 |)   595,203,995.01 ops
        NativeDllCalls(true, 1, 400000000, 3);  // Seconds:     0.91 |)   439,052,826.95 ops
        NativeDllCalls(true, 1, 400000000, 4);  // Seconds:     0.80 |)   501,224,491.43 ops
        NativeDllCalls(true, 1, 400000000, 8);  // Seconds:     0.63 |)   635,893,653.15 ops
        NativeDllCalls(true, 4, 100000000, 0);  // Seconds:     0.35 |) 1,149,359,562.48 ops
        NativeDllCalls(true, 400, 1000000, 0);  // Seconds:     0.24 |) 1,673,544,236.17 ops
        NativeDllCalls(true, 10000, 40000, 0);  // Seconds:     0.22 |) 1,826,379,772.84 ops
        NativeDllCalls(true, 40000, 10000, 0);  // Seconds:     0.21 |) 1,869,052,325.05 ops
        NativeDllCalls(true, 1000000, 400, 0);  // Seconds:     0.24 |) 1,652,797,628.57 ops
        NativeDllCalls(true, 100000000, 4, 0);  // Seconds:     0.31 |) 1,294,424,654.13 ops
        NativeDllCalls(true, 400000000, 0, 0);  // Seconds:     1.10 |)   364,277,890.12 ops
    }


static void NativeDllCalls(bool useStatic, int nonParallelIterations, int parallelIterations = 0, int maxParallelism = 0)
{
    if (useStatic) {
        Iterate<string, object>(
            (msg, cntxt) => { 
                ServiceContracts.ForNativeCall.SomeStaticCall(msg); 
            }
            , "test", null, nonParallelIterations,parallelIterations, maxParallelism );
    }
    else {
        var instance = new ServiceContracts.ForNativeCall();
        Iterate(
            (msg, cntxt) => {
                cntxt.SomeCall(msg);
            }
            , "test", instance, nonParallelIterations, parallelIterations, maxParallelism);
    }
}

static void Iterate<T, C>(Action<T, C> action, T testMessage, C context, int nonParallelIterations, int parallelIterations=0, int maxParallelism= 0)
{
    var start = DateTime.UtcNow;            
    if(nonParallelIterations == 0)
        nonParallelIterations = 1; // normalize values

    if(parallelIterations == 0)
        parallelIterations = 1; 

    if (parallelIterations > 1) {                    
        ParallelOptions options;
        if (maxParallelism == 0) // default max parallelism
            options = new ParallelOptions();
        else
            options = new ParallelOptions { MaxDegreeOfParallelism = maxParallelism };

        if (nonParallelIterations > 1) {
            Parallel.For(0, parallelIterations, options
            , (j) => {
                for (int i = 0; i < nonParallelIterations; ++i) {
                    action(testMessage, context);
                }
            });
        }
        else { // no nonParallel iterations
            Parallel.For(0, parallelIterations, options
            , (j) => {                        
                action(testMessage, context);
            });
        }
    }
    else {
        for (int i = 0; i < nonParallelIterations; ++i) {
            action(testMessage, context);
        }
    }

    var end = DateTime.UtcNow;

    Console.WriteLine("\tSeconds: {0,8:0.00} |) {1,16:0,000.00} ops",
        (end - start).TotalSeconds, (Math.Max(parallelIterations, 1) * nonParallelIterations / (end - start).TotalSeconds));

}

}
0
AaronLS

いいえ。コードが実行していること、およびコードが並列化に適しているかどうかを理解する必要があります。データ項目間の依存関係により、並列化が困難になる可能性があります。つまり、スレッドが前の要素に対して計算された値を使用する場合、値が計算されるまで待機する必要があり、並列に実行できません。また、ターゲットアーキテクチャを理解する必要もありますが、通常、最近購入したほぼすべてのものにマルチコアCPUが搭載されます。シングルコア上でも、より多くのスレッドからいくつかの利点を得ることができますが、いくつかのブロッキングタスクがある場合のみです。また、並列スレッドの作成と編成にはオーバーヘッドがあることにも注意してください。このオーバーヘッドがタスクにかかる時間のかなりの部分(またはそれ以上)である場合、タスクの速度が低下する可能性があります。

0
tvanfosson