【问题标题】:Disappointing performance with Parallel.ForParallel.For 的性能令人失望
【发布时间】:2012-06-01 07:59:30
【问题描述】:

我正在尝试使用Parallel.For 来加快计算速度。我有一个具有 8 个内核的 Intel Core i7 Q840 CPU,但与顺序 for 循环相比,我只能获得 4 的性能比。这是否与 Parallel.For 一样好,或者可以对方法调用进行微调以提高性能?

这是我的测试代码,顺序:

var loops = 200;
var perloop = 10000000;

var sum = 0.0;
for (var k = 0; k < loops; ++k)
{
    var sumk = 0.0;
    for (var i = 0; i < perloop; ++i) sumk += (1.0 / i) * i;
    sum += sumk;
}

并行:

sum = 0.0;
Parallel.For(0, loops,
                k =>
                    {
                        var sumk = 0.0;
                        for (var i = 0; i < perloop; ++i) sumk += (1.0 / i) * i;
                        sum += sumk;
                    });

我正在并行化的循环涉及使用“全局”定义的变量sum 进行计算,但这应该只占并行化循环内总时间的一小部分。

在 Release 构建(“优化代码”标志设置)中,顺序 for 在我的计算机上循环需要 33.7 秒,而Parallel.For 循环需要 8.4 秒,性能比仅为 4.0。

在任务管理器中,我可以看到顺序计算时 CPU 使用率为 10-11%,而并行计算时仅为 70%。我试图明确设置

ParallelOptions.MaxDegreesOfParallelism = Environment.ProcessorCount

但无济于事。我不清楚为什么不将所有 CPU 功率都分配给 并行 计算?

我注意到在 SO before 上提出了类似的问题,结果更令人失望。但是,该问题还涉及第三方库中较差的并行化。我主要关心的是核心库中基本操作的并行化。

更新

在一些 cmets 中向我指出,我使用的 CPU 只有 4 个物理内核,如果启用了超线程,系统可以看到 8 个内核。为此,我禁用了超线程并重新进行了基准测试。

使用超线程禁用,我的计算现在更快,并行和(我认为是)顺序for 循环。 for 循环期间的 CPU 使用率高达大约。在Parallel.For 循环期间为 45% (!!!) 和 100%。

for 循环的计算时间为 15.6 秒(比启用超线程时快两倍多),Parallel.For 的计算时间为 6.2 秒(比启用超线程时快 25% 启用)。 Parallel.For 的性能比现在只有 2.5,在 4 个真实内核上运行。

因此,尽管禁用了超线程,但性能比仍然大大低于预期。另一方面,在for 循环期间 CPU 利用率如此之高是不是很有趣?在这个循环中是否也存在某种内部并行化?

【问题讨论】:

  • 并行循环不需要锁定 sum 变量吗?
  • @SteveB 我相信你是对的。但是,显式锁定不会提高性能,对吧? :-)
  • 你确定你有 8 个核心吗?根据this 的说法,例如,i7-840QM 有 4 个内核,由于多线程能力,操作系统可见为 8 个。
  • 超线程会欺骗你有 8 个内核的操作系统。您可以在 BIOS 中将其关闭。
  • 超线程技术在许多程序的日常工作中都能很好地工作。正如@Magnus 所说,它会“愚弄”操作系统,但会提高它在特定工作负载中的速度。少量核心的问题是,如果您执行大量“数据”特定工作 - 大量读取和写入磁盘,那么工作核心必须等待此过程完成。当您每 1 个物理核心有 2 个逻辑核心时,很有可能当一个任务正在等待数据时,核心可以用于其他工作。看这个答案superuser.com/questions/279629/…

标签: c# performance task-parallel-library


【解决方案1】:

使用全局变量可能会引入严重的同步问题,即使您不使用锁也是如此。当您为变量赋值时,每个内核都必须访问系统内存中的同一位置,或者等待另一个内核完成后再访问它。 您可以在操作系统级别使用较轻的Interlocked.Add 方法以原子方式将值添加到总和,从而避免没有锁的损坏,但您仍然会因争用而延迟。

执行此操作的正确方法是更新线程局部变量以创建部分总和,并将它们全部添加到最后的单个全局总和中。 Parallel.For 有一个重载就是这样做的。 MSDN 甚至在How To: Write a Parallel.For Loop that has Thread Local Variables 有一个使用求和的示例

        int[] nums = Enumerable.Range(0, 1000000).ToArray();
        long total = 0;

        // Use type parameter to make subtotal a long, not an int
        Parallel.For<long>(0, nums.Length, () => 0, (j, loop, subtotal) =>
        {
            subtotal += nums[j];
            return subtotal;
        },
            (x) => Interlocked.Add(ref total, x)
        );

每个线程更新自己的 subtotal 值,并在完成时使用 Interlocked.Add 更新全局 total

【讨论】:

  • 非常感谢 Panagiotis 的宝贵回答!我不知道这个功能,但我肯定会在我的生产代码中使用它。
  • 将此标记为答案。在我上面概述的测试用例中,该解决方案似乎并没有显着加快速度,但它为并行化代码增加了非常宝贵的可靠性。
