Introducción

Cuando nosotros realizamos un código desde C# (y en la mayoría de los lenguajes existentes) este algoritmo, a menos que explicitemos lo contrario, será ejecutado en forma secuencial; lo cual implica que todo el proceso será realizado por un mismo hilo (thread) y, por consecuencia de asignación de recursos, por un mismo micro ó core. Por el contrario si utilizamos hilos dentro de nuestro código estamos especificando que distintas partes del código serán ejecutadas en concurrente haciendo que el sistema operativo turne distintos lapsos de tiempos a cada hilo o proceso. De todas formas estos hilos pueden ser ejecutados por el mismo procesador o no, dado que el control del proceso de cada hilo es decidido por el sistema operativo que estemos utilizando. Mediante la utilización de Parallel Extensions, o Task Parallel podemos especificarle al sistema operativo que distintas porciones de nuestro código sean ejecutadas por distintos procesadores ó cores.

Parallel Extensions es una librería (System.Threading) que nos provee de una seria de métodos para orientar nuestro desarrollo hacia la optima utilización de los procesadores que nuestro hardware posea. De esta forma podemos aprovechar al máximo el potencial de nuestro hardware haciendo que nuestra implementación posea una mayor performance y el algoritmo ejecute más rápido utilizando de manera más óptima los recursos disponibles.

Ahora bien, la mayoría de los procesadores de hoy en día cuentan con varios cores; por lo cual es conveniente comenzar a tener este tipo de consideraciones a la hora de realizar nuestros desarrollos para poder optimizar distintas partes del código sin tener un esfuerzo extra a la hora de programar.

La siguiente imagen muestra el uso de cada uno de los procesadores / cores teniendo en cuenta todos los procesos que el sistema operativo se encuentra ejecutando. Cabe aclarar que el sistema operativo se encarga de distribuir toda la carga de los procesos entre los distintos recursos que posea para administrar y esa es su tarea primordial, la administración de recursos.

image

La programación teniendo en cuenta la utilización de cada uno de los cores en cada porción del código en donde sea factible recibe el nombre de Parallel Programming Model, la cual llevada a un extremo idealiza que cada sentencia de iteración sea distribuida una iteración por procesador / cores. Hay lenguajes que directamente fueron diseñados bajo este modelo por lo cual ese comportamiento se obtiene en forma natural.

Cuando utilizamos este tipo de modelo para nuestros algoritmos hay que tener en cuenta que si una sentencia de iteración tiene una esencia recursiva (esto quiere decir que la iteración “I” depende de la iteración “I – 1” o viceversa) vamos a tener una alta dependencia entre las iteraciones por lo cual la ejecución en concurrente puede verse complicada o incluso que sea absurda plantearla, dado que si es llevada a cabo el control de la concurrencia en dichos casos haría incluso mas lenta la ejecución.

En este post vamos a ver un simple ejemplo de como podemos mejorar la performance de una implementación haciendo un uso muy sencillos de la librería Parallel Extensions. Esta libraría ofrece muchas mas clases para enriquecer nuestro código con estas practicas, por lo cual les recomiendo visitar los links al final del post.

Ejemplo de utilización

Para mostrar el funcionamiento de Parallel Extensions vamos a utilizar un algoritmo de cálculo de Pi implementado con el método de la aguja de Buffon: “… Se trata de lanzar una aguja sobre un papel en el que se han trazado rectas paralelas distanciadas entre sí de manera uniforme. Se puede demostrar que si la distancia entre las rectas es igual a la longitud de la aguja, la probabilidad de que la aguja cruce alguna de las líneas es 2/Pi…”.

Veremos como mejorar la performance de una misma solución utilizando distintas implementaciones, en primera instancia una secuencial para observar el comportamiento del hardware y en segunda la misma implementación utilizando Parallel Extensions para observar el cambio en el rendimiento.

Nuestro código de cálculo de Pi en forma secuencial sería de la siguiente forma:

image

Este algoritmo realiza varias pruebas lanzando la aguja con distintas medidas contando la cantidad de intersecciones que encontramos para realizar el calculo de manera empírica según el método de Buffon.

Cuando ejecutamos este programa podemos ver que el uso del CPU es el siguiente:

image

En este grafico se observa que el primer core está ejecutando el proceso del algoritmo mientras que el otro core nivela con los restantes procesos del sistema operativo, al mismo tiempo de colaborar con el mismo. Por supuesto que en todos los casos es el sistema operativo que se encarga de administrar la carga del proceso entre todos los recursos que posea disponibles, y es así como debe ser. Por lo cual se observa la utilización de ambos. Lo más importante que se debe notar en este gráfico es que no se está utilizando todo el potencial de la máquina. O sea que el algoritmo no está haciendo un uso optimo de los recursos, puesto que podría ejecutarse en menor tiempo haciendo un mejor uso de los recursos disponibles, dado que ninguno de los cores se encuentran al 100% del procesamiento, el cual es el escenario optimo.

