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

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

class Alaki { private static List input = new List(); 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>(); 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(); 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的加速值,但是当它在multithreading配置中运行时,加速大约是1.25。

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

提前致谢。

Parallel.For不将输入分为n个 (其中nMaxDegreeOfParallelism ); 相反,它创建了许多小批量,并确保最多n个正在同时处理。 (这是因为如果一个批处理需要很长时间来处理, Parallel.For仍然可以在其他线程上运行。请参阅.NET中的Parallelism – 第5部分,分配工作以获取更多详细信息。)

由于这种设计,您的代码正在创建并丢弃数十个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倍(通过multithreading),但保持GC的数量相同(这将发生,因为并行和串行配置中处理的批次数恰好相同),最好我们可以获得的改进是原始时间的62.5%,或1.6倍。

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

如果启用服务器GC ,它将在多个线程上执行垃圾回收。 如果我这样做,程序运行速度提高2倍(几乎100%的CPU使用率)。

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

试图解释结果:

  • 在VS Profiler中快速运行表明它几乎没有达到40%的CPU利用率。
  • String.Split是主要的热点。
  • 所以共享的东西必须阻止CPU。
  • 某事最有可能是内存分配。 你的瓶颈是
 var dic = new Dictionary>(); ... dic[token].Add(1); 

我替换了这个

 var dic = new Dictionary(); ... ... else dic[token] += 1; 

结果接近2倍加速。

但我的反问题是:这有关系吗? 您的代码非常人为且不完整。 并行版本最终会创建多个字典而不会合并它们。 这甚至不是真实的情况。 正如您所看到的,细节很重要。

您的示例代码要复杂,以便对Parallel.ForEach()做出广泛的声明。
解决/分析真正的问题太简单了。

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

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