web-dev-qa-db-ja.com

.NETのマルチスレッドとマルチプロセッシング:ひどいParallel.ForEachパフォーマンス

私は、ファイルを読み取り、ファイル内の各Wordの出現をカウントする非常に単純な「Word Count」プログラムをコーディングしました。これはコードの一部です:

class Alaki
{
    private static List<string> input = new List<string>();

    private static void exec(int threadcount)
    {
        ParallelOptions options = new ParallelOptions();
        options.MaxDegreeOfParallelism = threadcount;
        Parallel.ForEach(Partitioner.Create(0, input.Count),options, (range) =>
        {
            var dic = new Dictionary<string, List<int>>();
            for (int i = range.Item1; i < range.Item2; i++)
            {
                //make some delay!
                //for (int x = 0; x < 400000; x++) ;                    

                var tokens = input[i].Split();
                foreach (var token in tokens)
                {
                    if (!dic.ContainsKey(token))
                        dic[token] = new List<int>();
                    dic[token].Add(1);
                }
            }
        });

    }

    public static void Main(String[] args)
    {            
        StreamReader reader=new StreamReader((@"c:\txt-set\agg.txt"));
        while(true)
        {
            var line=reader.ReadLine();
            if(line==null)
                break;
            input.Add(line);
        }

        DateTime t0 = DateTime.Now;
        exec(Environment.ProcessorCount);
        Console.WriteLine("Parallel:  " + (DateTime.Now - t0));
        t0 = DateTime.Now;
        exec(1);
        Console.WriteLine("Serial:  " + (DateTime.Now - t0));
    }
}

シンプルでわかりやすいです。辞書を使用して、各単語の出現回数を数えます。スタイルはおおまかに MapReduce プログラミングモデルに基づいています。ご覧のとおり、各タスクは独自のプライベートディクショナリを使用しています。したがって、共有変数はありません。単語を数えるだけのタスクの集まりです。コードがクアッドコアi7 CPUで実行されたときの出力は次のとおりです。

パラレル:00:00:01.6220927
シリアル:00:00:02.0471171

スピードアップは約1.25で、これは悲劇を意味します!しかし、各行を処理するときに遅延を追加すると、約4のスピードアップ値に到達できます。

遅延のない元の並列実行では、CPUの使用率が30%に達することはほとんどないため、高速化は期待できません。ただし、遅延を追加すると、CPUの使用率は97%に達します。

まず、原因はプログラムのIOバウンドの性質であると考えました(ただし、ディクショナリへの挿入はある程度CPUに負荷がかかると思います)。すべてのスレッドが共有メモリバスからデータを読み取っているので、論理的に思えます。ただし、意外なのは、シリアルプログラムの4つのインスタンスを(遅延なしで)同時に実行すると、CPUの使用率が約上げられ、4つのインスタンスすべてが約2.3秒で終了することです。

つまり、コードがマルチプロセッシング構成で実行されている場合、約3.5のスピードアップ値に達しますが、マルチスレッド構成で実行されている場合、スピードアップは約1.25です。

あなたの考えは?コードに問題はありますか?共有データはまったくないと思いますし、コードで競合が発生することはないと思います。 .NETのランタイムに問題はありますか?

前もって感謝します。

29

Parallel.Forは、入力をnに分割しません(ここでnMaxDegreeOfParallelism);代わりに、多くの小さなバッチを作成し、最大でnが同時に処理されるようにします。 (これは、1つのバッチの処理に非常に長い時間がかかる場合、Parallel.Forは引き続き他のスレッドで作業を実行できます。詳細については、 。NETの並列処理-パート5、作業の分割 を参照してください。)

この設計により、コードは数十のDictionaryオブジェクト、数百のListオブジェクト、数千のStringオブジェクトを作成して破棄します。これは、ガベージコレクタに多大な圧力をかけています。

