[Traducción] Cuándo usar flujos paralelos

Fuente
Autores: Doug Lea con Brian Goetz, Paul Sandoz, Alexei Shipilev, Heinz Kabutz, Joe Bowbeer, ...

El marco java.util.streams contiene operaciones basadas en datos en colecciones y otras fuentes de datos. La mayoría de los métodos de flujo realizan la misma operación en cada elemento. Con el método de recopilación parallelStream() , si tiene varios núcleos, puede convertir los datos en datos paralelos . ¿Pero cuándo vale la pena hacerlo?


Considere usar S.parallelStream().operation(F) lugar de S.stream().operation(F) , siempre que las operaciones sean independientes entre sí y sean computacionalmente caras o se apliquen a una gran cantidad de elementos que se dividen efectivamente (divisible) estructuras de datos, o ambos. Más precisamente:


  • F : una función para trabajar con un solo elemento, generalmente una lambda, es independiente, es decir la operación en cualquiera de los elementos es independiente y no afecta las operaciones en otros elementos (para obtener recomendaciones sobre el uso de funciones sin estado que no interfieren, consulte la documentación del paquete de flujo ).
  • S : La colección original está efectivamente dividida. Además de las colecciones, hay otras adecuadas para la paralelización, la transmisión de fuentes de datos, por ejemplo, java.util.SplittableRandom (para la paralelización de las cuales puede usar el método stream.parallel() ). Pero la mayoría de las fuentes con E / S en el núcleo están diseñadas principalmente para operación secuencial.
  • El tiempo de ejecución total en modo secuencial excede el límite mínimo permitido. Hoy, para la mayoría de las plataformas, el límite es aproximadamente igual (dentro de x10) a 100 microsegundos. No se requieren mediciones precisas, en este caso. Para propósitos prácticos, es suficiente simplemente multiplicar N (el número de elementos) por Q (el tiempo de operación de un F ), y Q puede estimarse aproximadamente por el número de operaciones o el número de líneas de código. Después de eso, debe verificar que N * Q sea ​​al menos inferior a 10000 (si es tímido, agregue uno o un par de ceros). Entonces, si F es una función pequeña como x -> x + 1 , entonces la ejecución paralela tendrá sentido cuando N >= 10000 . Por el contrario, si F es un cálculo pesado, similar a encontrar el siguiente mejor movimiento en un juego de ajedrez, entonces el valor de Q tan grande que N puede ser descuidado, pero hasta que la colección se divida por completo.

El marco de procesamiento de transmisión no insistirá (y no puede) en ninguno de los anteriores. Si los cálculos son interdependientes, entonces su ejecución paralela no tiene sentido, o será perjudicial y conducirá a errores. Otros criterios derivados de los problemas de ingeniería y las compensaciones anteriores incluyen:


  • Puesta en marcha
    La aparición de núcleos adicionales en los procesadores, en la mayoría de los casos, estuvo acompañada por la adición de un mecanismo de administración de energía, que puede causar una desaceleración en el lanzamiento de los núcleos, a veces con superposiciones adicionales de la JVM, el sistema operativo y el hipervisor. En este caso, el límite en el que el modo paralelo tiene sentido corresponde aproximadamente al tiempo requerido para comenzar a procesar las subtareas con un número suficiente de núcleos. Después de eso, la computación paralela puede ser más eficiente energéticamente que secuencial (dependiendo de los detalles de los procesadores y sistemas. Para un ejemplo, vea el artículo ).
  • Detallado (granularidad)
    Rara vez tiene sentido dividir pequeños cálculos. El marco generalmente divide la tarea para que las partes individuales puedan funcionar en todos los núcleos del sistema disponibles. Si, después del inicio, prácticamente no hay trabajo para cada núcleo, se desperdiciarán los esfuerzos (generalmente secuenciales) para organizar la computación paralela. Dado que en la práctica el número de núcleos varía de 2 a 256 umbrales, también evita el efecto indeseable de la división excesiva de la tarea.
  • Divisibilidad
    Las colecciones divididas más eficientemente incluyen ArrayList y {Concurrent}HashMap , así como matrices regulares ( T[] , que se dividen en partes utilizando métodos estáticos java.util.Arrays ). Los divisores menos eficientes son LinkedList , BlockingQueue y la mayoría de las fuentes basadas en E / S. El resto está en algún punto intermedio (las estructuras de datos que admiten acceso aleatorio y / o búsqueda eficiente generalmente se dividen de manera eficiente). Si dividir datos lleva más tiempo que el procesamiento, entonces el esfuerzo es en vano. Si Q es lo suficientemente grande, puede obtener un aumento debido a la paralelización incluso para LinkedList , pero este es un caso bastante raro. Además, algunas fuentes no se pueden dividir en un solo elemento y, por lo tanto, puede haber una restricción en el grado de descomposición del problema.

