Arrays.stream(array_name).sum()比迭代方法慢吗?

时间:2015-01-13 15:46:09

标签: java performance algorithm java-8

我正在使用Java 8编写一个leetcode问题:https://oj.leetcode.com/problems/gas-station/

当我使用Arrays.stream(integer_array).sum()来计算总和时,我的解决方案得到了TLE,同时使用迭代来接受相同的解决方案来计算数组中元素的总和。这个问题的最佳时间复杂度是O(n),当我使用Java 8中的流API时,我很惊讶地获得了TLE。我已经在O(n)中实现了解决方案。

import java.util.Arrays;

public class GasStation {
    public int canCompleteCircuit(int[] gas, int[] cost) {
        int start = 0, i = 0, runningCost = 0, totalGas = 0, totalCost = 0; 
        totalGas = Arrays.stream(gas).sum();
        totalCost = Arrays.stream(cost).sum();

        // for (int item : gas) totalGas += item;
        // for (int item : cost) totalCost += item;

        if (totalGas < totalCost)
            return -1;

        while (start > i || (start == 0 && i < gas.length)) {
            runningCost += gas[i];
            if (runningCost >= cost[i]) {
                runningCost -= cost[i++];
            } else {
                runningCost -= gas[i];
                if (--start < 0)
                    start = gas.length - 1;
                runningCost += (gas[start] - cost[start]);
            }
        }
        return start;
    }

    public static void main(String[] args) {
        GasStation sol = new GasStation();
        int[] gas = new int[] { 10, 5, 7, 14, 9 };
        int[] cost = new int[] { 8, 5, 14, 3, 1 };
        System.out.println(sol.canCompleteCircuit(gas, cost));

        gas = new int[] { 10 };
        cost = new int[] { 8 };
        System.out.println(sol.canCompleteCircuit(gas, cost));
    }
}

解决方案获得接受时, 我评论以下两行:(使用流式传输计算总和)

totalGas = Arrays.stream(gas).sum();
totalCost = Arrays.stream(cost).sum();

并取消注释以下两行(使用迭代计算总和):

//for (int item : gas) totalGas += item;
//for (int item : cost) totalCost += item;

现在解决方案被接受了。为什么大型输入中的流API >比基元的迭代更慢?

5 个答案:

答案 0 :(得分:22)

处理这类问题的第一步是将代码带入受控环境。这意味着在您控制(并且可以调用)的JVM中运行它,并在JMH之类的良好基准测试中运行测试。分析,不要推测。

以下是我使用JMH对此进行分析的基准测试:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
public class ArraySum {
    static final long SEED = -897234L;

    @Param({"1000000"})
    int sz;

    int[] array;

    @Setup
    public void setup() {
        Random random = new Random(SEED);
        array = new int[sz];
        Arrays.setAll(array, i -> random.nextInt());
    }

    @Benchmark
    public int sumForLoop() {
        int sum = 0;
        for (int a : array)
            sum += a;
        return sum;
    }

    @Benchmark
    public int sumStream() {
        return Arrays.stream(array).sum();
    }
}

基本上,这会创建一个包含一百万个整数的数组,并将它们相加两次:一次使用for循环,一次使用流。运行基准测试会产生一堆输出(为了简洁和显着效果而省略),但总结结果如下:

Benchmark                 (sz)  Mode  Samples     Score  Score error  Units
ArraySum.sumForLoop    1000000  avgt        3   514.473      398.512  us/op
ArraySum.sumStream     1000000  avgt        3  7355.971     3170.697  us/op

哇!那个Java 8流的东西就是SUXX0R!它比for-loop慢14倍,不要使用!!! 1!

嗯,不。首先让我们回顾一下这些结果,然后仔细观察一下,看看我们是否可以弄清楚它们是在发生什么。

摘要显示了两种基准方法,其中包括&#34; sz&#34;一百万的参数。可以改变这个参数,但在这种情况下,它并没有变化。我也只运行了3次基准测试方法,正如您可以从&#34;样本&#34;中看到的那样。柱。 (也只有3次预热迭代,这里不可见。)每次操作得分为微秒,显然流代码比for循环代码要慢得多。但请注意分数误差:这是不同运行中的变化量。 JMH帮助打印出结果的标准偏差(此处未显示),但您可以很容易地看到分数误差是报告分数的重要部分。这降低了我们对得分的信心。

