何时应该优先选择使用流而不是传统循环以获得最佳性能?流是否利用分支预测?

47

我刚刚读到了分支预测,并想尝试一下如何在Java 8流中使用它。

然而,使用流的性能始终比传统循环差。

int totalSize = 32768;
int filterValue = 1280;
int[] array = new int[totalSize];
Random rnd = new Random(0);
int loopCount = 10000;

for (int i = 0; i < totalSize; i++) {
    // array[i] = rnd.nextInt() % 2560; // Unsorted Data
    array[i] = i; // Sorted Data
}

long start = System.nanoTime();
long sum = 0;
for (int j = 0; j < loopCount; j++) {
    for (int c = 0; c < totalSize; ++c) {
        sum += array[c] >= filterValue ? array[c] : 0;
    }
}
long total = System.nanoTime() - start;
System.out.printf("Conditional Operator Time : %d ns, (%f sec) %n", total, total / Math.pow(10, 9));

start = System.nanoTime();
sum = 0;
for (int j = 0; j < loopCount; j++) {
    for (int c = 0; c < totalSize; ++c) {
        if (array[c] >= filterValue) {
            sum += array[c];
        }
    }
}
total = System.nanoTime() - start;
System.out.printf("Branch Statement Time : %d ns, (%f sec) %n", total, total / Math.pow(10, 9));

start = System.nanoTime();
sum = 0;
for (int j = 0; j < loopCount; j++) {
    sum += Arrays.stream(array).filter(value -> value >= filterValue).sum();
}
total = System.nanoTime() - start;
System.out.printf("Streams Time : %d ns, (%f sec) %n", total, total / Math.pow(10, 9));

start = System.nanoTime();
sum = 0;
for (int j = 0; j < loopCount; j++) {
    sum += Arrays.stream(array).parallel().filter(value -> value >= filterValue).sum();
}
total = System.nanoTime() - start;
System.out.printf("Parallel Streams Time : %d ns, (%f sec) %n", total, total / Math.pow(10, 9));

输出:

  1. 对于已排序的数组:

