【问题标题】:Why is the sum of reciprocals using a for-loop ~400x faster than streams?为什么使用 for 循环的倒数之和比流快 400 倍?
【发布时间】:2019-06-07 09:40:00
【问题描述】:

此代码对计算double[] 元素倒数和的 3 种不同方法进行基准测试。

  1. for-loop
  2. Java 8 流
  3. colt 数学库

使用简单 for 循环的计算比使用流的计算快 400 倍的原因是什么? (或者在基准测试代码中有什么需要改进的地方?或者使用流计算的更快的方法?)

代码:

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import cern.colt.list.DoubleArrayList;
import cern.jet.stat.Descriptive;
import org.openjdk.jmh.annotations.*;

@State(Scope.Thread)
public class MyBenchmark {

    public static double[] array;

    static {
        int num_of_elements = 100;
        array = new double[num_of_elements];
        for (int i = 0; i < num_of_elements; i++) {
            array[i] = i+1;
        }
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public void testInversionSumForLoop(){
        double result = 0;
        for (int i = 0; i < array.length; i++) {
            result += 1.0/array[i];
        }
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public void testInversionSumUsingStreams(){
        double result = 0;
        result = Arrays.stream(array).map(d -> 1/d).sum();
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public void testInversionSumUsingCernColt(){
        double result = Descriptive.sumOfInversions(new DoubleArrayList(array), 0, array.length-1);
    }
}

结果:

/**
 * Results
 * Benchmark                                  Mode  Cnt    Score    Error  Units
 * MyBenchmark.testInversionSumForLoop        avgt  200    1.647 ±  0.155  ns/op
 * MyBenchmark.testInversionSumUsingCernColt  avgt  200  603.254 ± 22.199  ns/op
 * MyBenchmark.testInversionSumUsingStreams   avgt  200  645.895 ± 20.833  ns/o
 */

更新:这些结果表明 Blackhome.consume 或 return 是避免 jvm 优化所必需的。

/**
 * Updated results after adding Blackhole.consume
 * Benchmark                                  Mode  Cnt    Score    Error  Units
 * MyBenchmark.testInversionSumForLoop        avgt  200  525.498 ± 10.458  ns/op
 * MyBenchmark.testInversionSumUsingCernColt  avgt  200  517.930 ±  2.080  ns/op
 * MyBenchmark.testInversionSumUsingStreams   avgt  200  582.103 ±  3.261  ns/op
 */

oracle jdk 版本“1.8.0_181”,达尔文内核版本 17.7.0

【问题讨论】:

  • JMH 尚未使基准自动更正。此处不使用计算结果,在 loop 情况下,JIT 完全删除了计算。您需要通过调用Blackhole.consume 或在基准方法的末尾添加return result; 来“使用”结果。
  • 1.647ns 的时间太短,无法对 100 个数字求和,即每秒超过 6e+10 次操作。我认为 testInversionSumForLoop 正在被 JIT 编译器优化掉。
  • @apangin 你是对的,我错过了 Blackhole.consume 现在结果相似。

标签: java performance java-8 java-stream jmh


【解决方案1】:

在您的示例中,JVM 最有可能完全优化循环,因为在计算后永远不会读取 result 值。您应该使用Blackhole 来使用result,如下所示:

@State(Scope.Thread)
@Warmup(iterations = 10, time = 200, timeUnit = MILLISECONDS)
@Measurement(iterations = 20, time = 500, timeUnit = MILLISECONDS)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class MyBenchmark {

  static double[] array;

  static {
    int num_of_elements = 100;
    array = new double[num_of_elements];
    for (int i = 0; i < num_of_elements; i++) {
      array[i] = i + 1;
    }
  }

  double result = 0;

  @Benchmark
  public void baseline(Blackhole blackhole) {
    result = 1;
    result = result / 1.0;
    blackhole.consume(result);
  }

  @Benchmark
  public void testInversionSumForLoop(Blackhole blackhole) {
    for (int i = 0; i < array.length; i++) {
      result += 1.0 / array[i];
    }
    blackhole.consume(result);
  }

  @Benchmark
  public void testInversionSumUsingStreams(Blackhole blackhole) {
    result = Arrays.stream(array).map(d -> 1 / d).sum();
    blackhole.consume(result);
  }

}

这个新的基准测试显示出预期的 4 倍差异。循环受益于 JVM 中的a number of optimizations,并且不像流那样涉及创建新对象。

Benchmark                                 Mode  Cnt    Score   Error  Units
MyBenchmark.baseline                      avgt  100    2.437 ±  0.139  ns/op
MyBenchmark.testInversionSumForLoop       avgt  100  135.512 ± 13.080  ns/op
MyBenchmark.testInversionSumUsingStreams  avgt  100  506.479 ±  4.209  ns/o

我尝试添加一个基线来显示在我的机器上进行单次操作的成本是多少。基线ns/ops 类似于您的循环ns/ops,IMO 确认您的循环已优化。

我很想有人告诉我这个基准场景的良好基准。

我的环境:

openjdk version "11.0.1" 2018-10-16
OpenJDK Runtime Environment 18.9 (build 11.0.1+13)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.1+13, mixed mode)

Intel(R) Core(TM) i7-7700HQ CPU @ 2.80GHz
Linux 4.15.0-43-generic #46-Ubuntu SMP Thu Dec 6 14:45:28 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux

【讨论】:

  • 我的新结果非常接近(请参阅有问题的更新),我在这里看到你有 4 倍的差异可能是因为 jvm 版本。但正如其他人在 cmets 中所说,您对这个问题的根本原因也是正确的。
  • @Vipin 我用不同的基线重新运行,我仍然得到 4 倍。添加了我的 Java 版本和 CPU。
  • @KarolDowbecki 它不一定是Blackhole,你可以简单地返回结果,这样对我来说看起来更干净一些。此外,当您询问单个操作的基线结果是什么时,您的意思是该单个除法所花费的时间是多少?
  • 这个问题是this question 的后续问题,正如comment 中解释的那样,DoubleStream.sum() 使用Kahan summation algorithm,它提供了更高的精度,但也比一个普通的 for 循环。所以这不仅仅是循环与流。您可以将.sum()reduce(0, Double::sum) 进行比较...
  • @KarolDowbecki 您可以确定在您的基线操作中已经省略了除法。对于您的原始版本result += 1.0 / 1.0;,术语1.0 / 1.0 是编译时常量。对于您更改的变体result = 1; result = result / 1.0;,运行时优化器必须启动,但是,它不会被两个后续(冗余)分配混淆。因此,您将增量操作更改为 1 的简单赋值,这就是它变得更快的原因。真正的除法会慢得多。你应该试试result = (result + 1) / 1.0;
猜你喜欢
  • 2017-11-29
  • 2021-10-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-01-29
  • 2012-04-26
  • 2021-11-25
  • 1970-01-01
相关资源
最近更新 更多