运行更多迭代应该会有所帮助。更多的热身迭代将让JIT在运行基准测试之前做更多工作并稳定下来,并且运行更多基准测试迭代将消除系统中其他位置的瞬态活动的任何错误。因此,让我们尝试10次预热迭代和10次基准迭代:

Benchmark                 (sz)  Mode  Samples     Score  Score error  Units
ArraySum.sumForLoop    1000000  avgt       10   504.803       34.010  us/op
ArraySum.sumStream     1000000  avgt       10  7128.942      178.688  us/op

性能总体上要快一些,测量误差也相当小,因此运行更多迭代产生了预期的效果。但是流代码仍然比for循环代码慢得多。发生了什么事?

通过查看流方法的各个时间可以获得大线索:

# Warmup Iteration   1: 570.490 us/op
# Warmup Iteration   2: 491.765 us/op
# Warmup Iteration   3: 756.951 us/op
# Warmup Iteration   4: 7033.500 us/op
# Warmup Iteration   5: 7350.080 us/op
# Warmup Iteration   6: 7425.829 us/op
# Warmup Iteration   7: 7029.441 us/op
# Warmup Iteration   8: 7208.584 us/op
# Warmup Iteration   9: 7104.160 us/op
# Warmup Iteration  10: 7372.298 us/op

发生什么事了?前几次迭代相当快,但随后的第四次和后续迭代(以及随后的所有基准迭代)突然变慢了。

我之前见过这个。这是在this questionthis answer其他地方的SO。我建议阅读那个答案;它解释了JVM在这种情况下的内联决策如何导致性能较差。

这里有一点背景:for循环编译成一个非常简单的增量和测试循环,并且可以通过循环剥离和展开等常用优化技术轻松处理。在这种情况下,流代码虽然不是很复杂,但实际上与for循环代码相比非常复杂;有一些设置,每个循环至少需要一个方法调用。因此,JIT优化,特别是其内联决策,对于使流代码快速运行至关重要。它可能会出错。

另一个背景点是整数求和是关于你可以想到在循环或流中做的最简单的操作。这将倾向于使流设置的固定开销看起来相对更昂贵。它也很简单,它可以触发内联策略中的病态。

另一个答案的建议是添加JVM选项-XX:MaxInlineLevel=12以增加可以内联的代码量。使用该选项重新运行基准测试得出:

Benchmark                 (sz)  Mode  Samples    Score  Score error  Units
ArraySum.sumForLoop    1000000  avgt       10  502.379       27.859  us/op
ArraySum.sumStream     1000000  avgt       10  498.572       24.195  us/op
啊,好多了。使用-XX:-TieredCompilation禁用分层编译也具有避免病态行为的效果。我还发现使循环计算更加昂贵,例如求和整数的平方 - 也就是说,加上一个乘法 - 也可以避免病态行为。

现在,您的问题是在leetcode环境的上下文中运行,该环境似乎在您无法控制的JVM中运行代码,因此您无法进行操作更改内联或编译选项。而且你可能不想让你的计算更复杂,以避免病理学。所以对于这种情况,你可能只是坚持好的旧循环。但是不要害怕使用流,即使是处理原始数组。除了一些狭隘的边缘情况之外,它可以表现得很好。

答案 1 :(得分:6)

正常的迭代方法几乎和任何事情一样快,但是流有各种各样的开销:即使它直接来自流,也可能会出现这种情况。涉及原始Spliterator并且正在生成许多其他对象。

一般来说,你应该期待&#34;正常的方法&#34; 通常比流更快,除非你使用并行化,你的数据非常大。

答案 2 :(得分:1)

sum()方法在语法上等同于return reduce(0, Integer::sum);在大型列表中,进行所有方法调用的开销将超过基本的手动for循环迭代。 for(int i : numbers)迭代的字节代码仅比副手for循环生成的字节代码稍微复杂一些。在并行友好的环境中,流操作可能更快(虽然可能不适用于原始方法),但除非我们知道环境是并行友好的(并且它可能不是因为leetcode本身可能设计为支持低级而不是抽象因为它是衡量效率而不是可读性。)

