web-dev-qa-db-ja.com

MSDNが推奨していないのに、Parallel.ForEachがAsParallel()。ForAll()よりもはるかに高速なのはなぜですか?

ツリーを介して実行されるマルチスレッドアプリケーションを作成する方法を調べるために、いくつかの調査を行っています。

これを最適な方法で実装する方法を見つけるために、C:\ディスクを実行してすべてのディレクトリを開くテストアプリケーションを作成しました。

class Program
{
    static void Main(string[] args)
    {
        //var startDirectory = @"C:\The folder\RecursiveFolder";
        var startDirectory = @"C:\";

        var w = Stopwatch.StartNew();

        ThisIsARecursiveFunction(startDirectory);

        Console.WriteLine("Elapsed seconds: " + w.Elapsed.TotalSeconds);

        Console.ReadKey();
    }

    public static void ThisIsARecursiveFunction(String currentDirectory)
    {
        var lastBit = Path.GetFileName(currentDirectory);
        var depth = currentDirectory.Count(t => t == '\\');
        //Console.WriteLine(depth + ": " + currentDirectory);

        try
        {
            var children = Directory.GetDirectories(currentDirectory);

            //Edit this mode to switch what way of parallelization it should use
            int mode = 3;

            switch (mode)
            {
                case 1:
                    foreach (var child in children)
                    {
                        ThisIsARecursiveFunction(child);
                    }
                    break;
                case 2:
                    children.AsParallel().ForAll(t =>
                    {
                        ThisIsARecursiveFunction(t);
                    });
                    break;
                case 3:
                    Parallel.ForEach(children, t =>
                    {
                        ThisIsARecursiveFunction(t);
                    });
                    break;
                default:
                    break;
            }

        }
        catch (Exception eee)
        {
            //Exception might occur for directories that can't be accessed.
        }
    }
}

ただし、これをモード3(Parallel.ForEach)で実行すると、コードは約2.5秒で完了します(はい、SSDがあります;))。並列化なしでコードを実行すると、約8秒で完了します。コードをモード2(AsParalle.ForAll())で実行すると、ほぼ無限の時間がかかります。

プロセスエクスプローラーをチェックインすると、いくつかの奇妙な事実にも遭遇します。

Mode1 (No Parallelization):
Cpu:     ~25%
Threads: 3
Time to complete: ~8 seconds

Mode2 (AsParallel().ForAll()):
Cpu:     ~0%
Threads: Increasing by one per second (I find this strange since it seems to be waiting on the other threads to complete or a second timeout.)
Time to complete: 1 second per node so about 3 days???

Mode3 (Parallel.ForEach()):
Cpu:     100%
Threads: At most 29-30
Time to complete: ~2.5 seconds

特に奇妙なのは、Parallel.ForEachは、AsParallel()。ForAll()が前のタスクのいずれかが完了するのを待機しているように見えますが、すべての親タスクがすぐに実行されるのを待つ間、まだ実行中の親スレッド/タスクを無視しているようです。子タスクの完了をまだ待っています)。

また、MSDNで読んだのは、「可能な場合はForEachよりもForAllを優先する」でした。

ソース: http://msdn.Microsoft.com/en-us/library/dd997403(v = vs.110).aspx

なぜこれが起こり得るのか誰かが手掛かりを持っていますか?

編集1:

Matthew Watsonからのリクエストに応じて、ループする前にまずメモリにツリーをロードしました。これで、ツリーの読み込みが順次行われます。

しかし結果は同じです。 UnparallelizedとParallel.ForEachは、ツリー全体を約0.05秒で完了しますが、AsParallel()。ForAllは、1秒あたり約1ステップしか実行しません。

コード:

class Program
{
    private static DirWithSubDirs RootDir;

    static void Main(string[] args)
    {
        //var startDirectory = @"C:\The folder\RecursiveFolder";
        var startDirectory = @"C:\";

        Console.WriteLine("Loading file system into memory...");
        RootDir = new DirWithSubDirs(startDirectory);
        Console.WriteLine("Done");


        var w = Stopwatch.StartNew();

        ThisIsARecursiveFunctionInMemory(RootDir);

        Console.WriteLine("Elapsed seconds: " + w.Elapsed.TotalSeconds);

        Console.ReadKey();
    }        