Obtener las características exactas de estos efectos puede ser difícil (aunque, si lo intentas, se puede hacer usando herramientas como JMH ). Pero el efecto acumulativo es bastante fácil de notar. Para sentirlo usted mismo, haga un experimento. Por ejemplo, en una máquina de prueba de 32 núcleos, cuando ejecuta funciones pequeñas, como max() o sum() , por encima de ArrayList punto de equilibrio es de aproximadamente 10,000. Para más elementos, se observa una aceleración de hasta 20 veces. El horario de apertura para colecciones con menos de 10,000 artículos no es mucho menor que para 10,000 y, por lo tanto, es más lento que el procesamiento secuencial. El peor resultado ocurre con menos de 100 elementos: en este caso, los hilos involucrados se detienen sin hacer nada útil, porque los cálculos se completan antes de que comiencen. Por otro lado, cuando las operaciones en elementos requieren mucho tiempo, cuando se usan colecciones de manera eficiente y completamente divisibles, como ArrayList , los beneficios son visibles de inmediato.


Parafraseando todo lo anterior, el uso de parallel() en el caso de una cantidad irrazonablemente pequeña de cómputo puede costar alrededor de 100 microsegundos, y el uso de otra manera debería ahorrar al menos este tiempo (o tal vez horas para tareas muy grandes). El costo y los beneficios específicos variarán con el tiempo para diferentes plataformas y también, según el contexto. Por ejemplo, ejecutar pequeños cálculos en paralelo dentro de un ciclo secuencial mejora el efecto de los altibajos (los microprotestas de rendimiento en los que esto ocurre puede no reflejar la situación real).


Preguntas y respuestas


  • ¿Por qué la JVM no puede entender cuándo ejecutar operaciones en paralelo?

Podría intentarlo, pero con demasiada frecuencia la decisión sería incorrecta. La búsqueda de paralelismo multinúcleo totalmente automático no ha dado lugar a una solución universal durante los últimos treinta años, y por lo tanto, el marco utiliza un enfoque más confiable, que requiere que el usuario solo elija entre sí o no . Esta elección se basa en problemas de ingeniería que se encuentran constantemente en la programación secuencial, que es poco probable que desaparezcan por completo. Por ejemplo, puede encontrar una desaceleración de cien veces al buscar el valor máximo en una colección que contiene un solo elemento en comparación usando este valor directamente (sin una colección). A veces, la JVM puede optimizar estos casos para usted. Pero esto rara vez ocurre en casos secuenciales, y nunca en el caso del modo paralelo. Por otro lado, podemos esperar que, a medida que se desarrollen, las herramientas ayudarán a los usuarios a tomar mejores decisiones.


  • ¿Qué sucede si para tomar una buena decisión no tengo suficiente conocimiento sobre los parámetros ( F , N , Q , S )?

Esto también es similar a los problemas encontrados en la programación secuencial. Por ejemplo, el S.contains(x) de la clase Collection generalmente se ejecuta rápido si S es un HashSet , lento si LinkedList y promedio en otros casos. Por lo general, para el autor de un componente que usa la colección, la mejor manera de salir de esta situación es encapsularlo y publicar solo una operación específica en él. Entonces los usuarios estarán aislados de la necesidad de elegir. Lo mismo se aplica a las operaciones paralelas. Por ejemplo, un componente con una colección de precios interna puede determinar un método que verifique su tamaño hasta el límite, lo que tendrá sentido hasta que la computación a nivel de bits sea demasiado costosa. Un ejemplo:


 public long getMaxPrice() { return priceStream().max(); } private Stream priceStream() { return (prices.size() < MIN_PAR) ? prices.stream() : prices.parallelStream(); } 

Esta idea puede extenderse a otras consideraciones sobre cuándo y cómo usar la concurrencia.


  • ¿Qué sucede si mi función probablemente realiza operaciones de E / S u operaciones sincronizadas?

En un extremo están las funciones que no cumplen con los criterios de independencia, incluidas las operaciones de E / S secuenciales, el acceso a recursos sincronizados de bloqueo y los casos en que un error en una subtarea paralela que realiza E / S afecta a otros. Su paralelización no tiene mucho sentido. Por otro lado, hay cálculos que ocasionalmente realizan E / S o raramente bloquean la sincronización (por ejemplo, la mayoría de los casos de registro y el uso de colecciones competitivas como ConcurrentHashMap ). Son inofensivos Lo que hay entre ellos requiere más investigación. Si cada subtarea se puede bloquear durante un tiempo considerable en espera de E / S o acceso, los recursos de la CPU estarán inactivos sin la posibilidad de su uso por parte del programa o JVM. De esto es malo para todos. En estos casos, el procesamiento de transmisión en paralelo no siempre es la opción correcta. Pero hay buenas alternativas, por ejemplo, E / S asíncrona y el enfoque CompletableFuture .


  • ¿Qué sucede si mi fuente se basa en E / S?