コンピューターで PerfMonitor を実行すると、総実行時間の43%がGCに費やされていると報告されています。使用する一時オブジェクトを少なくするようにコードを書き直すと、必要な4倍のスピードアップが見られます。 PerfMonitorレポートの一部を次に示します。

総CPU時間の10%以上がガベージコレクターで費やされました。最もよく調整されたアプリケーションは0〜10%の範囲です。これは通常、高価なGen 2コレクションを必要とするのに十分なだけオブジェクトが長く存続できるようにする割り当てパターンが原因です。

このプログラムには、10 MB /秒を超えるピークGCヒープ割り当て率がありました。これはかなり高いです。これが単なるパフォーマンスのバグであることは珍しくありません。

編集:あなたのコメントに従って、あなたが報告したタイミングを説明しようとします。私のコンピューターでは、PerfMonitorを使用して、GCで費やした時間の43%から52%を測定しました。簡単にするために、CPU時間の50%が作業時間で、50%がGCであると仮定します。したがって、(マルチスレッド化により)作業を4倍高速化するが、GCの量は同じに保つ場合(これは、処理されるバッチの数がたまたま並列構成とシリアル構成で同じになるために起こります)、達成できる改善は、元の時間の62.5%、つまり1.6倍です。

ただし、GCはデフォルトで(ワークステーションGCで)マルチスレッド化されていないため、1.25倍のスピードアップしか見られません。 ガベージコレクションの基礎 に従って、すべてのマネージスレッドはGen 0またはGen 1のコレクション中に一時停止されます。 (.NET 4および.NET 4.5の並行およびバックグラウンドGCは、バックグラウンドスレッドでGen 2を収集できます。)スレッドはほとんどの時間を費やすため、プログラムは1.25倍のスピードアップのみを経験します(そしてCPU使用率は全体で30%です)。 GCの一時停止時間(このテストプログラムのメモリ割り当てパターンは非常に悪いため)。

server GC を有効にすると、複数のスレッドでガベージコレクションが実行されます。これを行うと、プログラムの実行速度が2倍になります(CPU使用率がほぼ100%)。

プログラムの4つのインスタンスを同時に実行すると、それぞれに独自のマネージヒープがあり、4つのプロセスのガベージコレクションは並行して実行できます。これが、CPU使用率が100%になる理由です(各プロセスが1つのCPUの100%を使用しています)。全体的にわずかに長い時間(すべてで2.3秒vs 1つで2.05秒)は、測定の不正確さ、ディスクの競合、ファイルのロードにかかった時間、スレッドプールの初期化が必要、コンテキスト切り替えのオーバーヘッド、またはその他の原因が考えられます環境要因。

52

結果を説明する試み:

  • vSプロファイラーをすばやく実行すると、CPU使用率がわずか40%に達していることがわかります。
  • String.Splitがメインのホットスポットです。
  • したがって、共有された何かがCPUをブロックしている必要があります。
  • それはおそらくメモリ割り当てです。あなたのボトルネックは
_var dic = new Dictionary<string, List<int>>();
...
   dic[token].Add(1);
_

私はこれを

_var dic = new Dictionary<string, int>();
...
... else dic[token] += 1;
_

結果は2倍のスピードアップに近づきます。

しかし、私の反対の質問は次のようになります:それは重要ですか?あなたのコードは非常に人工的で不完全です。並列バージョンでは、マージせずに複数の辞書を作成することになります。これは実際の状況に近いものではありません。ご覧のとおり、細かいことはほとんど問題ありません。

あなたのサンプルコードはParallel.ForEach()についての幅広いステートメントを作るために複雑にすることです。
実際の問題を解決/分析するのは簡単すぎる。

9
Henk Holterman

楽しみのために、ここに短いPLINQバージョンを示します。

File.ReadAllText("big.txt").Split().AsParallel().GroupBy(t => t)
                                                .ToDictionary(g => g.Key, g => g.Count());
0
Slai