Esto podemos apreciarlo de mejor forma en la vista de recursos, en donde se puede apreciar que el procesador no se encuentra al 100% por lo cual se requiere mayor tiempo para finalizar con la ejecución del algoritmo.

image

Si realizamos el mismo algoritmo utilizando Parallel Extensions, estaremos especificándole al sistema operativo que cada una de las iteraciones debe ejecutarse en cada uno de los procesadores / cores que la maquina posea. De esta manera si contamos con 2 cores se estarían ejecutando 2 iteraciones en paralelo. Mientras más procesadores o cores tenga nuestro hardware mas iteraciones en concurrente obtendremos. De esta forma hacemos un mayor uso de nuestros recursos disponibles.

Al decidir utilizar parallel extensions para la ejecución de un ciclo debemos tener en cuenta los casos de concurrencia que tendremos, el caso optimo es aquel en el que la solución no presenta ningún caso de recursividad, por lo cual la iteración “i” no depende del resultado de la iteración “i-1”; de esta forma nos aseguramos que la iteración “i” pueda ejecutarse al mismo tiempo que la iteración “i-1” sin que ninguna tenga que esperar al resultado de la otra. En caso de tener que utilizar variables o recursos compartidos entre las distintas iteraciones el control de la concurrencia debemos hacerlo nosotros, porque nunca debemos olvidarnos que la ejecución será en concurrente. Por lo cual podemos utilizar la sentencia look, semáforos, wait handlers o cualquier mecanismo que creamos conveniente para el escenario. Podemos encontrar algunos recursos útiles dentro de la librería de Parallel Extensions. Mientras menos secciones criticas contenga nuestro código mayor independencia tendremos entre cada una de las iteraciones y la ejecución será más rápida.

image

En nuestra implementación con Parallel Extensions en primera medida debemos notar que la primera sentencia de iteración fue reemplazada por la llamada a un método de la librería Parallel.For la cual nos provee de un mecanismo para ejecutar con parallelism cada una de las iteraciones del ciclo. Los parámetros que recibe este método son muy similares a los parámetros de un for normal, inicialización, condición de corte, porción de código a ejecutar. El mismo cuenta con parámetros opcionales para especificar la función de incremento y para recibir datos del contexto útiles si es necesario tener control de las demás iteraciones. Un caso en el cual necesitamos hacer uso de esa información seria para poder detener la ejecución ante un error o por algún otro motivo.

Para hacer que las implementaciones sean lo mas similares posibles, para simplificar la comparación entre ambas, se hace uso de un único contador para llevar el total de intersecciones. Al utilizar un único contador nos encontramos con el uso de una variable en forma concurrente por lo cual debemos proteger la sección del código en la cual la utilizamos. Para simplificar el código he utilizado un simple look el cual nos asegura que un solo hilo o proceso ejecutara ese código a la vez.

Comportamiento de los procesadores con Parallel Extensions:

image

En este caso podemos observar que el mismo algoritmo en primera instancia se ha ejecutado en un lapso menor de tiempo dado que ha utilizado de manera más optima los recursos disponibles. Ambos procesadores se encuentran al 100% del uso, cada uno atendiendo a un ciclo de la iteración por vez.

La utilización de parallel extensions nos permite administrar de mejor manera la ejecución de nuestro código, y es una muy buena práctica para la correcta utilización de los recursos disponibles.

En la mayoría de los casos, si un cliente compra un hardware más potente, pensando que la solución que le entregamos va a escalar verticalmente puede que no observe un mejor rendimiento del software. Si el hardware adquirido posee un microprocesador más rápido entonces se incrementara la velocidad de ejecución de nuestra solución, mientras que si adquiere un hardware cuyo procesador sea igualmente rápido pero con mayor cantidad de procesadores es posible que no observe un incremento real de la performance. Mediante la utilización de parallel extensions estamos asegurándonos que cada procesador va a ser utilizado y que el peso de nuestro programa va a ser correctamente distribuido y balanceado. De esta forma podemos decir que nuestro sistema esta preparado para correr en máquinas con varios procesadores y / o cores. Es importante destacar que si el hardware solamente cuenta con un procesador o un solo core entonces la ejecución mediante Parallel Extensions será idéntica a una ejecución con un for normal.

Si observamos el gráfico de recursos podemos observar que ahora si nuestro CPU se encuentra al máximo, dándonos las mayores prestaciones posibles. Si toda la ejecución pudiera hacer un uso total de nuestro CPU entonces no tendríamos tiempos muertos de procesamiento por lo cual nuestro programa sería realmente optimo en cuanto a la utilización de recursos.

Por supuesto que la solución pensada puede no ser la más óptima, pero acá estamos teniendo en cuenta que la implementación sea la más optima mas allá de la solución optada.

image 

Links

· Parallel Computing Developer Center

· Parallel LINQ

Saludos!

Max Deboli ~DreamThe