Java 8: rendimiento de Streams vs Collections

140

Soy nuevo en Java 8. Todavía no conozco la API en profundidad, pero he hecho un pequeño punto de referencia informal para comparar el rendimiento de la nueva API de Streams con las colecciones antiguas.

La prueba consiste en filtrar una lista de Integer, y para cada número par, calcular la raíz cuadrada y almacenarla como resultado Listde Double.

Aquí está el 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));      
    }.

Y aquí están los resultados para una máquina de doble núcleo:

    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 esta prueba en particular, las transmisiones son aproximadamente el doble de lentas que las colecciones, y el paralelismo no ayuda (¿o lo estoy usando de manera incorrecta?).

Preguntas:

  • ¿Es justa esta prueba? ¿He cometido algún error?
  • ¿Las transmisiones son más lentas que las colecciones? ¿Alguien ha hecho un buen punto de referencia formal sobre esto?
  • ¿Por qué enfoque debería luchar?

Resultados actualizados

Ejecuté la prueba 1k veces después del calentamiento JVM (1k iteraciones) según lo aconsejado 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)

En este caso, las secuencias son más efectivas. Me pregunto qué se observaría en una aplicación donde la función de filtrado solo se llama una o dos veces durante el tiempo de ejecución.

Señor Smith
fuente
1
¿Lo has probado con un IntStreamen su lugar?
Mark Rotteveel
2
¿Puedes por favor medir correctamente? Si todo lo que está haciendo es una carrera, sus puntos de referencia, por supuesto, estarán apagados.
skiwi
2
@MisterSmith ¿Podemos tener algo de transparencia sobre cómo calentó su JVM, también con pruebas de 1K?
skiwi
1
Y para aquellos interesados ​​en escribir microbenchmarks correctos, aquí está la pregunta: stackoverflow.com/questions/504103/…
Mister Smith
2
@assylias El uso toListdebe ejecutarse en paralelo, incluso si se recopila en una lista no segura para subprocesos, ya que los diferentes subprocesos se recopilarán en listas intermedias confinadas en subprocesos antes de fusionarse.
Stuart Marks

Respuestas:

192
  1. Deje de usar LinkedListpara cualquier cosa que no sea una eliminación pesada del medio de la lista con el iterador.

  2. Deje de escribir código de evaluación comparativa a mano, use JMH .

Puntos de referencia adecuados:

@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

Tal como esperaba, la implementación de la transmisión es bastante más lenta. JIT puede incorporar todas las cosas lambda pero no produce un código tan conciso como la versión estándar.

En general, las transmisiones de Java 8 no son mágicas. No podían acelerar las cosas ya bien implementadas (con, probablemente, iteraciones simples o declaraciones for-each de Java 5 reemplazadas por Iterable.forEach()y Collection.removeIf()llamadas). Las transmisiones tienen más que ver con la codificación de conveniencia y seguridad. Conveniencia: la compensación de velocidad está funcionando aquí.

leventov
fuente
2
Gracias por tomarse el tiempo para evaluar esto. No creo que cambiar LinkedList por ArrayList cambie nada, ya que ambas pruebas deberían agregarse, los tiempos no deberían verse afectados. De todos modos, ¿podría explicar los resultados? Es difícil saber qué está midiendo aquí (las unidades dicen ns / op, pero ¿qué se considera una operación?).
Mister Smith
52
Su conclusión sobre el rendimiento, aunque válida, es exagerada. Hay muchos casos en los que el código de flujo es más rápido que el código iterativo, en gran parte porque los costos de acceso por elemento son más baratos con los flujos que con los iteradores simples. Y en muchos casos, la versión de las secuencias se alinea con algo que es equivalente a la versión escrita a mano. Por supuesto, el diablo esta en los detalles; cualquier bit de código dado podría comportarse de manera diferente.
Brian Goetz
26
@BrianGoetz, ¿podría especificar casos de uso cuando las transmisiones son más rápidas?
Alexandr
1
En la última versión de FMH: use en @Benchmarklugar de@GenerateMicroBenchmark
pdem
3
@BrianGoetz, ¿podría especificar casos de uso cuando las transmisiones son más rápidas?
kiltek
17

1) Ve menos de 1 segundo usando su punto de referencia. Eso significa que puede haber una fuerte influencia de los efectos secundarios en sus resultados. Entonces, aumenté tu tarea 10 veces

    int max = 10_000_000;

y ejecutó su punto de referencia. Mis 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)

sin editar ( int max = 1_000_000) los resultados fueron

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)

Es como sus resultados: la transmisión es más lenta que la recopilación. Conclusión: se dedicó mucho tiempo a la inicialización del flujo / transmisión de valores.

