Java 8: desempenho do Streams vs Collections

140

Sou iniciante no Java 8. Ainda não conheço a API em profundidade, mas fiz uma pequena referência informal para comparar o desempenho da nova API do Streams com as boas e antigas coleções.

O teste consiste em filtrar uma lista de Integer, e para cada número par, calcular a raiz quadrada e armazená-la no resultado Listde Double.

Aqui está o código:

    public static void main(String[] args) {
        //Calculating square root of even numbers from 1 to N       
        int min = 1;
        int max = 1000000;

        List<Integer> sourceList = new ArrayList<>();
        for (int i = min; i < max; i++) {
            sourceList.add(i);
        }

        List<Double> result = new LinkedList<>();


        //Collections approach
        long t0 = System.nanoTime();
        long elapsed = 0;
        for (Integer i : sourceList) {
            if(i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Stream approach
        Stream<Integer> stream = sourceList.stream();       
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Parallel stream approach
        stream = sourceList.stream().parallel();        
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
    }.

E aqui estão os resultados para uma máquina com núcleo duplo:

    Collections: Elapsed time:        94338247 ns   (0,094338 seconds)
    Streams: Elapsed time:           201112924 ns   (0,201113 seconds)
    Parallel streams: Elapsed time:  357243629 ns   (0,357244 seconds)

Para este teste específico, os fluxos são duas vezes mais lentos que as coleções e o paralelismo não ajuda (ou estou usando da maneira errada?).

Questões:

  • Este teste é justo? Eu cometi algum erro?
  • Os fluxos são mais lentos que as coleções? Alguém fez uma boa referência formal sobre isso?
  • Qual abordagem devo buscar?

Resultados atualizados.

Fiz o teste 1k vezes após o aquecimento da JVM (1k iterações), conforme recomendado por @pveentjer:

    Collections: Average time:      206884437,000000 ns     (0,206884 seconds)
    Streams: Average time:           98366725,000000 ns     (0,098367 seconds)
    Parallel streams: Average time: 167703705,000000 ns     (0,167704 seconds)

Nesse caso, os fluxos são mais eficientes. Gostaria de saber o que seria observado em um aplicativo em que a função de filtragem é chamada apenas uma ou duas vezes durante o tempo de execução.

Mister Smith
fonte
1
você já tentou isso com um IntStream?
Mark Rotteveel
2
Você pode medir adequadamente? Se tudo o que você está fazendo é uma corrida, é claro que seus benchmarks estarão fora.
skiwi
2
@MisterSmith Podemos ter alguma transparência sobre como você aqueceu sua JVM, também com testes de 1K?
skiwi
1
E para os interessados ​​em escrever as marcas corretas de microbench, eis a questão: stackoverflow.com/questions/504103/…
Mister Smith
2
@assylias O uso toListdeve ser executado em paralelo, mesmo que esteja coletando em uma lista que não seja segura para threads, pois os diferentes threads serão coletados em listas intermediárias restritas a threads antes de serem mesclados.
Stuart Marks

Respostas:

192
  1. Pare de usar LinkedListpara remover qualquer coisa que não seja pesada no meio da lista usando o iterador.

  2. Pare de escrever o código de benchmarking manualmente, use JMH .

Benchmarks adequados:

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(StreamVsVanilla.N)
public class StreamVsVanilla {
    public static final int N = 10000;

    static List<Integer> sourceList = new ArrayList<>();
    static {
        for (int i = 0; i < N; i++) {
            sourceList.add(i);
        }
    }

    @Benchmark
    public List<Double> vanilla() {
        List<Double> result = new ArrayList<>(sourceList.size() / 2 + 1);
        for (Integer i : sourceList) {
            if (i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        return result;
    }

    @Benchmark
    public List<Double> stream() {
        return sourceList.stream()
                .filter(i -> i % 2 == 0)
                .map(Math::sqrt)
                .collect(Collectors.toCollection(
                    () -> new ArrayList<>(sourceList.size() / 2 + 1)));
    }
}

Resultado:

Benchmark                   Mode   Samples         Mean   Mean error    Units
StreamVsVanilla.stream      avgt        10       17.588        0.230    ns/op
StreamVsVanilla.vanilla     avgt        10       10.796        0.063    ns/op

Assim como eu esperava, a implementação do fluxo é bastante mais lenta. O JIT é capaz de incorporar todas as coisas lambda, mas não produz um código tão conciso quanto a versão vanilla.

Geralmente, os fluxos Java 8 não são mágicos. Eles não podiam acelerar as coisas já bem implementadas (com, provavelmente, iterações simples ou instruções do Java 5 para cada uma delas substituídas por Iterable.forEach()e Collection.removeIf()chamadas). Os fluxos são mais sobre codificação de conveniência e segurança. Conveniência - o tradeoff de velocidade está funcionando aqui.

leventov
fonte
2
Obrigado por reservar um tempo para avaliar isso. Eu não acho que alterar o LinkedList por ArrayList mudaria alguma coisa, pois os dois testes devem adicionar a ele, os horários não devem ser afetados. Enfim, você poderia explicar os resultados? É difícil dizer o que você está medindo aqui (as unidades dizem ns / op, mas o que é considerado um op?).
Mister Smith
52
Sua conclusão sobre desempenho, embora válida, é exagerada. Existem muitos casos em que o código de fluxo é mais rápido que o código iterativo, principalmente porque os custos de acesso por elemento são mais baratos com fluxos do que com iteradores simples. E, em muitos casos, a versão de streams é alinhada a algo equivalente à versão manuscrita. Claro, o diabo está nos detalhes; qualquer bit de código pode se comportar de maneira diferente.
Brian Goetz
26
@BrianGoetz, você poderia especificar casos de uso, quando os fluxos forem mais rápidos?
Alexandr
1
Na última versão do FMH: use em @Benchmarkvez de@GenerateMicroBenchmark
pdem 20/05
3
@BrianGoetz, você poderia especificar casos de uso quando o Streams for mais rápido?
kiltek 12/11/19
17

1) Você vê o tempo em menos de 1 segundo usando seu benchmark. Isso significa que pode haver forte influência de efeitos colaterais nos seus resultados. Então, eu ampliei sua tarefa 10 vezes

    int max = 10_000_000;

e executou seu benchmark. Meus resultados:

Collections: Elapsed time:   8592999350 ns  (8.592999 seconds)
Streams: Elapsed time:       2068208058 ns  (2.068208 seconds)
Parallel streams: Elapsed time:  7186967071 ns  (7.186967 seconds)

sem os int max = 1_000_000resultados edit ( ) foram

Collections: Elapsed time:   113373057 ns   (0.113373 seconds)
Streams: Elapsed time:       135570440 ns   (0.135570 seconds)
Parallel streams: Elapsed time:  104091980 ns   (0.104092 seconds)

É como seus resultados: o fluxo é mais lento que a coleta. Conclusão: muito tempo foi gasto para a inicialização do fluxo / transmissão de valores.

2) Depois de aumentar o fluxo de tarefas, ficou mais rápido (tudo bem), mas o fluxo paralelo permaneceu muito lento. O que há de errado? Nota: você tem collect(Collectors.toList())em seu comando. A coleta para uma coleção única introduz essencialmente gargalos e sobrecarga de desempenho em caso de execução simultânea. É possível estimar o custo relativo das despesas gerais substituindo

collecting to collection -> counting the element count

Para fluxos, isso pode ser feito por collect(Collectors.counting()). Eu obtive resultados:

Collections: Elapsed time:   41856183 ns    (0.041856 seconds)
Streams: Elapsed time:       546590322 ns   (0.546590 seconds)
Parallel streams: Elapsed time:  1540051478 ns  (1.540051 seconds)

Isso é uma grande tarefa! ( int max = 10000000) Conclusão: a coleta de itens para coleta levou a maior parte do tempo. A parte mais lenta é adicionar à lista. BTW, simples ArrayListé usado para Collectors.toList().

Sergey Fedorov
fonte
Você precisa marcar a marca desse teste com microbiologia, o que significa que ele deve primeiro ser aquecido várias vezes e, em seguida, executado várias vezes e calculado a média.
skiwi
@skiwi Com certeza, você está certo, principalmente porque há grandes desvios nas medições. Fiz apenas uma investigação básica e não pretendo que os resultados sejam precisos.
Sergey Fedorov 26/03
O JIT no modo de servidor entra em ação após 10k execuções. E então leva algum tempo para compilar o código e trocá-lo.
precisa saber é o seguinte
Sobre esta frase: " você tem o collect(Collectors.toList())seu comando, ou seja , pode haver uma situação em que você precise resolver uma única coleção por vários threads. " Tenho quase certeza de que toListcoletamos várias instâncias de lista diferentes em paralelo. Somente na última etapa da coleção, os elementos são transferidos para uma lista e, em seguida, retornados. Portanto, não deve haver sobrecarga de sincronização. É por isso que os colecionadores têm função de fornecedor, contador e combinador. (Poderia ser lento por outros motivos, é claro.) #
1100
@ Lii Penso da mesma forma sobre a collectimplementação aqui. Mas, no final, várias listas devem ser mescladas em uma única, e parece que a mesclagem é a operação mais pesada do exemplo.
Sergey Fedorov 07/07
4
    public static void main(String[] args) {
    //Calculating square root of even numbers from 1 to N       
    int min = 1;
    int max = 10000000;

    List<Integer> sourceList = new ArrayList<>();
    for (int i = min; i < max; i++) {
        sourceList.add(i);
    }

    List<Double> result = new LinkedList<>();


    //Collections approach
    long t0 = System.nanoTime();
    long elapsed = 0;
    for (Integer i : sourceList) {
        if(i % 2 == 0){
            result.add( doSomeCalculate(i));
        }
    }
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Stream approach
    Stream<Integer> stream = sourceList.stream();       
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i -> doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Parallel stream approach
    stream = sourceList.stream().parallel();        
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i ->  doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
}

static double doSomeCalculate(int input) {
    for(int i=0; i<100000; i++){
        Math.sqrt(i+input);
    }
    return Math.sqrt(input);
}

Mudei um pouco o código, corri no meu mac book pro que tem 8 núcleos, obtive um resultado razoável:

Coleções: Tempo decorrido: 1522036826 ns (1,522037 segundos)

Fluxos: Tempo decorrido: 4315833719 ns (4.315834 segundos)

Fluxos paralelos: Tempo decorrido: 261152901 ns (0,261153 segundos)

Mellon
fonte
Eu acho que seu teste é justo, você só precisa de uma máquina com mais núcleos de CPU.
Mellon
3

Para o que você está tentando fazer, eu não usaria APIs regulares de qualquer maneira. Há uma tonelada de boxe / unboxing acontecendo, então há uma enorme sobrecarga de desempenho.

Pessoalmente, acho que muitas APIs projetadas são uma porcaria, porque criam uma grande quantidade de lixo de objetos.

Tente usar matrizes primitivas de double / int e tente fazer um único encadeamento e veja qual é o desempenho.

PS: Você pode dar uma olhada no JMH para cuidar do benchmark. Ele cuida de algumas das armadilhas típicas, como o aquecimento da JVM.

pveentjer
fonte
As LinkedLists são ainda piores que ArrayLists porque você precisa criar todos os objetos do nó. O operador mod também é lento. Eu acredito que algo como 10/15 ciclos + drena o pipeline de instruções. Se você deseja fazer uma divisão muito rápida por 2, basta mudar o número 1 bit para a direita. Esses são truques básicos, mas tenho certeza de que existem truques avançados de modo para acelerar as coisas, mas esses provavelmente são mais específicos do problema.
pveentjer
Estou ciente do boxe. Esta é apenas uma referência informal. A idéia é ter a mesma quantidade de boxe / unboxing nos testes de coleções e de fluxos.
Mister Smith
Primeiro, eu me certificaria de que não está medindo erro. Tente executar o benchmark algumas vezes antes de fazer o benchmark real. Então, pelo menos, você tem o aquecimento da JVM fora do caminho e o código é JITADO corretamente. Sem isso, você provavelmente tira conclusões erradas.
pveentjer 26/03
Ok, vou postar novos resultados seguindo o seu conselho. Eu dei uma olhada no JMH, mas requer o Maven e leva algum tempo para configurar. Obrigado mesmo assim.
Mister Smith
Eu acho que é melhor evitar pensar em testes de referência em termos de "Pelo que você está tentando fazer". isto é, geralmente esses tipos de exercícios são simplificados o suficiente para serem demonstráveis, mas complexos o suficiente para parecerem que podem / devem ser simplificados.
ryvantage