Por el momento, utilizando los generadores JDK Stream / I / O (por ejemplo, BufferedReader.lines() ), se adaptan principalmente para su uso en modo secuencial, procesando elementos uno por uno a medida que estén disponibles. Es posible el soporte para el procesamiento masivo de alto rendimiento de E / S almacenadas, pero, por el momento, esto requiere el desarrollo de generadores especiales Stream s, Spliterator s y Collector s. Se puede agregar soporte para algunos casos comunes en futuras versiones de JDK.


  • ¿Qué sucede si mi programa se ejecuta en una computadora ocupada y todos los núcleos están ocupados?

Las máquinas generalmente tienen un número fijo de núcleos y no pueden crear mágicamente nuevos nuevos cuando realizan operaciones paralelas. Sin embargo, siempre y cuando los criterios para elegir un modo paralelo claramente hablen, no hay nada que dudar. Sus tareas paralelas competirán por la CPU con otras y notará menos aceleración. En la mayoría de los casos, esto es aún más efectivo que otras alternativas. El mecanismo subyacente está diseñado de modo que si no hay núcleos disponibles, solo notará una ligera desaceleración en comparación con la versión secuencial, excepto cuando el sistema está tan sobrecargado que pasa todo su tiempo cambiando de contexto en lugar de hacer un trabajo real, o configurado con la expectativa de que todo el procesamiento se realice de forma secuencial. Si tiene un sistema de este tipo, entonces quizás el administrador ya haya deshabilitado el uso de multithreading / nuclearity en la configuración de JVM. Y si usted es el administrador del sistema, tiene sentido hacerlo.


  • ¿Todas las operaciones están paralelas cuando se usa el modo paralelo?

Si Al menos hasta cierto punto. Pero vale la pena tener en cuenta que el marco de flujo tiene en cuenta las limitaciones de las fuentes y los métodos al elegir cómo hacer esto. En general, cuantas menos restricciones, mayor es el potencial de paralelismo. Por otro lado, no hay garantía de que el marco identificará y aplicará todas las oportunidades disponibles para la concurrencia. En algunos casos, si tiene el tiempo y la competencia, su propia solución puede hacer un uso mucho mejor de las posibilidades de paralelismo.


  • ¿Qué aceleración obtendré de la concurrencia?

Si se adhiere a estos consejos, entonces, generalmente, lo suficiente como para tener sentido. La previsibilidad no es un punto fuerte del hardware y los sistemas modernos y, por lo tanto, no hay una respuesta universal. La localidad de caché, las características de GC, la compilación JIT, los conflictos de acceso a la memoria, la ubicación de los datos, las políticas de programación del sistema operativo y la presencia de un hipervisor son algunos de los factores que tienen un impacto significativo. El rendimiento del modo secuencial también está sujeto a su influencia, que, cuando se usa el paralelismo, a menudo se amplifica: el problema que causa una diferencia del 10 por ciento en el caso de la ejecución secuencial puede conducir a una diferencia de 10 veces en el procesamiento paralelo.


Stream Framework incluye algunas características que ayudan a aumentar las posibilidades de aceleración. Por ejemplo, el uso de la especialización para primitivas, como IntStream , generalmente tiene un mayor efecto para el modo paralelo que para el modo secuencial. La razón es que, en este caso, no solo disminuye el consumo de recursos (y memoria), sino que también mejora la localidad de caché. El uso de ConcurrentHashMap lugar de HashMap , en el caso de la operación paralela de la operación de collect , reduce los costos internos. Aparecerán nuevos consejos y trucos como experiencia adquirida con el marco.


  • Todo esto es demasiado aterrador! ¿No podemos proponer reglas para usar las propiedades de JVM para desactivar la concurrencia?

No queremos decirte qué hacer. La aparición de nuevas formas para que los programadores hagan algo mal puede dar miedo. Los errores en el código, la arquitectura y las evaluaciones ciertamente sucederán. Hace décadas, algunas personas predijeron que la concurrencia a nivel de la aplicación conduciría a grandes desastres. Pero nunca se hizo realidad.

Source: https://habr.com/ru/post/es420805/


All Articles