【解决方案2】:

Parallel.For 和 Parallel.ForEach 将使用它认为合适的并行度,平衡设置和拆除线程的成本以及它期望每个线程执行的工作。 .NET 4.5 made several improvements to performance (including more intelligent decisions on the number of threads to spin up) compared to previous .NET versions.

请注意,即使每个内核启动一个线程,上下文切换、false sharing 问题、资源锁定和其他问题也可能会阻止您实现线性可扩展性(通常,不一定与您的特定代码示例)。

【讨论】:

  • 感谢您的回答,埃里克。我用 .NET 4.5 重新编译了代码,但就我而言,时间是相同的。列出并行化问题是件好事。即使在我高度并行化的情况下,我也不期望精确的线性可扩展性,但我预计在 8 核 CPU 上会好于 4 倍。我是否应该期望手动创建线程会提高性能?
  • 我很高兴地报告,在这种情况下,古斯塔夫森定律 (en.wikipedia.org/wiki/Gustafson's_law) 可能会引起人们的兴趣。值得了解并行性能如何随作业大小而变化。
  • 不知道那个,@HighPerformanceMark,但从现在开始我将永远参考这条法律 :-)
【解决方案3】:

我认为计算增益是如此之低,因为您的代码“太容易”而无法在每次迭代中处理其他任务 - 因为 parallel.for 只是在每次迭代中创建新任务,因此在线程中为它们提供服务需要时间。我会这样:

int[] nums = Enumerable.Range(0, 1000000).ToArray();
long total = 0;

Parallel.ForEach(
    Partitioner.Create(0, nums.Length),
    () => 0,
    (part, loopState, partSum) =>
    {
        for (int i = part.Item1; i < part.Item2; i++)
        {
            partSum += nums[i];
        }
        return partSum;
    },
    (partSum) =>
    {
        Interlocked.Add(ref total, partSum);
    }
);

Partitioner 将为每个任务创建最佳的作业部分,使用线程的服务任务的时间将更少。如果可以的话,请对这个解决方案进行基准测试,并告诉我们它是否可以加快速度。

【讨论】:

  • 查看我的评论以了解有关超线程的更多信息。
  • Jacob,根据docs.microsoft.com/en-us/dotnet/standard/parallel-programming/…,任务并行库(在您的示例中为Parallel.ForEach自动猜测如何“分块”请求的任务 - 它“在每次迭代中创建新任务”。 [那将是糟糕的性能。] 您调用的 Partitioner.Create 重载可能是内部使用的重载。除非您调用带有更多参数的重载,否则无法获得任何好处 - 并且已经过测试以确定更适合您的特定任务的块大小。
  • ... (由于 ForEach 说它需要 dynamic 分区,我怀疑它会做出猜测,然后使用返回的第一个块使用的实际时间来制作更好地猜测下一个分块 - 您提供的任何静态参数都很难击败,除非在定义明确且经过全面测试的情况下。)注意:我不知道这种改进是否发生在您之后写下你的答案;只是确保读者了解情况。
  • @ToolmakerSteve 感谢您的评论,当时我不知道所有这些细节 :) 还没有看到您链接到的文章,看起来很好地参考了这个问题。因此,与往常一样,.NET 已经内置了很好的解决方案,但如果需要,总是可以进行“手动调整”的调整。
  • @OP 请检查一下我相信这就是原因......为每个循环制作任务更昂贵,你想要做的是将大数字 10000000 分成等于数字的组核心......所以8个核心,8个组,然后每组都以parrell完成,所以每个核心都这样做(1 250 000)。尝试在自己的线程上执行每一项操作效率极低,我对此感到惊讶。
【解决方案4】:

foreach vs parallel for each example

    for (int i = 0; i < 10; i++)
    {
        int[] array = new int[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29 };
        Stopwatch watch = new Stopwatch();
        watch.Start();
        //Parallel foreach
        Parallel.ForEach(array, line =>
        {
            for (int x = 0; x < 1000000; x++)
            {

            }

        });

        watch.Stop();
        Console.WriteLine("Parallel.ForEach {0}", watch.Elapsed.Milliseconds);
        watch = new Stopwatch();
        //foreach
        watch.Start();
        foreach (int item in array)
        {
            for (int z = 0; z < 10000000; z++)
            {

            }
        }
        watch.Stop();
        Console.WriteLine("ForEach {0}", watch.Elapsed.Milliseconds);

        Console.WriteLine("####");
    }
    Console.ReadKey();

我的处理器

英特尔® 酷睿™ i7-620M 处理器(4M 高速缓存,2.66 GHz)

【讨论】:

  • 您的代码在非并行情况下多了一个零。 (如果一个人不费心去定义一个常量或变量来保存一个将被重复使用的“神奇”数字,这是一个可能犯的错误的典型例子!-在这种情况下,循环迭代的次数。)
猜你喜欢
  • 2013-07-10
  • 2019-07-20
  • 1970-01-01
  • 1970-01-01
  • 2012-11-08
  • 1970-01-01
  • 2012-07-19
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多