.NET多线程与多处理:糟糕的Parallel.ForEach性能

时间:2012-12-02 16:09:21

标签: c# .net multithreading task-parallel-library multiprocessing

我编写了一个非常简单的“字数统计”程序,它读取文件并计算文件中每个单词的出现次数。以下是代码的一部分:

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的利用率达到大约提升,所有四个实例都在大约2.3秒内完成!

这意味着当代码在多处理配置中运行时,它达到大约3.5的加速值,但是当它在多线程配置中运行时,加速大约是1.25。

你的想法是什么? 我的代码有什么问题吗?因为我认为根本没有共享数据,我认为代码不会遇到任何争议。 .NET的运行时有缺陷吗?

提前致谢。

3 个答案:

答案 0 :(得分:52)

Parallel.For不会将输入分为 n 个部分(其中 n MaxDegreeOfParallelism);相反,它创建了许多小批量,并确保最多同时处理 n 。 (这是因为如果一个批处理需要很长时间来处理,Parallel.For仍然可以在其他线程上运行。有关详细信息,请参阅Parallelism in .NET - Part 5, Partioning of Work。)

由于这种设计,您的代码正在创建并丢弃数十个Dictionary对象,数百个List对象和数千个String对象。这给垃圾收集器带来了巨大的压力。

在我的计算机上运行PerfMonitor报告在GC中花费了43%的总运行时间。如果您重写代码以使用更少的临时对象,您应该看到所需的4倍加速。 PerfMonitor报告的一些摘录如下:

  

超过总CPU时间的10%用于垃圾收集器。   大多数调整良好的应用程序都在0-10%的范围内。这通常是   由分配模式引起的,允许对象长时间存活   足以需要昂贵的Gen 2系列。

     

此程序的GC堆分配峰值速率超过10 MB /秒。   这是非常高的。这简直就是一个并不罕见   性能错误。

修改:根据您的评论,我将尝试解释您报告的时间安排。在我的计算机上,使用PerfMonitor,我在GC中花费了43%到52%的时间。为简单起见,我们假设50%的CPU时间是有效的,50%是GC。因此,如果我们使工作速度提高4倍(通过多线程)但保持GC的数量相同(这将发生,因为并行和串行配置中正在处理的批处理数量相同),最好我们可以获得的改进是原始时间的62.5%,或1.6×。

但是,我们只看到1.25倍的加速,因为默认情况下GC不是多线程的(在工作站GC中)。根据{{​​3}},所有托管线程在Gen 0或Gen 1集合期间暂停。 (并行和后台GC,在.NET 4和.NET 4.5中,可以在后台线程上收集Gen 2.)您的程序只有1.25倍的加速(并且您看到整体CPU使用率为30%),因为线程花费大部分时间暂停GC的时间(因为此测试程序的内存分配模式非常差)。

如果启用Fundamentals of Garbage Collection,它将在多个线程上执行垃圾回收。如果我这样做,程序运行速度提高2倍(CPU使用率几乎达到100%)。

当您同时运行程序的四个实例时,每个实例都有自己的托管堆,并且四个进程的垃圾回收可以并行执行。这就是为什么你看到100%的CPU使用率(每个进程使用100%的一个CPU)。总体时间略长(所有的时间为2.3秒,一个为2.05秒)可能是由于测量不准确,磁盘争用,加载文件所需的时间,必须初始化线程池,上下文切换的开销或其他环境因素。

答案 1 :(得分:9)

尝试解释结果:

  • VS Profiler中的快速运行表明它几乎没有达到40%的CPU利用率。
  • 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()做出广泛的陈述 解决/分析真正的问题太简单了。

答案 2 :(得分:0)

只是为了好玩,这是一个较短的PLINQ版本:

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