使用Interlocked.CompareExchange()的Parallel.For():较差的性能和与串行版本略有不同的结果

时间:2016-03-30 11:53:23

标签: c# parallel-processing mean interlocked

我尝试使用Parallel.For()计算列表的平均值。我决定反对它,因为它比简单的串行版本慢大约四倍。然而,我很感兴趣的是,它并没有产生与序列结果完全相同的结果,我认为了解原因是有益的。

我的代码是:

public static double Mean(this IList<double> list)
{
        double sum = 0.0;


        Parallel.For(0, list.Count, i => {
                        double initialSum;
                        double incrementedSum;
                        SpinWait spinWait = new SpinWait();

                        // Try incrementing the sum until the loop finds the initial sum unchanged so that it can safely replace it with the incremented one.
                        while (true) {
                            initialSum = sum;
                            incrementedSum = initialSum + list[i];
                            if (initialSum == Interlocked.CompareExchange(ref sum, incrementedSum, initialSum)) break;
                            spinWait.SpinOnce();
                        }
                     });

        return sum / list.Count;
    }

当我在2000000点的随机序列上运行代码时,我得到的结果与序列均值的最后两位数字不同。

我搜索了stackoverflow并找到了这个:VB.NET running sum in nested loop inside Parallel.for Synclock loses information。然而,我的情况与那里描述的情况不同。有一个线程局部变量temp是导致不准确的原因,但我根据教科书Interlocked.CompareExchange()模式使用了一个更新(我希望)的总和。问题当然是因为表现不佳(这令我感到惊讶,但我知道开销),但我很好奇是否有从这个案例中学到的东西。

您的意见表示赞赏。

2 个答案:

答案 0 :(得分:6)

使用 double 是潜在的问题,您可以通过使用 long 来更好地了解同步不是原因。你得到的结果实际上是正确的,但从来没有让程序员高兴。

您发现浮点数学是交际的但是not associative。或换句话说,a + b == b + aa + b + c != a + c + b。在您的代码中隐含添加数字的顺序非常随机。

This C++ question也谈论它。

答案 1 :(得分:1)

准确性问题在其他答案中得到了很好的解决,所以我在此不再重复,其他说永远不要相信浮点值的低位。相反,我会尝试解释你所看到的性能以及如何避免它。

由于您没有显示您的顺序代码,我将假设绝对最简单的情况:

double sum = list.Sum();

这是一个非常简单的操作,应该尽可能快地运行在一个CPU核心上。使用非常大的列表似乎应该可以利用多个核心来对列表求和。事实证明,你可以:

double sum = list.AsParallel().Sum();

在我的笔记本电脑上运行了一些(i3具有2个核心/ 4个逻辑触发器),在多次运行中产生了大约2.6倍的速度,相对于200万个随机数(相同的列表,多次运行)。

然而,您的代码比上面的简单案例慢得多。不是简单地将列表分成独立求和的块然后对结果求和,而是引入各种阻塞和等待,以便让所有线程更新一个运行总和。

那些额外的等待,支持它们的更复杂的代码,创建对象以及为垃圾收集器添加更多工作都会导致更慢的结果。你不仅在列表中的每个项目上浪费了大量的时间,而且实际上是强迫程序执行顺序操作,等待其他线程单独留下sum变量足够长的时间给你更新它。

假设您实际执行的操作比简单Sum()可以处理的操作更复杂,您可能会发现Aggregate()方法比Parallel.For对您更有用。

Aggregate扩展有几个重载,包括一个实际上是Map Pattern实现的重载,与MapReduce工作的bigdata系统的相似之处。文档为here

此版本的Aggregate使用累加器种子(每个线程的起始值)和三个函数:

    为序列中的每个项调用
  1. updateAccumulatorFunc并返回更新的累加器值

  2. combineAccumulatorsFunc用于组合并行枚举中每个分区(线程)的累加器

  3. resultSelector从累计结果中选择最终输出值。

  4. 使用此方法的并行总和如下所示:

    double sum = list.AsParallel().Aggregate(
        // seed value for accumulators
        (double)0, 
        // add val to accumulator
        (acc, val) => acc + val,
        // add accumulators
        (acc1, acc2) => acc1 + acc2,
        // just return the final accumulator
        acc => acc
    );
    

    对于工作正常的简单聚合。对于使用非平凡累加器的更复杂的聚合,有一个variant接受一个为初始状态创建累加器的函数。这在例如Average实现中很有用:

    public class avg_acc
    {
        public int count;
        public double sum;
    }
    
    public double ParallelAverage(IEnumerable<double> list)
    {
        double avg = list.AsParallel().Aggregate(
            // accumulator factory method, called once per thread:
            () => new avg_acc { count = 0, sum = 0 },
            // update count and sum
            (acc, val) => { acc.count++; acc.sum += val; return acc; },
            // combine accumulators
            (ac1, ac2) => new avg_acc { count = ac1.count + ac2.count, sum = ac1.sum + ac2.sum },
            // calculate average
            acc => acc.sum / acc.count
        );
        return avg;
    }
    

    虽然没有标准Average扩展快(比并行快〜1.5倍,比并行快1.6倍),但这显示了如何在不必锁定输出或等待其他情况下并行执行相当复杂的操作线程停止弄乱它们,以及如何使用复杂的累加器来保存中间结果。