为什么Parallel.For对于这种看似容易并行化的操作来说速度较慢?

时间:2015-12-04 22:57:23

标签: c# multithreading parallel-processing

我在http://AdventOfCode.com(第4天挑战赛)中获得了一些乐趣

他们的一个问题涉及获取每个数字1,2,3,...,将其视为字符串并计算该字符串的MD5哈希值。该问题需要第1个(最小的)数字,其MD5哈希值(十六进制)以6个零开始。

我用一个简单的for解决了它,但花了大约35秒(在2012 i5 Macbook上的Win10 VM中运行)。

看到CPU利用率相当低,我尝试了我脑海中最简单的优化 - TPL,更确切地说是Parallel.For

令我惊讶的是,(第一个)结果在42秒后被检索到,因此比单线程更糟糕。正如预期的那样,CPU利用率正在上升。

这是我的C#代码。为单线程与TPL注释一行或另一行。

using System;
using System.Text;
using System.Threading;
using System.Security.Cryptography;
using System.Diagnostics;
using System.Threading.Tasks;

class Program
{
    static void Main(string[] args)
    {
        Day4.P2();
    }
}

class Day4
{
    //not thread-safe, make one instance per thread
    static ThreadLocal<MD5> md5Hash = new ThreadLocal<MD5>(MD5.Create);

    public static int P2()
    {
        string input = "yzbqklnj";
        var sw = Stopwatch.StartNew();

        Action<int> checkAction = i =>
        {
            var hashBytes = md5Hash.Value.ComputeHash(Encoding.UTF8.GetBytes(input + i));
            if ( hashBytes[0] == 0 && hashBytes[1] == 0 && (hashBytes[2]) == 0 )
            {
                Console.WriteLine(i + ": " + sw.Elapsed);
            }
        };

        //for (var i = 0;; i++) { checkAction(i); }
        Parallel.For(1, int.MaxValue, checkAction);

        return 0;
    }
}

我的问题是:为什么并行版本不是很优越?

如何在线程之间对数据进行分区?

PS。在实际的Windows机器上运行时结果是相似的,但是(预期)第一个结果是最小(即问题的正确结果)。

5 个答案:

答案 0 :(得分:1)

  

为什么并行版本不是很优越?

因为没有理由这样做。没有保证处理顺序。情况可能是你的线程都忙着处理没有前6个字符0的数字,而顺序版本的线程比第一个正确的数字更快。< / p>

  

它是如何[即TPL]在其线程中划分数据?

MSDN上没有提到确切的方法,但关键原则是负载平衡。在Data Parallelism (Task Parallel Library)上引用MSDN页面:

  

在幕后,任务计划程序根据任务划分任务   系统资源和工作量。如果可能,调度程序   如果是,则在多个线程和处理器之间重新分配工作   工作量变得不平衡。

最后,并行版本的答案是错误的,但是,我得到的并行与顺序的数字与你所说的大不相同。我得到了:

  • 顺序 -
    • 第一个号码:9962624;经过时间:20.51秒
  • 平行 -
    • 第一个号码:1343955022;经过时间:10.06秒

此外,稍后,并行版本分别以21.7秒(9962624),22.06秒(541160794),23.59秒(541640646)给出了下一个数字。

我没有什么革命性的可以在这里得出结论,只是为了重申这一点

  1. 这取决于数据在TPL的“幕后”分区的方式。
  2. 分区的发生方式尚不清楚。

答案 1 :(得分:1)

行为是可以预期的,因为所有线程只是以某种方式划分范围1..int.MaxValue。这是一个巨大的范围,所以几乎所有的线程都可以处理大量的数字。一个线程可以做有用的工作并从头开始,但即使这样也不能保证,因此结果是不可预测的。我测量了你的程序的这个时间(纠正结果的时间):

original serial: 00:00:28.27 
original parallel: 00:00:24.53

您可以手动编码分块,但有一件事要尝试,将序列定义为有序。