    public static void ThisIsARecursiveFunctionInMemory(DirWithSubDirs currentDirectory)
    {
        var depth = currentDirectory.Path.Count(t => t == '\\');
        Console.WriteLine(depth + ": " + currentDirectory.Path);

        var children = currentDirectory.SubDirs;

        //Edit this mode to switch what way of parallelization it should use
        int mode = 2;

        switch (mode)
        {
            case 1:
                foreach (var child in children)
                {
                    ThisIsARecursiveFunctionInMemory(child);
                }
                break;
            case 2:
                children.AsParallel().ForAll(t =>
                {
                    ThisIsARecursiveFunctionInMemory(t);
                });
                break;
            case 3:
                Parallel.ForEach(children, t =>
                {
                    ThisIsARecursiveFunctionInMemory(t);
                });
                break;
            default:
                break;
        }
    }
}

class DirWithSubDirs
{
    public List<DirWithSubDirs> SubDirs = new List<DirWithSubDirs>();
    public String Path { get; private set; }

    public DirWithSubDirs(String path)
    {
        this.Path = path;
        try
        {
            SubDirs = Directory.GetDirectories(path).Select(t => new DirWithSubDirs(t)).ToList();
        }
        catch (Exception eee)
        {
            //Ignore directories that can't be accessed
        }
    }
}

編集2:

マシューのコメントの更新を読んだ後、次のコードをプログラムに追加しようとしました:

ThreadPool.SetMinThreads(4000, 16);
ThreadPool.SetMaxThreads(4000, 16);

ただし、これはAsParallelの実行方法を変更しません。それでも、最初の8ステップは瞬時に実行されてから、1ステップ/秒に減速します。

(補足、私は現在、Directory.GetDirectories()の周りのTry Catchブロックでディレクトリにアクセスできない場合に発生する例外を無視しています)

編集3:

また、私が主に関心を持っているのは、Parallel.ForEachとAsParallel.ForAllの違いです。なんらかの理由で、2番目のスレッドが再帰ごとに1つのスレッドを作成し、最初のスレッドが約30スレッドですべてを処理するのは奇妙ですマックス。 (また、MSDNがAsParallelを使用するように提案している理由は、タイムアウトが〜1秒で非常に多くのスレッドを作成するにもかかわらずです)

編集4:

私が見つけた別の奇妙なこと:スレッドプールのMinThreadsを1023を超える値に設定しようとすると、値を無視し、約8または16にスケールバックするようです:ThreadPool.SetMinThreads(1023、16);

それでも1023を使用すると、最初の1023要素が非常に高速になり、その後ずっと経験してきた遅いペースに戻ります。

注:また、文字通り1000を超えるスレッドが作成されるようになりました(Parallel.ForEach全体では30に比べて)。

これは、Parallel.ForEachがタスクの処理において非常にスマートであることを意味しますか?

さらに多くの情報、このコードは1023を超える値を設定すると8〜8を2回印刷します(値を1023以下に設定すると、正しい値が印刷されます)

        int threadsMin;
        int completionMin;
        ThreadPool.GetMinThreads(out threadsMin, out completionMin);
        Console.WriteLine("Cur min threads: " + threadsMin + " and the other thing: " + completionMin);

        ThreadPool.SetMinThreads(1023, 16);
        ThreadPool.SetMaxThreads(1023, 16);

        ThreadPool.GetMinThreads(out threadsMin, out completionMin);
        Console.WriteLine("Now min threads: " + threadsMin + " and the other thing: " + completionMin);

編集5:

Deanのリクエストにより、タスクを手動で作成する別のケースを作成しました。

case 4:
    var taskList = new List<Task>();
    foreach (var todo in children)
    {
        var itemTodo = todo;
        taskList.Add(Task.Run(() => ThisIsARecursiveFunctionInMemory(itemTodo)));
    }
    Task.WaitAll(taskList.ToArray());
    break;

これはParallel.ForEach()ループと同じくらい高速です。したがって、AsParallel()。ForAll()が非常に遅い理由に対する答えはまだありません。

32
Devedse