以三种方式中的任何一种方式完成的总和操作(Arrays.stream(int[]).sumfor (int i : ints){total+=i;}for(int i = 0; i < ints.length; i++){total+=i;}的效率应相对相似。我使用了以下测试类(总计一百每个百万个0到4096之间的百万个整数并记录平均时间。所有这些都在非常相似的时间范围内返回。它甚至试图通过在while(true)循环中占用除一个可用核心之外的所有核心来限制并行处理,但是我仍然没有发现特别的区别:

public class SumTester {
    private static final int ARRAY_SIZE = 100_000_000;
    private static final int ITERATION_LIMIT = 100;
    private static final int INT_VALUE_LIMIT = 4096;

    public static void main(String[] args) {
        Random random = new Random();
        int[] numbers = new int[ARRAY_SIZE];
        IntStream.range(0, ARRAY_SIZE).forEach(i->numbers[i] = random.nextInt(INT_VALUE_LIMIT));

        Map<String, ToLongFunction<int[]>> inputs = new HashMap<String, ToLongFunction<int[]>>();

        NanoTimer initializer = NanoTimer.start();
        System.out.println("initialized NanoTimer in " + initializer.microEnd() + " microseconds");

        inputs.put("sumByStream", SumTester::sumByStream);
        inputs.put("sumByIteration", SumTester::sumByIteration);
        inputs.put("sumByForLoop", SumTester::sumByForLoop);

        System.out.println("Parallelables: ");
        averageTimeFor(ITERATION_LIMIT, inputs, Arrays.copyOf(numbers, numbers.length));

        int cores = Runtime.getRuntime().availableProcessors();
        List<CancelableThreadEater> threadEaters = new ArrayList<CancelableThreadEater>();
        if (cores > 1){
            threadEaters = occupyThreads(cores - 1);
        }
        // Only one core should be left to our class
        System.out.println("\nSingleCore (" + threadEaters.size() + " of " + cores + " cores occupied)");
        averageTimeFor(ITERATION_LIMIT, inputs, Arrays.copyOf(numbers, numbers.length));
        for (CancelableThreadEater cte : threadEaters){
            cte.end();
        }
        System.out.println("Complete!");
    }

    public static long sumByStream(int[] numbers){
        return Arrays.stream(numbers).sum();
    }

    public static long sumByIteration(int[] numbers){
        int total = 0;
        for (int i : numbers){
            total += i;
        }
        return total;
    }

    public static long sumByForLoop(int[] numbers){
        int total = 0;
        for (int i = 0; i < numbers.length; i++){
            total += numbers[i];
        }
        return total;       
    }

    public static void averageTimeFor(int iterations, Map<String, ToLongFunction<int[]>> testMap, int[] numbers){
        Map<String, Long> durationMap = new HashMap<String, Long>();
        Map<String, Long> sumMap = new HashMap<String, Long>();
        for (String methodName : testMap.keySet()){
            durationMap.put(methodName, 0L);
            sumMap.put(methodName, 0L);
        }
        for (int i = 0; i < iterations; i++){
            for (String methodName : testMap.keySet()){
                int[] newNumbers = Arrays.copyOf(numbers, ARRAY_SIZE);
                ToLongFunction<int[]> function = testMap.get(methodName);
                NanoTimer nt = NanoTimer.start();
                long sum = function.applyAsLong(newNumbers);
                long duration = nt.microEnd();
                sumMap.put(methodName, sum);
                durationMap.put(methodName, durationMap.get(methodName) + duration);
            }
        }
        for (String methodName : testMap.keySet()){
            long duration = durationMap.get(methodName) / iterations;
            long sum = sumMap.get(methodName);
            System.out.println(methodName + ": result '" + sum + "', elapsed time: " + duration + " milliseconds on average over " + iterations + " iterations");
        }
    }

    private static List<CancelableThreadEater> occupyThreads(int numThreads){
        List<CancelableThreadEater> result = new ArrayList<CancelableThreadEater>();
        for (int i = 0; i < numThreads; i++){
            CancelableThreadEater cte = new CancelableThreadEater();
            result.add(cte);
            new Thread(cte).start();
        }
        return result;
    }

    private  static class CancelableThreadEater implements Runnable {
        private Boolean stop = false;
        public void run(){
            boolean canContinue = true;
            while (canContinue){
                synchronized(stop){
                    if (stop){
                        canContinue = false;
                    }
                }
            }           
        }

        public void end(){
            synchronized(stop){
                stop = true;
            }
        }
    }

}

返回

initialized NanoTimer in 22 microseconds
Parallelables: 
sumByIteration: result '-1413860413', elapsed time: 35844 milliseconds on average over 100 iterations
sumByStream: result '-1413860413', elapsed time: 35414 milliseconds on average over 100 iterations
sumByForLoop: result '-1413860413', elapsed time: 35218 milliseconds on average over 100 iterations

SingleCore (3 of 4 cores occupied)
sumByIteration: result '-1413860413', elapsed time: 37010 milliseconds on average over 100 iterations
sumByStream: result '-1413860413', elapsed time: 38375 milliseconds on average over 100 iterations
sumByForLoop: result '-1413860413', elapsed time: 37990 milliseconds on average over 100 iterations
Complete!

也就是说,在这种情况下没有真正的理由进行sum()操作。您正在迭代每个数组,总共三次迭代,最后一次可能是一次比正常更长的迭代。通过一个完整的阵列同时迭代和一个短路迭代,可以正确计算。有可能更有效率地做到这一点,但我无法找到比我做得更好的方法。我的解决方案最终成为图表上最快的java解决方案之一 - 它运行在223ms,这是python解决方案的中间包。

如果您愿意,我会在问题中添加我的解决方案,但我希望这里能回答实际问题。

答案 3 :(得分:1)

我的基准测试(参见下面的代码)显示,流式处理方法比迭代方法慢约10-15%。有趣的是,并行流的结果在我的4核(i7)macbook pro上变化很大,但是,虽然我看到它们几次比迭代快约30%,但最常见的结果几乎是慢三倍< / strong>比顺序。

以下是基准代码:

import java.util.*;
import java.util.function.*;

public class StreamingBenchmark {

    private static void benchmark(String name, LongSupplier f) {
       long start = System.currentTimeMillis(), sum = 0;
       for(int count = 0; count < 1000; count ++) sum += f.getAsLong();
       System.out.println(String.format(
           "%10s in  %d millis. Sum = %d", 
            name, System.currentTimeMillis() - start, sum
       ));
    }

    public static void main(String argv[]) {
        int data[] = new int[1000000];
        Random randy = new Random();
        for(int i = 0; i < data.length; i++) data[i] = randy.nextInt();

        benchmark("iterative", () -> { int s = 0; for(int n: data) s+=n; return s; });
        benchmark("stream", () -> Arrays.stream(data).sum());
        benchmark("parallel", () -> Arrays.stream(data).parallel().sum());

    }
}

以下是几次运行的输出:

 iterative in  350 millis. Sum = 564821058000
 stream in  394 millis. Sum = 564821058000
 parallel in  883 millis. Sum = 564821058000

 iterative in  340 millis. Sum = -295411382000
 stream in  376 millis. Sum = -295411382000
 parallel in  1031 millis. Sum = -295411382000

 iterative in  365 millis. Sum = 1205763898000
 stream in  379 millis. Sum = 1205763898000
 parallel in  1053 millis. Sum = 1205763898000

这让我很好奇,我也尝试在scala中运行等效的逻辑:

object Scarr {
    def main(argv: Array[String]) = {
        val randy = new java.util.Random
        val data = (1 to 1000000).map { _ => randy.nextInt }.toArray
        val start = System.currentTimeMillis
        var sum = 0l;
        for ( _ <- 1 to 1000 ) sum += data.sum
        println(sum + " in " + (System.currentTimeMillis - start) + " millis.")

    }
}

这花了 14秒!比java中的流式传输长约40倍(!)。哎哟!

答案 4 :(得分:1)

流功能相对较慢。因此,在leetcode竞赛或任何算法竞赛中,始终倾向于使用经典循环而不是流函数,因为大的输入容易产生TLE。反过来,这可能会导致罚款,从而影响您的最终排名。 https://stackoverflow.com/a/27994074/6185191

在此处提到了详细的解释