Java-8的DoubleStream.sum()方法在并行运行时是否稳定?

我对Java 8中的以下结构感到好奇:

double[] doubles = //...
double sum = DoubleStream.of(doubles).parallel().sum();

切入追逐:

  • 的值是否始终相同,例如,在不同的计算机上运行时?sum

更多背景...

浮点算术是有损的,并且(与实值算术不同)不是关联的。因此,除非注意如何划分和重新组装工作,否则可能会导致非确定性结果。

我很高兴地发现,这种方法在引擎盖下使用了Kahan Summation。这大大减少了误差,但仍然不能给出精确的*结果。sum()

在我的测试中,重复调用似乎每次都返回相同的结果,但我想知道我们可以安全地假设它有多稳定。例如:

  1. 在所有情况下都稳定?
  2. 在具有相同内核数的计算机之间是否稳定?
  3. 仅在给定计算机上稳定?
  4. 完全不能指望它稳定吗?

我很高兴在每台计算机上假设相同的JVM版本。

这是我做的一个测试:

public static void main(String[] args) {
    Random random = new Random(42L);
    for (int j = 1; j < 20; j++) {

        // Stream increases in size and the magnitude of the values at each iteration.
        double[] doubles = generate(random, j*100, j);

        // Like a simple for loop
        double sum1 = DoubleStream.of(doubles).reduce(0, Double::sum); 

        double sum2 = DoubleStream.of(doubles).sum();
        double sum3 = DoubleStream.of(doubles).parallel().sum();

        System.out.println(printStats(doubles, sum1, sum2, sum3));

        // Is the parallel computation stable?
        for (int i = 0; i < 1000; i++) {
            double sum4 = DoubleStream.of(doubles).parallel().sum();
            assert sum4 == sum3;
        }
        Arrays.sort(doubles);
    }
}

/**
 * @param spread When odd, returns a mix of +ve and -ve numbers.
 *               When even, returns only +ve numbers.
 *               Higher values cause a wider spread of magnitudes in the returned values.
 *               Must not be negative.  
 */
private static double[] generate(Random random, int count, int spread) {
    return random.doubles(count).map(x -> Math.pow(4*x-2, spread)).toArray();
}

private static String printStats(double[] doubles, double sum1, double sum2, double sum3) {
    DoubleSummaryStatistics stats = DoubleStream.of(doubles).summaryStatistics();

    return String.format("-----%nMin: %g, Max: %g, Average: %g%n"
            + "Serial difference:   %g%n"
            + "Parallel difference: %g",
            stats.getMin(), stats.getMax(), stats.getAverage(), sum2-sum1, sum3-sum1);
}

当我运行这个时,前几个迭代是:

-----
Min: -1.89188, Max: 1.90414, Average: 0.0541140
Serial difference:   -2.66454e-15
Parallel difference: -2.66454e-15
-----
Min: 0.000113827, Max: 3.99513, Average: 1.17402
Serial difference:   1.70530e-13
Parallel difference: 1.42109e-13
-----
Min: -7.95673, Max: 7.87757, Average: 0.0658356
Serial difference:   0.00000
Parallel difference: -7.10543e-15
-----
Min: 2.53794e-09, Max: 15.8122, Average: 2.96504
Serial difference:   -4.54747e-13
Parallel difference: -6.82121e-13

请注意,虽然 &可以假定比 - 它们可能彼此不一样!sum2sum3sum1

我播种了42,所以如果有人得到一个不同的结果,那将立即证明一些东西。:-)Random


* 对于好奇的人...

  • 以下是一些(python)算法,可提供精确的结果
  • 这里给出了具有我听说过的具有最佳性能特征的精确和算法(需要ACM订阅或费用)。每个输入需要5次浮点运算,但编写(在C中)以利用指令级并行性,并且仅比朴素求和慢2-3倍运行,这对于精确的结果来说听起来相当不错。(c.f. Kahan 求和,每次输入 4 次浮点)

答案 1

我认为DoubleStream::sum的文档对这个问题非常清楚:

[..]浮点和的值既是输入值又是加法运算顺序的函数。有意不定义此方法的加法运算顺序,以便实现灵活性,从而提高计算结果的速度和准确性。[..]

这意味着,您不应依赖稳定性,尤其是并行流。


另一方面,每次运行都会看到相同的结果也就不足为奇了。从概念上讲sum 方法可以按如下方式实现:

double sum(double[] array, int startInclusive, int endExclusive) {
    int distance = endExclusive - startInclusive;
    if (distance < 1000) {
        double total = 0;
        for (int i = startInclusive; i < endExclusive; ++i) {
            total += array[i];
        }
        return total;
    } else {
        int middle = startInclusive + distance / 2;
        var left = async sum(array, startInclusive, middle);
        var right = async sum(array, middle, endExclusive);
        return await left + await right;
    }
}

尽管异步执行的任务的调度是不确定的,但该方法始终返回相同的结果,因为加法运算的顺序是相同的(即括号不会重新排列)。

但是,更复杂的实现可能会考虑当前工作负荷以及子任务的预期执行时间(与异步操作的成本相比)。如果发生这种情况,结果可能会有所不同。


答案 2

我确实从您为并行求和发布的内容中获得了不同的结果,因此我可以确认它并非在所有情况下都稳定。串行求和在测试和我的测试中的行为似乎相同。我的JVM可能与你的不同,我的核心数量可能与你不同。无论如何,以下是我为发布结果的相同迭代获得的结果。

Oracle Corporation
Java HotSpot(TM) 64-Bit Server VM
25.51-b03
-----
Min: -1.89188, Max: 1.90414, Average: 0.0541140
Serial difference:   -2.66454e-15
Parallel difference: -2.66454e-15
-----
Min: 0.000113827, Max: 3.99513, Average: 1.17402
Serial difference:   1.70530e-13
Parallel difference: 1.70530e-13
-----
Min: -7.95673, Max: 7.87757, Average: 0.0658356
Serial difference:   0.00000
Parallel difference: 3.55271e-15
-----
Min: 2.53794e-09, Max: 15.8122, Average: 2.96504
Serial difference:   -4.54747e-13
Parallel difference: -4.54747e-13