この問題はかなりデバッグ可能であり、スレッドに問題がある場合の珍しい贅沢です。ここでの基本的なツールは、[デバッグ]> [ウィンドウ]> [スレッド]デバッガウィンドウです。アクティブなスレッドを表示し、それらのスタックトレースを確認できます。遅くなると、すべてスタックしている数十のスレッドがアクティブになることが簡単にわかります。それらのスタックトレースはすべて同じに見えます。

    mscorlib.dll!System.Threading.Monitor.Wait(object obj, int millisecondsTimeout, bool exitContext) + 0x16 bytes  
    mscorlib.dll!System.Threading.Monitor.Wait(object obj, int millisecondsTimeout) + 0x7 bytes 
    mscorlib.dll!System.Threading.ManualResetEventSlim.Wait(int millisecondsTimeout, System.Threading.CancellationToken cancellationToken) + 0x182 bytes    
    mscorlib.dll!System.Threading.Tasks.Task.SpinThenBlockingWait(int millisecondsTimeout, System.Threading.CancellationToken cancellationToken) + 0x93 bytes   
    mscorlib.dll!System.Threading.Tasks.Task.InternalRunSynchronously(System.Threading.Tasks.TaskScheduler scheduler, bool waitForCompletion) + 0xba bytes  
    mscorlib.dll!System.Threading.Tasks.Task.RunSynchronously(System.Threading.Tasks.TaskScheduler scheduler) + 0x13 bytes  
    System.Core.dll!System.Linq.Parallel.SpoolingTask.SpoolForAll<ConsoleApplication1.DirWithSubDirs,int>(System.Linq.Parallel.QueryTaskGroupState groupState, System.Linq.Parallel.PartitionedStream<ConsoleApplication1.DirWithSubDirs,int> partitions, System.Threading.Tasks.TaskScheduler taskScheduler) Line 172  C#
// etc..

このようなものが表示されたら、すぐにファイアホースの問題を考えてください。おそらく、レースとデッドロックに続く、スレッドに関する3番目に多いバグです。

原因はわかったので、コードの問題は、完了したすべてのスレッドがN個のスレッドを追加することです。ここで、Nはディレクトリ内のサブディレクトリの平均数です。実際には、スレッドの数は指数的に増加します、これは常に悪いことです。これは、N = 1の場合にのみ制御を維持しますが、通常のディスクでは発生しません。

ほとんどすべてのスレッドの問題と同様に、この不正な動作は再現が不十分になる傾向があることに注意してください。マシンのSSDはそれを隠す傾向があります。同様に、マシンのRAM)を実行すると、プログラムを2回目に実行したときに、問題なくすばやく完了する可能性があります。ディスクではなくファイルシステムキャッシュから読み取るため、非常に高速です。ThreadPool.SetMinThreads()をいじると、それも非表示になりますが、修正することはできません。問題が修正されることはなく、非表示になるだけです。何が起こっても、指数は常に設定された最小スレッド数を圧倒します。 。それが発生する前にドライブの反復処理が完了することを期待することができます。大きなドライブを持つユーザーのアイドルの希望。

ParallelEnumerable.ForAll()とParallel.ForEach()の違いもおそらく簡単に説明できます。スタックトレースから、ForAll()がいたずらを行っていることがわかります。RunSynchronously()メソッドは、すべてのスレッドが完了するまでブロックします。ブロッキングとは、スレッドプールスレッドが実行してはならないことであり、スレッドプールを弱体化させ、別のジョブのためにプロセッサをスケジュールすることを許可しません。そして、あなたが観察した効果があります。スレッドプールは、他のN個のスレッドが完了するのを待っているスレッドですぐに圧倒されます。何も起こっていない、彼らはプールで待機しており、すでにアクティブになっているので、スケジュールされていません。

これはかなり一般的なデッドロックシナリオですが、スレッドプールマネージャーには回避策があります。アクティブなスレッドプールスレッドを監視し、タイムリーに完了しない場合はステップインします。次に、extraスレッドの開始を許可します。これは、SetMinThreads()で設定された最小値より1つ多くなります。ただし、SetMaxThreads()で設定された最大値を超えないようにしてください。アクティブなtpスレッドが多すぎると、リスクが高くなり、OOMがトリガーされる可能性があります。これはデッドロックを解決しますが、完了するためにForAll()呼び出しの1つを取得します。しかし、これは非常に遅い速度で発生し、スレッドプールはこれを1秒に2回しか実行しません。それが追いつく前に、あなたは忍耐を使い果たすでしょう。

Parallel.ForEach()にはこの問題はありません。ブロックされないため、プールを痛めません。

解決策のようですが、プログラムがまだマシンのメモリを消火していて、プールに待機中のtpスレッドをさらに追加していることに注意してください。これはプログラムをクラッシュさせる可能性もあります。メモリが大量にあり、スレッドプールがリクエストを追跡するためにメモリを大量に使用しないためです。ただし、一部のプログラマは それを達成する です。

解決策は非常に単純なものですが、スレッドを使用しないでください。これは有害であり、ディスクが1つしかない場合は同時実行性はありません。そして、それは複数のスレッドによって指揮されているようなではありません。スピンドルドライブで特に悪い、ヘッドシークは非常に非常に遅いです。 SSDの方がはるかに優れていますが、50マイクロ秒という簡単なオーバーヘッドが必要です。他の方法ではうまくキャッシュされるとは期待できないディスクにアクセスするための理想的なスレッド数は、常にoneです。

45
Hans Passant

最初に注意すべきことは、IOバウンド操作を並列化しようとしていることです。これにより、タイミングが大幅に歪められます。

2番目に注意すべきことは、並列化されたタスクの性質です。つまり、ディレクトリツリーを再帰的に下降しています。これを行うために複数のスレッドを作成すると、各スレッドがディスクの異なる部分に同時にアクセスする可能性があります。これにより、ディスクの読み取りヘッドがあちこちにジャンプし、速度が大幅に低下します。

テストを変更してメモリ内ツリーを作成し、代わりに複数のスレッドでアクセスしてみてください。その後、結果がすべての有用性を超えて歪むことなく、タイミングを適切に比較することができます。

さらに、多数のスレッドを作成している可能性があり、それらは(デフォルトでは)スレッドプールスレッドになります。スレッドの数が多いと、プロセッサコアの数を超えると、実際には速度が低下します。

また、スレッドプールの最小スレッド数( ThreadPool.GetMinThreads() で定義)を超えると、新しいスレッドプールスレッドが作成されるたびにスレッドプールマネージャーによって遅延が発生することに注意してください。 (これは新しいスレッドごとに約0.5秒だと思います)。

また、スレッドの数がThreadPool.GetMaxThreads()によって返される値を超える場合、作成中のスレッドは、他のスレッドの1つが終了するまでブロックされます。これは起こりそうだと思います。

ThreadPool.SetMaxThreads()ThreadPool.SetMinThreads()を呼び出してこれらの値を増やし、違いがあるかどうかを確認することで、この仮説をテストできます。

(最後に、本当にC:\から再帰的に下がろうとしている場合、保護されたOSフォルダーに到達すると、ほぼ確実にIO例外が発生します。)

注:最大/最小スレッドプールスレッドを次のように設定します。

ThreadPool.SetMinThreads(4000, 16);
ThreadPool.SetMaxThreads(4000, 16);

フォローアップ

上記のように設定されたスレッドプールスレッド数でテストコードを試したところ、次の結果が得られました(C:\ドライブ全体ではなく、より小さなサブセットで実行されます)。

  • モード1は06.5秒かかりました。
  • モード2は15.7秒かかりました。
  • モード3は16.4秒かかりました。

これは私の期待に沿うものです。これを行うためにスレッドの負荷を追加すると、実際にはシングルスレッドよりも遅くなり、2つの並列アプローチはほぼ同じ時間を要します。


他の誰かがこれを調査したい場合のために、決定的なテストコードをいくつか示します(OPのディレクトリ構造がわからないため、OPのコードは再現できません)。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;

namespace Demo
{
    internal class Program
    {
        private static DirWithSubDirs RootDir;

        private static void Main()
        {
            Console.WriteLine("Loading file system into memory...");
            RootDir = new DirWithSubDirs("Root", 4, 4);
            Console.WriteLine("Done");

            //ThreadPool.SetMinThreads(4000, 16);
            //ThreadPool.SetMaxThreads(4000, 16);

            var w = Stopwatch.StartNew();
            ThisIsARecursiveFunctionInMemory(RootDir);

            Console.WriteLine("Elapsed seconds: " + w.Elapsed.TotalSeconds);
            Console.ReadKey();
        }

        public static void ThisIsARecursiveFunctionInMemory(DirWithSubDirs currentDirectory)
        {
            var depth = currentDirectory.Path.Count(t => t == '\\');
            Console.WriteLine(depth + ": " + currentDirectory.Path);

            var children = currentDirectory.SubDirs;

            //Edit this mode to switch what way of parallelization it should use
            int mode = 3;

            switch (mode)
            {
                case 1:
                    foreach (var child in children)
                    {
                        ThisIsARecursiveFunctionInMemory(child);
                    }
                    break;

                case 2:
                    children.AsParallel().ForAll(t =>
                    {
                        ThisIsARecursiveFunctionInMemory(t);
                    });
                    break;

                case 3:
                    Parallel.ForEach(children, t =>
                    {
                        ThisIsARecursiveFunctionInMemory(t);
                    });
                    break;

                default:
                    break;
            }
        }
    }

    internal class DirWithSubDirs
    {
        public List<DirWithSubDirs> SubDirs = new List<DirWithSubDirs>();

        public String Path { get; private set; }

        public DirWithSubDirs(String path, int width, int depth)
        {
            this.Path = path;

            if (depth > 0)
                for (int i = 0; i < width; ++i)
                    SubDirs.Add(new DirWithSubDirs(path + "\\" + i, width, depth - 1));
        }
    }
}
6
Matthew Watson

Parallel.Forおよび.ForEachメソッドは、タスクでの反復の実行と同等に内部的に実装されます。そのようなループ:

Parallel.For(0, N, i => 
{ 
  DoWork(i); 
});

以下と同等です。

var tasks = new List<Task>(N); 
for(int i=0; i<N; i++) 
{ 
tasks.Add(Task.Factory.StartNew(state => DoWork((int)state), i)); 
} 
Task.WaitAll(tasks.ToArray());

そして、他のすべての反復と並行して実行される可能性があるすべての反復の観点から、これはokmentalモデルですが、現実には起こりません。実際には、並列処理では必ずしも反復ごとに1つのタスクを使用しません。これは、必要以上にオーバーヘッドが大きくなるためです。 Parallel.ForEachは、ループをできるだけ早く完了するために必要な最小限のタスクを使用しようとします。スレッドがそれらのタスクを処理するために利用可能になると、タスクをスピンアップし、それらの各タスクは管理スキームに参加します(これはチャンキングと呼ばれます)。そして、もっと戻ってきます。チャンクサイズは、参加するタスクの数、マシンの負荷などによって異なります。

PLINQの.AsParallel()の実装は異なりますが、同様に複数の反復を一時ストアにフェッチし、スレッド内で(ただしタスクとしてではなく)計算を実行して、クエリ結果を小さなバッファーに入れることができます。 (ParallelQueryに基づいて何かを取得し、さらに.Whatever()関数が並列実装を提供する拡張メソッドの代替セットにバインドします)。

これらの2つのメカニズムがどのように機能するかについて少し考えたところで、元の質問への回答を提供します。

それで、なぜ.AsParallel()がParallel.ForEachより遅いのですか?その理由は以下に由来します。タスク(またはここでの同等の実装)は、I/Oのような呼び出しで[〜#〜] not [〜#〜]ブロックを行います。彼らは「待って」、CPUを解放して他のことをします。しかし(C#の簡単な本を引用):「PLINQはスレッドをブロックせずにI/Oバウンドの作業を実行できません」。呼び出しはsynchronousです。これらは、CPU時間を消費しないタスクごとにWebページをダウンロードするなどの場合(およびその場合にのみ)並列度を上げることを目的として書かれています。

そしての関数呼び出しがI/Oバウンド呼び出しに正確に類似している理由は次のとおりです:スレッドの1つ(呼び出しそれT)は、すべての子スレッドが完了するまでブロックして何もしません。これは、ここでは処理が遅い場合があります。 T自体は、子のブロックが解除されるのを待つ間、CPUを集中的に使用しませんそれは待機するだけです。したがって、これは一般的なI/Oバウンド関数呼び出しと同じです。

3
Dean

受け入れられた回答に基づいて AsParallelはどのように機能しますか?

.AsParallel.ForAll()は、.ForAll()を呼び出す前にIEnumerableにキャストバックします

したがって、1つの新しいスレッド+ N個の再帰呼び出しが作成されます(それぞれが新しいスレッドを生成します)。

0
user1023602