Conditional Operator Time : 294062652 ns, (0.294063 sec) 
Branch Statement Time : 272992442 ns, (0.272992 sec) 
Streams Time : 806579913 ns, (0.806580 sec) 
Parallel Streams Time : 2316150852 ns, (2.316151 sec) 
  • 对于未排序的数组:

    Conditional Operator Time : 367304250 ns, (0.367304 sec) 
    Branch Statement Time : 906073542 ns, (0.906074 sec) 
    Streams Time : 1268648265 ns, (1.268648 sec) 
    Parallel Streams Time : 2420482313 ns, (2.420482 sec) 
    
  • 我尝试使用List相同的代码:
    list.stream()代替Arrays.stream(array)
    list.get(c)代替array[c]

    输出:

    1. 对于排序后的列表:

    Conditional Operator Time : 860514446 ns, (0.860514 sec) 
    Branch Statement Time : 663458668 ns, (0.663459 sec) 
    Streams Time : 2085657481 ns, (2.085657 sec) 
    Parallel Streams Time : 5026680680 ns, (5.026681 sec) 
    
  • 对于未排序列表

  • Conditional Operator Time : 704120976 ns, (0.704121 sec) 
    Branch Statement Time : 1327838248 ns, (1.327838 sec) 
    Streams Time : 1857880764 ns, (1.857881 sec) 
    Parallel Streams Time : 2504468688 ns, (2.504469 sec) 
    

    我参考了几篇博客(此处)(此处),这些都指出了关于流的性能问题。

    1. 我同意使用流进行编程在某些情况下更好以及更容易,但当我们牺牲性能时,为什么我们需要使用它们?我错过了什么吗?
    2. 只有在函数定义需要很长时间时,导致循环性能可以忽略不计的情况下,才会发现流与循环具有相同的性能。哪种情况下会发生这种情况?
    3. 在所有情况下,我都没有看到流利用分支预测的优势(我尝试了排序和无序流,但都没有用。相比普通流,它产生了超过两倍的性能影响)?

    13
    应用程序中的大多数性能问题都是由于像这样的过早优化所导致的。 - Timothy Truckle
    1
    @TimothyTruckle:我很好奇。你能举个例子吗? - Leif
    6
    @Leif 好的,也许不是最性能问题,但是程序可维护性和可演变性存在问题:http://ubiquity.acm.org/article.cfm?id=1513451 - http://wiki.c2.com/?PrematureOptimization - http://www.flounder.com/optimization.htm - Timothy Truckle
    13
    你认为性能应该是首要考虑因素的假设是根本错误的。编写最清晰表达你意图的代码。在绝大多数情况下,流处理已足够快速。 - Brian Goetz
    1
    @Leif 人们完全误解性能瓶颈所在并不罕见。 - Voo
    显示剩余3条评论
    5个回答

    45

    我同意使用流编程在某些场景下更加简便,但是当我们失去性能时,我们为什么需要使用它们呢?

    性能很少成为问题。会有10%的情况下需要将流重写为循环以获得所需的性能。

    我是否缺漏了什么?

    使用parallelStream()比使用流更容易,并且可能更有效率,因为编写高效并发代码很难。

    在哪种情况下流的性能与循环相等?只有在定义的函数需要很长时间,导致循环性能可以忽略不计的情况下吗?

    您的基准测试存在缺陷,因为代码在启动时尚未编译。我建议像JMH一样在循环中执行整个测试,或者使用JMH。

    在任何情况下,我都看不到流利用分支预测的优势

    分支预测是CPU的特性,不是JVM或流的特性。


    5
    当你发现并行处理使操作速度下降了两倍时,你可能会认为数组太小,无法对性能进行有用的语句描述。此外,你应该知道,虽然条件表达式看起来不同,即比“if”语句更紧凑,但代码上没有技术差异。两者都包含分支,因此,如果条件表达式显然更快,这是基准测试设置出现缺陷的迹象,其他副作用似乎主导了性能。 - Holger
    1
    @Holger 我不认为这是正确的。条件语句实际上是以系统不同的方式解释的(至少根据我所读到的。它有一个称为cmovl的单独指令来执行此操作),因此速度相对较快。来源:https://dev59.com/iWgu5IYBdhLWcg3wkH6P#11237235 即使基准测试存在缺陷,输出差异也不应该如此之大。 - Kishore Bandi
    6
    您在问题中标记了[java],并且发布的是Java源代码。在Java中,不存在cmovl这样的东西。您的源代码首先会被编译成Java字节码,如果两个不同的结构产生相同的字节码,则它们可能会或可能不会被优化为任何本机代码,但它们不能以任何方式展示基本差异。JVM根本不知道您在源代码中使用了“if”语句还是条件表达式。它所看到的都是字节码中的分支。 - Holger
    3
    区别在于,一种情况下,如果条件不满足,则添加0,而另一种情况下,在这种情况下根本不添加值。因此,存在轻微差异,可能会引导JVM的优化决策朝不同的方向发展,但结果并不像您认为的那样可预测。但在任一情况下,字节码都不是无分支的。顺便说一句,您可以类似地将.filter(value-> value> = filterValue)替换为.map(value-> value> = filterValue?value:0),以查看在特定运行时环境中是否有益处。 - Holger
    3
    顺便说一下,您的排序数组中有1280个值低于阈值,而32768-1280个值高于阈值。这产生了一个完全不同的分支概率,与随机数据几乎均匀分布在两侧的情况不同(几乎,您应该使用rnd.nextInt(bound)而不是rnd.nextInt()%bound)。如果您想比较处理排序或未排序的数组,则应在运行之间对数组进行简单的排序或洗牌,而不改变数字。 - Holger
    显示剩余3条评论

    31

    Java 是一种高级语言,可以免去程序员考虑低级性能优化的麻烦。

    除非你已经证明这是你真实应用程序中的问题,否则不要因为性能而选择某种特定方法。

    你的测量结果表明流有一些负面影响,但差异在可观察范围之内,因此这不是一个问题。 此外,这个测试是一个“合成”的情况,代码在重负载的生产环境下可能会完全不同。 此外,由JIT从您的Java(byte)代码创建的机器码可能会在未来Java(维护)版本中更改,并使您的测量过时。

    总之:选择最能表达你(程序员)意图的语法或方法。在整个程序中保持相同的方法或语法,除非你有一个很好的理由去改变。


    1
    更简洁地说:过早的优化会导致项目失败。 - Delioth
    3
    我喜欢人们如何藏在这背后 ;) - Eugene
    @TimothyTruckle 同意。我正在查看一些低级细节,这并不是很重要,如果需要的话,我可以随时切换回循环。解释得很好 :) - Kishore Bandi

    18

    虽然已经讲得很清楚了,但我想通过JMH向您展示代码的样子。

    @Fork(3)
    @BenchmarkMode(Mode.AverageTime)
    @Measurement(iterations = 10, timeUnit = TimeUnit.NANOSECONDS)
    @State(Scope.Benchmark)
    @Threads(1)
    @Warmup(iterations = 5, timeUnit = TimeUnit.NANOSECONDS)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public class MyBenchmark {
    
      private final int totalSize = 32_768;
      private final int filterValue = 1_280;
      private final int loopCount = 10_000;
      // private Random rnd;
    
      private int[] array;
    
      @Setup
      public void setup() {
        array = IntStream.range(0, totalSize).toArray();
    
        // rnd = new Random(0);
        // array = rnd.ints(totalSize).map(i -> i % 2560).toArray();
      }
    
      @Benchmark
      public long conditionalOperatorTime() {
        long sum = 0;
        for (int j = 0; j < loopCount; j++) {
          for (int c = 0; c < totalSize; ++c) {
            sum += array[c] >= filterValue ? array[c] : 0;
          }
        }
        return sum;
      }
    
      @Benchmark
      public long branchStatementTime() {
        long sum = 0;
        for (int j = 0; j < loopCount; j++) {
          for (int c = 0; c < totalSize; ++c) {
            if (array[c] >= filterValue) {
              sum += array[c];
            }
          }
        }
        return sum;
      }
    
      @Benchmark
      public long streamsTime() {
        long sum = 0;
        for (int j = 0; j < loopCount; j++) {
          sum += IntStream.of(array).filter(value -> value >= filterValue).sum();
        }
        return sum;
      }
    
      @Benchmark
      public long parallelStreamsTime() {
        long sum = 0;
        for (int j = 0; j < loopCount; j++) {
          sum += IntStream.of(array).parallel().filter(value -> value >= filterValue).sum();
        }
        return sum;
      }
    }
    

    一个已排序数组的结果:

    Benchmark                            Mode  Cnt           Score           Error  Units
    MyBenchmark.branchStatementTime      avgt   30   119833793,881 ±   1345228,723  ns/op
    MyBenchmark.conditionalOperatorTime  avgt   30   118146194,368 ±   1748693,962  ns/op
    MyBenchmark.parallelStreamsTime      avgt   30   499436897,422 ±   7344346,333  ns/op
    MyBenchmark.streamsTime              avgt   30  1126768177,407 ± 198712604,716  ns/op
    

    未排序数据的结果:

    Benchmark                            Mode  Cnt           Score           Error  Units
    MyBenchmark.branchStatementTime      avgt   30   534932594,083 ±   3622551,550  ns/op
    MyBenchmark.conditionalOperatorTime  avgt   30   530641033,317 ±   8849037,036  ns/op
    MyBenchmark.parallelStreamsTime      avgt   30   489184423,406 ±   5716369,132  ns/op
    MyBenchmark.streamsTime              avgt   30  1232020250,900 ± 185772971,366  ns/op
    

    我只能说JVM有很多优化的可能性,也许分支预测也涉及其中。现在由你来解释基准测试结果。


    3
    你的测试有些缺陷:4个测试方法,3个分支;预热只进行了纳秒级别的时间(请至少升级为毫秒级别),结果也是以纳秒为单位。此外误差相当大,你 可以尝试 使用 -Xmx -Xms 4G 运行测试,以确保 GC 调用不会干扰你的结果。 - Eugene
    2
    那个数组的生成应该真正成为一个设置方法。 - Eugene
    4
    @Eugene,你说得对,这个基准测试在垃圾回收、最小和最大堆大小以及设置步骤方面有些缺陷,但在分叉、时间单位和预热方面是没有问题的。由于我没有指定任何“时间”,所以预热时间限制为1秒。此外,我认为你应该阅读一下“@Fork”,因为每个方法都会被分叉3次,而不是所有方法一起进行。我真的不关心5-10%的误差,因为整个基准测试应该展示一个趋势,而不是完美的基准测试。 - Flown

    10

    我来谈谈我的观点。

    我刚刚学习了关于分支预测的知识,想尝试一下在Java 8 Streams中如何使用它。

    分支预测是CPU的一个特性,与JVM无关。它需要保持CPU流水线满并准备好做一些事情。测量或“预测”分支预测非常困难(除非您确切地知道CPU将要做什么)。这将取决于CPU现在承载的负载(可能远远超过您的程序)。

    但是,在流处理中,性能总是比传统循环差。

    这个说法和前面的说法没有关系。是的,在像你的简单示例这样的案例中,流会慢一些,慢到多达30%,这是可以接受的。 您可以通过像其他人建议的JMH来衡量特定情况下它们的速度,但这只证明该情况下产生了什么负载,仅适用于该情况。

    同时,您可能正在使用Spring / Hibernate /服务等等,它们在毫秒级别执行操作,而您的流在纳秒级别执行操作,您担心性能问题吗?您怀疑代码中最快的部分的速度?当然,这只是理论上的事情。

    至于您最后提到的尝试使用排序和未排序的数组得到了糟糕的结果。这绝对不是分支预测或否定的指标-除非您可以查看实际的CPU流水线发生预测的位置以及是否发生预测-但您没有这样做。


    是的。我在这里比较了两个不同的项目。你说得对。与价值流添加的价值相比,不看这些细节也没关系。将其与我们使用的框架进行比较是正确的,尽管它们在毫秒级别工作,但这只是为了让生活更轻松。 - Kishore Bandi

    8

    如何让我的Java程序运行更快?

    简而言之,Java程序可以通过以下方式加速:

    1. 多线程
    2. JIT

    流与Java程序加速有关吗?

    是的!

    1. 请注意Collection.parallelStream()Stream.parallel()方法以进行多线程处理
    2. 一个循环可以足够长,使JIT跳过。Lambda通常很小,可以由JIT编译 => 可能会提高性能

    在什么情况下流比for循环更快?

    让我们看一下jdk/src/share/vm/runtime/globals.hpp

    develop(intx, HugeMethodLimit,  8000,
            "Don't compile methods larger than this if "
            "+DontCompileHugeMethods")
    

    如果您的循环足够长,它将不会被JIT编译,并且运行速度会变慢。 如果您将这样的循环重写为流,您可能会使用mapfilterflatMap方法来将代码分解成小块,每个小块都足够小以适应限制。 当然,编写巨大的方法除了JIT编译之外还有其他缺点。 例如,如果您有大量生成的代码,可以考虑这种情况。
    关于分支预测呢?
    当然,与其他代码一样,流也利用了分支预测。 但是,据我所知,分支预测并不是显式用于使流更快的技术。
    那么,什么时候将循环重写为流以获得最佳性能呢?
    永远不要这样做。
    过早地优化是万恶之源 ©Donald Knuth 尝试优化算法。流是函数式编程的接口,而不是加速循环的工具。

    10
    每当有人提到这句话时,我就想重复一下这句话原本的上下文:“我们应该忘记小效率问题,大约有97%的时间:过早优化是一切邪恶的根源。但我们不应错过关键的3%机会。好的程序员不会被这种论据所安慰,他将明智地仔细研究关键代码;但前提是必须先确定出这些代码。”(加粗为本人添加)。除此之外和“永远不要”之外,对最后一句话也点赞。 - Marco13
    1
    个人而言,我觉得流和lambda表达式通常比传统的迭代习惯用法在意图和逻辑上不够清晰。由于每个人都时常引用Knuth,他也是编程中首先考虑清晰度的原始支持者之一,正如你所说,在那些被证明需要优化的情况下进行优化。因此,除非lambda表达式确实使事情更清晰或解决了特定问题,否则我会避免使用它们。不要误解,我很高兴在许多情况下使用它们。通常,我会将复杂的lambda表达式包装在一个具有说明性名称和javadoc的方法中。 - Charlie Reitzel

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接