当 x = 0 时,Java 的 Math.pow(x, 2) 性能不佳

Poor performance of Java's Math.pow(x, 2) when x = 0

背景

注意到我正在处理的 java 程序的执行速度比预期的慢,我决定修改我认为可能导致问题的代码区域 - 调用 Math.pow(x, 2) 来自 for 循环。与 相反,我创建的一个简单基准测试(代码在末尾)发现用 x*x 替换 Math.pow(x, 2) 实际上使循环加速了近 70 倍:

x*x: 5.139383ms
Math.pow(x, 2): 334.541166ms

请注意,我知道基准测试并不完美,当然应该对这些值持保留态度 - 基准测试的目的是获得一个大概的数字。

问题

虽然基准测试给出了有趣的结果,但它没有准确地对我的数据建模,因为我的数据主要由 0 组成。因此,更准确的测试是 运行 没有将 for 循环标记为可选的基准测试。根据 javadoc for Math.pow()

If the first argument is positive zero and the second argument is greater than zero, or the first argument is positive infinity and the second argument is less than zero, then the result is positive zero.

所以预计该基准测试会 运行 更快,对吧!?然而实际上,这又要慢得多:

x*x: 4.3490535ms
Math.pow(x, 2): 3082.1720006ms

当然,人们可能认为 math.pow() 代码 运行 比简单的 x*x 代码慢一点,因为它需要为一般情况工作,但是慢 700 倍?到底是怎么回事!?为什么 0 的情况比 Math.random() 的情况慢这么多?

更新: 根据@Stephen C 的建议更新了代码和时间。然而,这并没有什么区别。

用于基准测试的代码

请注意,重新排序两个测试的差异可以忽略不计。

public class Test {
    public Test(){
        int iterations = 100;
        double[] exampleData = new double[5000000];
        double[] test1Results = new double[iterations];
        double[] test2Results = new double[iterations];

        //Optional
        for (int i = 0; i < exampleData.length; i++) {
            exampleData[i] = Math.random();
        }

        for (int i = 0; i < iterations; i++) {
            test1Results[i] = test1(exampleData);
            test2Results[i] = test2(exampleData);
        }
        System.out.println("x*x: " + calculateAverage(test1Results) / 1000000 + "ms");
        System.out.println("Math.pow(x, 2): " + calculateAverage(test2Results) / 1000000 + "ms");
    }

    private long test1(double[] exampleData){
        double total = 0;
        long startTime;
        long endTime;
        startTime = System.nanoTime();
        for (int j = 0; j < exampleData.length; j++) {
            total += exampleData[j] * exampleData[j];
        }
        endTime = System.nanoTime();
        System.out.println(total);
        return endTime - startTime;
    }

    private long test2(double[] exampleData){
        double total = 0;
        long startTime;
        long endTime;
        startTime = System.nanoTime();
        for (int j = 0; j < exampleData.length; j++) {
            total += Math.pow(exampleData[j], 2);
        }
        endTime = System.nanoTime();
        System.out.println(total);
        return endTime - startTime;
    }

    private double calculateAverage(double[] array){
        double total = 0;
        for (int i = 0; i < array.length; i++) {
            total += array[i];
        }
        return total/array.length;
    }

    public static void main(String[] args){
        new Test();
    }
}

使用 Math class 中的任何方法都需要更长的时间,然后只使用一个简单的运算符(如果可能)。这是因为程序要将Math.method()的输入传递给Mathclass,Mathclass再进行运算,然后Math class 将 return 从 Math. 方法 () 计算出的值。所有这些都比仅使用 *、/、+ 或 - 这样的基本运算符需要更多的处理能力。

可能与 JDK 7 中的回归有关:http://bugs.java.com/bugdatabase/view_bug.do?bug_id=8029302

来自错误报告:

There is a Math.pow performance regression where power of 2 inputs run slower than other values.

因此,我已经替换了对 Math.pow() 的所有调用:

public static double pow(final double a, final double b) {
    if (b == 2.0) {
        return (a * a);
    } else {
        return Math.pow(a, b);
    }
}

根据错误报告,它已在 JDK 8 中修复,符合上面@BartKiers 的评论。

虽然@whiskeyspider 发现的错误报告是相关的,但我认为这不是完整的解释。根据错误报告,回归给出了大约 4 倍的减速。但在这里我们看到大约 1000 倍的减速。差异太大,无法忽略。

我认为我们在这里看到的部分问题是基准测试本身。看看这个:

        for (int j = 0; j < exampleData.length; j++) {
            double output = exampleData[j] * exampleData[j];
        }

主体中的语句分配给未使用的局部变量。它可以被 JIT 编译器优化掉。 (事实上​​ ,整个循环可以被优化掉......虽然根据经验,这似乎并没有发生在这里。)

对比:

        for (int j = 0; j < exampleData.length; j++) {
            double output = Math.pow(exampleData[j], 2);
        }

除非 JIT 编译器知道 pow 没有副作用,否则无法优化。由于 pow 的实现是在本机代码中进行的,因此必须以 "intrinsic" 方法的制作方式来传授这些知识......在幕后。从错误报告分析来看,不同 Java 版本/发行版之间 "intinsification" 的变化是回归的根本原因。我怀疑 OP 基准测试中的缺陷是 放大 效果。

修复是为了确保使用 output 值,以便 JIT 编译器无法优化它;例如

        double blackhole = 0;  // declared at start ...
        ...
        for (int j = 0; j < exampleData.length; j++) {
            blackhole += exampleData[j] * exampleData[j];
        }
        ...
        for (int j = 0; j < exampleData.length; j++) {
            blackhole += Math.pow(exampleData[j], 2);
        }

参考: ...尤其是规则#6。

虽然这是一个糟糕的基准,但幸运的是它揭示了一个有趣的效果。

这些数字表明您显然 运行 是 "Client" VM 下的基准测试。它没有非常强大的 JIT 编译器(称为 C1 编译器),缺乏很多优化。难怪效果不如预期。

  • 客户端 VM 不够智能,无法消除 Math.pow 调用,即使它没有副作用。
  • 此外,它没有针对 Y=2X=0 的专用快速路径。至少,它在 Java 9 之前没有。这最近已在 JDK-8063086 and then further optimized in JDK-8132207.
  • 中修复

但有趣的是Math.pow确实比使用C1编译器的X=0慢!

但是为什么呢?由于实现细节。

x86 架构不提供计算 X^Y 的硬件指令。但是还有其他有用的说明:

  • FYL2X 计算 Y * log₂X
  • F2XM1 计算 2^X - 1

因此,X^Y = 2^(Y * log₂X)。由于 log₂X 仅针对 X > 0 定义,因此 FYL2XX=0 和 returns -Inf 的异常结束。因此,X=0 是在一条缓慢的异常路径中处理的,而不是在专门的快速路径中处理的。

那怎么办?

首先,停止使用 Client VM,尤其是在您关心性能的情况下。切换到最新的 JDK 64 位风格的 8,您将获得最佳的 C2 优化 JIT 编译器。当然,它可以很好地处理 Math.pow(x, 2) 等。然后写一个 correct benchmark using a proper tool like JMH.