我编写了一个非常简单的“字数统计”程序,它读取文件并计算文件中每个单词的出现次数。以下是代码的一部分:
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的运行时有缺陷吗?
提前致谢。
答案 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)
尝试解释结果:
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());