int result = Enumerable.Range(1, int.MaxValue)
    //.AsParallel()
    //.AsOrdered()
    .Where(i =>
    {
        var hashBytes = md5Hash.Value.ComputeHash(Encoding.UTF8.GetBytes(input + i));
        return (hashBytes[0] == 0 && hashBytes[1] == 0 && hashBytes[2] == 0);
    }).First();

Console.WriteLine(result + ": " + sw.Elapsed);

我首先注释掉两行以使其成为串行。

enumerable serial: 00:00:26.68
ordered parallel: 00:01:53.41

这真是一个惊喜。虽然第一个数字实际上很快找到(可以在大约9.2秒内以Where条件打印到控制台),但事实证明引擎在每个线程返回至少一个值(或用完之前)不会合并结果顺序,大概)。所以大多数时候我们都在等待最慢的线程找到它的值。但是将Console.WriteLine返回到Where条件会返回订单问题。虽然结果有保证,但处理顺序不是。

最后,分块并不那么难

const int chunkSize = 100000;
int result = int.MaxValue;
object foundLock = new object();
for (int chunk = 1; chunk < int.MaxValue; chunk += chunkSize)
{
    Parallel.For(chunk, chunk + chunkSize, (i) =>
      {
          var hashBytes = md5Hash.Value.ComputeHash(Encoding.UTF8.GetBytes(input + i));
          if (hashBytes[0] == 0 && hashBytes[1] == 0 && hashBytes[2] == 0)
          {
              lock (foundLock)
              {
                  result = Math.Min(result, i);
              }
          }
      });

    if (result < int.MaxValue)
    {
        Console.WriteLine(result + ": " + sw.Elapsed);
        break;
    }
}

结果时间

chunked parallel: 00:00:08.85

答案 2 :(得分:0)

  

如何在线程之间对数据进行分区?

这是关键问题。这没有指定。它可能首先巧合地检查坏数字。也许你得到了一个错误的答案,因为你可能不一定得到最小的i,只有任何一个。

我会这样做:按顺序处理大小数量,例如10000个。一旦找到匹配,就会中止所有比具有匹配的块大的块的处理。您可能会发现一些更小的匹配,并且必须选择最小的匹配。

我不太确定如何使用TPL最好地实现这一点。 Parallel.ForEach循环可以中止,但我不知道它们是否可以可靠地订购。可能。

答案 3 :(得分:0)

随着操作的增长,并行化是有意义的,如果每个操作要完成的工作非常小,并行化时可能会变慢,因为增加线程和在它们之间切换的额外成本可能高于使用多个CPU的节省

要测试这个,如果您执行以下操作,您会得到类似的结果: 将int.MaxValue更改为int.MaxValue / 1000并使用for j = 1到1000将lambda的INSIDE包装起来,以确保一个单元的工作量增加1000倍,从而减少安排较少任务的时间,并在每个任务中“更多”时间

根据你到达的结果,我们可以得出一些结论。

答案 4 :(得分:0)

尝试在每次迭代时调用Console.WriteLine(...)。你可能会看到至少和我在机器上看到的一样有趣......

2: 00:00:00.0030730
1: 00:00:00.0031281
1073741824: 00:00:00.0033078
1073741825: 00:00:00.0080216
1073741826: 00:00:00.0080340
1073741827: 00:00:00.0080457

...

1073745189: 00:00:00.0663925
1073745190: 00:00:00.0664038
1073745191: 00:00:00.0664155
85: 00:00:00.0489811
86: 00:00:00.0666171
87: 00:00:00.0666364

...

40451: 00:00:01.1846214
1073753653: 00:00:01.1846293
40452: 00:00:01.1846365
1073753654: 00:00:01.1846440
40453: 00:00:01.1846527
1073753655: 00:00:01.1846633
40454: 00:00:01.1846750

... etc.

循环计数器可以非常快速地增加,但由于更多涉及的计算无法跟上,您可能会看到各种各样的值以看似任意的顺序进行测试。

按照usr的建议进行块测试(块不需要大于系统中的核心数)是利用多线程处理的一个想法,但请记住,你打破了逻辑这样做可以保证算法的正确性。以指定顺序运行测试不是多线程解决方案可以保证的。