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

Sae*_*ari 28 .net c# multithreading multiprocessing task-parallel-library

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

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));
    }
}
Run Code Online (Sandbox Code Playgroud)

它简单直接.我使用字典来计算每个单词的出现次数.该样式大致基于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的运行时有缺陷吗?

提前致谢.

Bra*_*ger 53

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倍(通过多线程)但保持GC的数量相同(这将发生,因为并行和串行配置中正在处理的批处理数量相同),最好我们可以获得的改进是原始时间的62.5%,或1.6倍.

但是,我们只看到1.25倍的加速,因为默认情况下GC不是多线程的(在工作站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秒)可能是由于测量不准确,磁盘争用,加载文件所需的时间,必须初始化线程池,上下文切换的开销或其他环境因素.

  • @SaeedShahrivari - 在更改GC之前,请考虑重构代码以更有效地使用内存. (3认同)
  • @SaeedShahrivari:即使您启用了服务器GC,您也可能只获得2倍的加速(而不是理论上的4倍); 真正的问题是正在创造如此多的垃圾..NET有一个优秀的GC,但是这个测试代码的内存分配模式是"压力测试"它.尝试通过以下方式减少创建的垃圾量:重用对象; 在`List <T>`中预分配容量; 避免`String.Split`(创建临时数组); 使用内存分析器来了解程序的分配行为. (2认同)

Hen*_*man 9

试图解释结果:

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

我替换了这个

var dic = new Dictionary<string, int>();
...
... else dic[token] += 1;
Run Code Online (Sandbox Code Playgroud)

结果更接近2倍加速.

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

您的示例代码很复杂,可以进行广泛的陈述Parallel.ForEach().
解决/分析真正的问题太简单了.