sábado, 22 de octubre de 2016

PlinQ 1



A forma de introducción, PLinQ, ofrece la capacidad de realizar consultas LinQ To Objects, de forma paralela, ósea utilizando todos los cores (núcleos) de el microprocesador. En un primer vistazo, podemos pensar que esto es magia, pero como veremos a continuación, no es algo que sea 100% recomendable en todos los casos, y puede llegar a tener una serie de consideraciones y fallos, que o bien no conocemos o no estamos acostumbrados en la programación síncrona.

Una de las ideas que solemos tener en nuestra cabeza acerca de la programación en paralelo, es que si poseemos una máquina con 4 cores o microprocesadores, un trabajo que tarda en hacerse 20 segundos, debería de tardar 20/4, solo 5 segundos. Esto no es cierto, hay algunos casos en que la ejecución sobre un único núcleo es más rápida que sobre n. La diferencia radica, en que para poder hacer una ejecución en paralelo es necesario particionar la información en trozos, para que cada uno de estos, sea tratado por un núcleo y luego volver a fusionar la información. Todo este trabajo, conlleva un sobrecoste, que en ocasiones no es conveniente.




Aquí dejo un link con toda la información sobre el sistema de partición de datos para proceso de PLinQ y en este, para ver la forma de procesado de una consulta en paralelo.


Recuerda que aquí tienes el indice de todos los posts del Curso de LinQ.


Antes de meternos en materia, me gustaría dejar una idea centralizada, clara y concisa de cuando se debe utilizar PLinQ, y no es otra que EN PROCESOS QUE CADA UNA DE SUS ITERACIONES CONLLEVE UN ALTO USO DEL PROCESADOR O UN Nº EXTREMADAMENTE ALTO DE ELEMENTOS. Esta reflexión la contemplaremos en más ocasiones dentro los 3 post que formarán esta tecnología de LinQ.


Ejemplo simple de consulta en paralelo.

Nada mejor que ver un pequeño ejemplo de PLinQ, para poder comprobar la extrema sencillez que acarrea el realizar una consulta en paralelo.

Me voy a intentar escapar un poco del típico ejemplo del método EsPrimo (que busca los números primos dentro de una secuencia) típico dentro del mundo de PLinQ, para hacerlo con una clase un poco más compleja.


Como siempre, la clase modelo del ejemplo primero:


public class MiClase
{
    public int Numero { get; set; }
    public long TotalSum { get; set; }

    public static long CalcularTotalSum(int number)
    {
        long result = 0;
 
        for (int i = 0; i < number; i++)
        {
            result += i;
        }
 
        return result;
    }
 
}


Todo, ‘la mar de fácil’. Una clase que guarda 2 propiedades, una para un número, y otra la suma de cada uno de los números tipo secuencia de Fibonacci

Aunque no sea lo más recomendable, he decidido incorporar a esta clase un método estático que realice el cálculo, que a efectos didácticos es más asequible de ver y corto de escribir.

Una pieza de esta clase, se montaría de este modo:


Numero = 8
TotalSum = 28 (1 + 2 + 3 + 4 + 5 + 6 + 7)


Vamos a ver un ejemplo de ejecución simple asíncrona:


static void Main(string[] args)
{
    Stopwatch reloj = new Stopwatch();
    reloj.Start();
    var items = Enumerable.Range(1, 80000)
                .Select(a => new MiClase { Numero = a, TotalSum = CalcularTotalSum(a) }).ToList();
 
    reloj.Stop();
    Console.WriteLine($"Ha tardado {reloj.ElapsedMilliseconds} milisegundos");
 
    Console.Read();
}

Hemos añadido la instanciación de un objeto Stopwatch (System.Diagnostics), para medir tiempos.


Si ejecutamos así la consulta, este sería el resultado:










Para realizar la ejecución en paralelo, solamente tendremos que llamar al método extensor de IEnumerable<T> AsParallel.


static void Main(string[] args)
{
    Stopwatch reloj = new Stopwatch();
    reloj.Start();
    var items = Enumerable.Range(1, 80000)
                .AsParallel()
                .Select(a => new MiClase { Numero = a, TotalSum = CalcularTotalSum(a) }).ToList();
 
    reloj.Stop();
    Console.WriteLine();
    Console.WriteLine($"    Ha tardado {reloj.ElapsedMilliseconds} milisegundos");
 
    Console.Read();
}


El resultado … mágico, ha tardado 4 veces menos en realizar el trabajo:










De hacer toda esta magia, se encarga la clase System.Linq.ParallelEnumerable que está definida en el namespace System.Core. En esta clase está implementada toda la funcionalidad de Parallel LinQ y en ella se exponen versiones para ejecución en paralelo de todos los operadores de consultas estándar: Select, Where, GroupBy, OrderBy, Skip, First, etc.


Cuando utilizamos el método AsParallel, transformamos la ejecución de las consultas de forma síncrona sobre un único núcleo en una consulta en paralelo multicore.

A modo informativo, mostraremos las firmas de sus métodos extensores, tiene 2 versiones una genérica y otra simple:


public static ParallelQuery<TSource> AsParallel<TSource>(this IEnumerable<TSource> source)
public static ParallelQuery          AsParallel         (this IEnumerable          source)

Si en algún momento necesitamos realizar el caso contrario y desparalelizar una consulta, existe el método extensor AsSequential, que actúa exactamente al contrario que AsParallel.



¿Qué hace .NET framework para realizar el paralelismo?

De una forma un poco simplificada, puntualizaremos los pasos que se dan cada vez que se paraleliza una consulta:

1.- Se analiza la consulta.
2.- Se particiona la secuencia.
3.- Se realiza la ejecución en Paralelo.

4.- Se vuelven  a unificar los datos particionados de resultado.


Análisis de la consulta

En este análisis, se procesa un algoritmo que teniendo en cuenta las características de la máquina en la que se ejecuta y la estructura de los datos sobre los que se va realizar la consulta, toma una decisión, intentando adelantarse al resultado y gestionando si es más beneficioso paralelizar la consulta o por lo contrario es más eficaz dejarla en ejecución simple.

Como veremos más adelante, mediante la llamada al método WithExecutionMode y la Enumeración ParallelExecutionMode, podremos forzar al motor a que en todos los casos realice la paralelización de la consulta.


Particionamiento de la secuencia

Una vez que ha decidido que la consulta se va a paralelizar, será necesario particionar la secuencia en grupos de datos, que puedan ser gestionados por cada uno de los cores de la máquina en paralelo.
El método en que se particiona la información, no es siempre el mismo y según el tipo datos de la colección, el tipo de acción, etc, se repartirá con un criterio diferente.

Ya que considero que este punto no es demasiado importante para el desenlace del post, dejo un enlace de la MSDN, en el que se explica de forma ampliada.


Ejecución en Paralelo

Una vez que ya están disponibles los grupos de datos (divididos) a tratar, estos se ejecutarán en paralelo, repartiéndose el trabajo entre los cores del microprocesador.


Unificación de Datos

Como paso final, una vez que todos los hilos han finalizado su trabajo, deben unirse para completar la secuencia de resultado. PLinQ, también permite manejar el modo de fusión por medio del método WithMergeOptions y la Enumeración ParallelMergeOptions, que también veremos más adelante en más profundidad, y que puede alterar también el rendimiento de la consulta.



AsOrdered

Como hemos visto anteriormente, dentro del plan de ejecución de una consulta en paralelo, hay una desmembración y fusión de datos. Dentro de este proceso, perdemos la garantía de que los datos tengan el orden inicial. El operador AsOrdered, nos avalá la ordenación original después de la ejecución en paralelo. Utilizándolo se pierde algo de rendimiento, pero suele ser una perdida mínima para la mejora y seguridad que ofrece.



public static ParallelQuery AsOrdered(this ParallelQuery source);


Como podemos ver su firma no recibe ningún parámetro (no confundir con el parámetro con la palabra reservada this, que marca el tipo a extender – Métodos Extensores -).

Vamos a añadir esta particularidad a nuestro ejemplo:


static void Main(string[] args)
{
    Stopwatch reloj = new Stopwatch();
    reloj.Start();
    var items = Enumerable.Range(1, 80000)
                .AsParallel().AsOrdered()
                .Select(a => new MiClase { Numero = a, TotalSum = MiClase.CalcularTotalSum(a) }).ToList();

    reloj.Stop();
    Console.WriteLine($"Ha tardado {reloj.ElapsedMilliseconds} milisegundos");

    Console.Read();
}


Al igual que en otras ocasiones, también existe un método para obligar a desordenar los datos, este es AsUnOrdered:


public static ParallelQuery<TSource> AsUnordered<TSource>(this ParallelQuery<TSource> source);




Parallel sobre colecciones pequeñas o livianas

El consejo de Microsoft en estos casos es no utilizar Parallel sobre secuencias de pocos elementos, con un uso ligero de procesamiento, ya que la simple comprobación del análisis inicial de la consulta, hace que la perdida de rendimiento sea de una cuantía mayor a la que acontecería de realizar la consulta de manera simple directamente ya que como hemos comentado con anterioridad casi con toda seguridad este análisis de cómo resultado ejecución simple.