Saltar al contenido

Introducción al marco de unión de Java Fork

Voy a hacer una prueba informal para ver el rendimiento de la clase SumTask frente a una implementación secuencial, en una lista de diez millones de elementos:

1234567891011121314151617181920212223242526272829303132333435publicclassComparePerformance{publicstaticvoidmain(String[] args){Aleatorio aleatorio =nuevoAleatorio();List<Long> data = aleatorio .longs(10_000_000,1,100).boxed(). collect(toList());testForkJoin(data);//testSequentially(data);}privatestaticvoidtestForkJoin(List<Long> data){finallong start =System. currentTimeMillis();ForkJoinPool pool =newForkJoinPool();SumTask task =newSumTask(datos); pool.invoke(task);System.out.println("Ejecutado con fork/join in (ms): "+(System.currentTimeMillis()- start));}privatestaticvoidtestSequentially(List<Long> data){finallong start =System.currentTimeMillis();long sum =0;for(Long l: data){ sum += l;}System.out.println("Ejecutado secuencialmente en (ms): "+(System.currentTimeMillis()- start));}}

java

Introducción al marco de unión de Java Fork
Introducción al marco de unión de Java Fork

Sé que la validez de una prueba como esta es cuestionable (usando System.currentTimeMillis() para medir el tiempo de ejecución?), y que dependiendo del hardware que estés probando, puedes obtener diferentes resultados.

Sin embargo, no tenemos que preocuparnos demasiado por los números reales porque la forma en que se comparan entre sí es mucho más interesante!

Corrí el programa diez veces para cada prueba para obtener un tiempo de ejecución promedio. La primera prueba usó el marco de la horquilla/junta, usando un umbral de mil elementos, luego un umbral de cien mil, y luego un umbral de un millón de elementos. La segunda prueba utilizó la implementación secuencial.

Aquí están los resultados:

ForkJoin 1.000 THForkJoin 100.000 THForkJoin 1.000, 000 THSecuencialmenteTiempo #01 (en ms)36263415Tiempo #02 (en ms)107313616Tiempo #03 (en ms)38252816Tiempo #04 (en ms)34313331Tiempo #05 (en ms)32312915Tiempo #06 (en ms)140273016Hora #07 (en ms)35263032Hora #08 (en ms)36283616Hora #09 (en ms)34272916Hora #10 (en ms)37323315 Promedio 52. 9 28,4 31,8 18,8

Esto dio algunos resultados inesperados.

En primer lugar, se puede ver que en una lista de diez millones, mil elementos es en realidad un umbral bajo, que impacta en el rendimiento debido a la sobrecarga de crear muchas pequeñas subtareas.

Por otro lado, si llegas hasta un millón, tendrás mejores tiempos en promedio, pero el punto dulce está más cerca de los cien mil.

Sin embargo, la implementación más eficiente en tiempo fue la secuencial, alrededor de un 30% más rápida que la implementación de bifurcación/unión con un umbral de cien mil.

¿Es porque la tarea es tan simple que no necesita ser ejecutada en paralelo?

Probablemente. Aunque una tarea de suma puede considerarse un algoritmo de dividir y conquistar (el tipo de algoritmo que funciona perfectamente con el marco de bifurcación/unión), si la tarea es pequeña, probablemente sea mejor hacerla secuencialmente; al final, el costo de dividir y poner en cola las tareas se suma para exacerbar el tiempo de ejecución.

Por otro lado, el método computeSumDirectly en la implementación de bifurcación/unión tiene el mismo bucle que la implementación secuencial para sumar todos los elementos de la lista, pero qué pasa si cambiamos la implementación secuencial a algo como lo siguiente:

1234567privatestaticvoidtestSequentiallyStream(List<Long> data){finallong start =System.currentTimeMillis(); data.stream().reduce(0L,Long::sum);System.out.println("Ejecutado con un stream secuencial en (ms): "+(System.currentTimeMillis()- start)";}

java

El código parece más elegante, pero en mi caso, el promedio de ejecución subió a 241,5 ms, probablemente debido a la sobrecarga de la creación de la corriente.

Y cuando utilizo una corriente paralela (que utiliza el marco de bifurcación/unión bajo el capó con la piscina común):

1234567privatestaticvoidtestParallelStream(List<Long> data){finallong start =System.currentTimeMillis(); data.parallelStream().reduce(0L,Long::sum);System.out.println("Ejecutado con un flujo secuencial en (ms): "+(System.currentTimeMillis()- start)";}

java

El tiempo medio de ejecución que obtengo es de 181,6 ms.

Así que la aplicación también importa.

Pero volviendo a la comparación original, ¿es porque mi procesador sólo tiene cuatro núcleos y no permite un alto nivel de paralelismo?

Probablemente, eso también suena como una buena razón.

Más núcleos, tal vez dieciséis, podrían funcionar mejor para el paralelismo ya que las tareas más pequeñas pueden ser canalizadas rápidamente.

Sin embargo, lo que podemos negar es que dos aspectos muy importantes para obtener un buen rendimiento cuando se utiliza el marco de horquilla/junta son:

  • Determinar el umbral adecuado de una tarea de bifurcación
  • Determinar el nivel correcto de paralelismo

En otras palabras, decidir si se usa el tenedor/junta se reduce a experimentar con diferentes parámetros.

David Hovemeyer dice en su conferencia sobre el paralelismo Fork/join:

Claramente el paralelismo tiene ventajas y desventajas, y no es un buen ajuste para todas las situaciones.