2) Después de aumentar el flujo de tareas se hizo más rápido (eso está bien), pero el flujo paralelo permaneció demasiado lento. Que pasa Nota: tienes collect(Collectors.toList())en tu comando. La recolección en una sola colección esencialmente introduce un cuello de botella en el rendimiento y una sobrecarga en caso de ejecución concurrente. Es posible estimar el costo relativo de los gastos generales reemplazando

collecting to collection -> counting the element count

Para transmisiones se puede hacer por collect(Collectors.counting()). Obtuve 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)

¡Eso es para una gran tarea! ( int max = 10000000) Conclusión: la recolección de artículos para la recolección tomó la mayoría del tiempo La parte más lenta es agregar a la lista. Por cierto, simple ArrayListse utiliza para Collectors.toList().

Sergey Fedorov
fuente
Debe realizar una microbenchmark de esta prueba, lo que significa que primero debe calentarse muchas veces y luego ejecutarse muchas tmes y promediarse.
skiwi
@skiwi seguro, tienes razón, especialmente porque hay grandes desviaciones en las mediciones. Solo hice una investigación básica y no pretendo que los resultados sean precisos.
Sergey Fedorov
El JIT en modo servidor, se activa después de 10k ejecuciones. Y luego toma algún tiempo compilar el código e intercambiarlo.
pveentjer
Acerca de esta oración: " tienes collect(Collectors.toList())en tu comando, es decir, puede haber una situación en la que necesites abordar una Colección individual por varios hilos " . Estoy casi seguro de que se toListrecopila en varias instancias de listas diferentes en paralelo. Solo como el último paso de la colección, los elementos se transfieren a una lista y luego se devuelven. Por lo tanto, no debe haber sobrecarga de sincronización. Esta es la razón por la cual los coleccionistas tienen una función de proveedor, un acumulador y una combinación. (Podría ser lento por otras razones, por supuesto).
Lii
@Lii Pienso lo mismo sobre la collectimplementación aquí. Pero al final, varias listas deberían fusionarse en una sola, y parece que la fusión es la operación más pesada en el ejemplo dado.
Sergey Fedorov
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);
}

Cambié un poco el código, ejecuté mi Mac Book Pro que tiene 8 núcleos, obtuve un resultado razonable:

Colecciones: Tiempo transcurrido: 1522036826 ns (1.522037 segundos)

Streams: Tiempo transcurrido: 4315833719 ns (4.315834 segundos)

Flujos paralelos: Tiempo transcurrido: 261152901 ns (0.261153 segundos)

Mellon
fuente
Creo que su prueba es justa, solo necesita una máquina que tenga más núcleos de CPU.
Mellon
3

Para lo que estás tratando de hacer, de todos modos no usaría el java api normal. Hay un montón de boxeo / unboxing, por lo que hay una gran sobrecarga de rendimiento.

Personalmente, creo que muchas API diseñadas son basura porque crean una gran cantidad de basura de objetos.

Intente utilizar matrices primitivas de double / int e intente hacerlo de un solo subproceso y vea cuál es el rendimiento.

PD: es posible que desee echar un vistazo a JMH para encargarse de hacer el punto de referencia. Se ocupa de algunos de los escollos típicos, como el calentamiento de la JVM.

pveentjer
fuente
Las LinkedLists son incluso peores que las ArrayLists porque necesita crear todos los objetos de nodo. El operador de mod también es perro lento. Creo que algo así como 10/15 ciclos + drena la tubería de instrucciones. Si quieres hacer una división muy rápida por 2, simplemente mueve el número 1 bit a la derecha. Estos son trucos básicos, pero estoy seguro de que hay trucos avanzados de modo para acelerar las cosas, pero estos probablemente son más específicos del problema.
pveentjer
Soy consciente del boxeo. Esto es solo un punto de referencia informal. La idea es tener la misma cantidad de boxeo / unboxing tanto en las colecciones como en las pruebas de secuencias.
Señor Smith
Primero, me aseguraría de que no esté midiendo el error. Intente ejecutar el punto de referencia varias veces antes de hacer el punto de referencia real. Entonces, al menos, tiene el calentamiento JVM fuera del camino y el código está JITADO correctamente. Sin esto, probablemente sacas las conclusiones equivocadas.
pveentjer
Ok, publicaré nuevos resultados siguiendo tu consejo. He echado un vistazo a JMH pero requiere Maven y su configuración tarda un tiempo. Gracias de cualquier manera.
Señor Smith
Creo que es mejor evitar pensar en pruebas de referencia en términos de "Por lo que estás tratando de hacer". es decir, generalmente este tipo de ejercicios se simplifican lo suficiente como para ser demostrables, pero lo suficientemente complejos como para parecer que pueden / deberían simplificarse.
ryvantage