[C#] Efectos de AsParallel()

Durante la pasada DotNetConference  y después de la charla sobre Async Best Practices de Lluis Franco vino Rodrigo con una serie de curiosidades de los efectos de AsParallel() sobre una colección. Y de esas curiosidades, nace este post.

El ejemplo que comentamos es sencillo. Se trata de calcular cuántos números primos hay de 1 hasta N. Para ello he usado una función sencilla para calcular si un número es primo o no:


static bool IsPrime(int candidate)
{
	if ((candidate & 1) == 0)
	{
		if (candidate == 2)
		{
			return true;
		}
		else
		{
			return false;
		}
	}
	for (int i = 3; (i * i) <= candidate; i += 2)
	{
		if ((candidate % i) == 0)
		{
			return false;
		}
	}
	return candidate != 1;
}

Y he creado una lista de enteros hasta N:


var list = Enumerable.Range(1, 10000000);

Quedando la consulta sobre LINQ de la siguiente manera:


var onlyPrimes = list.Where(i => IsPrime(i))
		     .Count();

Evidentemente esto tiene un coste. Para pocos elementos es prácticamente despreciable, pero si generamos una cierta cantidad de elementos a partir de 1 millón vemos como este se dispara:

image

Una solución para prevenir este tipo de casos es usar AsParallel(). Este método permite distribuir el contenido de un IEnumerable para que se habilite el procesamiento en paralelo. Es decir, cambiando la sentencia LINQ original por esta obtendremos del tiempo actual de 7,3 segundos a 2,5 para el último caso:

var onlyPrimesParallel = list
	.AsParallel()
	.Where(i => IsPrime(i))
	.Count();

Y juntando los dos tiempos, la gráfica quedaría tal que:

image

¿Qué efectos produce AsParallel?

Para verlo, usaremos una extensión de Visual Studio que se llama Concurrency Visualizer. Es parecido a los ya conocidos reportes de rendimiento, diagnósticos y análisis sólo que está destinado a mostrar cómo se distribuyen los hilos sobre la memoria y CPU de la máquina. Podemos asociarlo al proyecto en ejecución o asignarle cualquier programa que esté en ejecución.

En el primer caso, que es secuencial, esperamos ver que el hilo de la aplicación se ejecuta en mayor medida sobre uno de los núcleos de nuestro sistema:

image

Mientras que si vemos el resultado de la opción en ejecución en paralelo obtenemos lo siguiente:

image

Se puede apreciar fácilmente que hay mucha carga y más procesos que están distribuidos a lo largo de los núcleos.

¿Debemos usar AsParallel?

Bien, antes de responder la pregunta hay que aclarar una serie de puntos. AsParallel no es gratuito. La paralelización no es gratuita. Es costosa. Y Mucho. Para una CPU que no paralaleliza no hay mayor problema, porque entra un proceso y este ocupará el tiempo de la CPU sin importar nada más. Cuando termine devolverá su trabajo y la CPU podrá seguir con otra cosa. Cuando se paraleliza, al coste de las operaciones que se estén haciendo, hay que añadirle el peaje de paralelización. Este peaje es el coste que tiene para el núcleo y el sistema sincronizarse con sus semejantes. Veamos más información del uso de los núcleos en el caso paralelo:

image

El cambio de contexto es el “peaje” que comenté anteriormente. Supone el proceso que se debe llevar para que los núcleos de la CPU se sincronicen para ejecutar la tarea de forma paralela. Esto implica que en cada cambio de contexto, los registros de los procesadores son guardados y cargados, el kernel del sistema operativo se ejecutará, la TLB (Translation Lookaside Buffer) se recargará y las etapas de instrucciones del procesador finalizará. Es decir, se fuerza prácticamente a que ejecute nuevo código pero con registros ya cargados para que continúe por donde lo dejó la otra etapa paralela. Y le podemos añadir por si fuera poco, que puede ser todavía más costoso para el núcleo si la caché no es válida para el hilo actual. El escenario ideal sería que se paralelice en la justa medida para que todos los núcleos puedan reusar la caché que ya tienen.

Aparecen tres métricas en el reporte:

  • Cross Core Context Switches:  Número de veces que un hilo ha cambiado de un núcleo lógico a otro.
  • Total Context Switches: Número de veces que se cambia el contexto (de ejecución a sincronización, etc).
  • Percent of Content Switches: Las dos métricas anteriores divididas (CrossCore / Total Context). Cuando más alto sea el porcentaje, mayor es la actividad del núcleo y del hilo en particular.

Ahora ya podemos responder a la pregunta. Sabemos que es costoso, porque implica un coste asociado por hilo al paralelizar. Así que:

¿Debemos usarlo? Depende. Hay que medir qué aporta y que no aporta. En la segunda gráfica se aprecia que el coste es algo superior al secuencial, pero a medida que crece la lista el coste del paralelo tiene una pendiente con menor inclinación que la secuencial. Otro punto es ver qué hacemos con la paralelización. Si el resultado implica recoger los resultados del proceso, será más caro –como es el ejemplo actual- Si sólo queremos aplicar una operación (cómo hacer una petición HTTP) será más barato. En cualquier caso, hay que medir y probar.

¿Puedo colocar AsParallel donde quiera? Sí, por supuesto. Pero evidentemente eso no significa que funcione como crees que debería funcionar. En la sentencia LINQ actual, AsParallel() está justo antes del WHERE() porque lo que queremos es que distribuya los N elementos de la lista en los núcleos de la CPU y que una vez distribuidos, ejecute el WHERE(). Esto ha sido porque hemos asumido que lo que ejecuta el WHERE() es atómico y costoso. Si por el contrario colocamos AsParallel() después, provocaremos esto:

image

Sí, efectivamente usa todos los núcleos disponibles. También tarda casi más de 3 segundos que la forma secuencial. Lo que hemos provocado aquí es:

  • La ejecución de calcular un número primo es puramente secuencial. Es decir, tenemos una lista de N elementos y para cada uno de ellos vamos a determinar es primo o no. Puramente secuencial.
  • Cuando tengamos todo el resultado, que habrá devuelvo una cantidad X<N de elementos (aquellos que sean primos) aplicamos el AsParallel(). Y la siguiente operación es un COUNT() de forma paralela, por lo que el sistema va a distribuir los X elementos entre los núcleos para poder contarlos y finalmente sumarlos para obtener el resultado final. ¿Es paralelo? Por supuesto, pero de forma incorrecta porque hemos hecho el cálculo de los primos en secuencial y le hemos añadido el sobrecoste de la paralelización para obtener el COUNT. Y además se puede apreciar como el número de cambios de contexto es bastante mayor que la versión paralela correcta, por lo que en lugar de mejorar el rendimiento, lo hemos empeorado con creces respecto a la versión secuencial.

BONUS: DNX

Probando el código de AsParallel() con DNX 4.5.1 (el DNX Core que tengo instalado no soporta AsParallel por el momento) he obtenido un tiempo máximo de 2,2 segundos (0,3 menor que la media –que no máximo-) del ejemplo anterior. Cuanto menos curioso…

Puedes encontrar el código que he usado en GitHub

7 comentarios sobre “[C#] Efectos de AsParallel()”

  1. Muy buen post Andrés, felicidades! 🙂
    Si que recuerdo que después de la charla me comentó Rodrigo que le había producido ciertas ‘inquietudes’, jeje… me alegro.
    Me gustaría ver el efecto que produce usar un grado de paralelización (WithDegreeOfParallelism) en esas métricas. Por ejemplo, el número de cores / 2… voy a ver si saco un ratillo.
    Repito: Buen post! 😉

  2. Genial el post que te has currado de nuestra conversación!!!! Muy interesante ver organizadas las ideas que estuvimos comentando.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *