Compartir a través de


Particionadores personalizados para PLINQ y TPL

Para paralelizar una operación en un origen de datos, uno de los pasos esenciales es crear particiones del origen en varias secciones a las que se tenga acceso simultáneamente a través de varios subprocesos. PLINQ y Task Parallel Library (TPL) proporcionan particionadores predeterminados que funcionan de forma transparente al escribir una consulta o bucle ForEach en paralelo. En escenarios más avanzados, puede conectar su propio particionador.

Creación de particiones diferentes

Hay muchas maneras de crear particiones de un origen de datos. En los enfoques más eficaces, varios subprocesos cooperan para procesar la secuencia original, en lugar de separar físicamente el origen en varias subsecuencias. Para matrices y otros orígenes indizados como colecciones IList donde de antemano se conoce la longitud, la creación de particiones por intervalos es la forma más simple de crear particiones. Cada subproceso recibe índices iniciales y finales únicos, para poder procesar el intervalo del origen sin sobrescribir ni ser sobrescrito por otro subproceso. La única sobrecarga implicada en la creación de particiones por intervalos es el trabajo inicial de crear los intervalos; ninguna sincronización adicional se requiere después de eso. Por consiguiente, puede proporcionar buen rendimiento siempre que la carga de trabajo se divida uniformemente. Una desventaja de la creación de particiones por intervalos es que si un subproceso finaliza pronto, no puede ayudar a los demás a finalizar el trabajo.

Con listas vinculadas u otras colecciones cuya longitud no se conoce, puede utilizar la creación de particiones por fragmentos. En la creación de particiones por fragmentos, cada subproceso o tarea de un bucle o consulta en paralelo utiliza un número de elementos de origen de un fragmento, los procesa y vuelve a recuperar más elementos. Los particionadores se aseguran de que se distribuyen todos los elementos y no hay ningún duplicado. Un fragmento puede tener cualquier tamaño. Por ejemplo, el particionador que se muestra en Cómo: Implementar las particiones dinámicas crea fragmentos que contienen un solo elemento. Con tal de que los fragmentos no sean demasiado grandes, esta forma de crear particiones mantiene inherentemente el equilibrio de carga porque la asignación de elementos a subprocesos no está predeterminada. Sin embargo, el particionador incurre en la sobrecarga de sincronización cada vez que el subproceso necesita obtener otro fragmento. La cantidad de sincronización en que se incurre en estos casos es inversamente proporcional al tamaño de los fragmentos.

En general, la creación de particiones por intervalos solo es más rápida cuando el tiempo de ejecución del delegado es de poco a moderado, el origen tiene un número grande de elementos y el trabajo total de cada partición es aproximadamente equivalente. La creación de particiones por fragmentos es, por consiguiente, más rápida en la mayoría de los casos. En orígenes con un número pequeño de elementos o tiempos de ejecución más largos para el delegado, el rendimiento de la creación de particiones por fragmentos e intervalos es casi igual.

Los particionadores de TPL también admiten un número de particiones dinámicas. Esto significa que pueden crear particiones sobre la marcha, por ejemplo, cuando el bucle ForEach genera una nueva tarea. Esta característica permite al particionador escalar junto con el propio bucle. Los particionadores dinámicos también mantienen inherentemente el equilibrio de carga. Cuando se crea un particionador personalizado, se debe admitir que la creación de particiones dinámicas se use desde un bucle ForEach.

Configurar particionadores de equilibrio de carga para PLINQ

Algunas sobrecargas del método Partitioner.Create permitían crear particionadores para una matriz u origen IList y especificar si debía intentar equilibrar la carga de trabajo entre los subprocesos. Cuando se configura el particionador para equilibrar la carga, se utiliza la creación de particiones por fragmentos y los elementos se presentan fuera de cada partición en pequeños fragmentos a medida que se solicitan. Este enfoque ayuda a asegurar que todas las particiones tienen elementos para procesar hasta que todo el bucle o la consulta se completa. Se puede utilizar una sobrecarga adicional para proporcionar la creación de particiones de equilibrio de carga de cualquier origen IEnumerable.

En general, el equilibrio de carga exige que las particiones soliciten elementos con relativa frecuencia de los particionadores. Por contraste, el particionador que crea particiones estáticas puede asignar todos los elementos a la vez mediante la creación de particiones por intervalos o por fragmentos. Esto requiere menos sobrecarga que el equilibrio de carga, pero podría llevar más mucho tiempo ejecutarse si un subproceso termina significativamente con más trabajo que los demás. De forma predeterminada cuando se pasa IList o una matriz, PLINQ siempre utiliza la creación de particiones por intervalos sin equilibrio de carga. Para habilitar el equilibrio de carga para PLINQ, use el método Partitioner.Create, como se muestra en el siguiente ejemplo.

        ' Static number of partitions requires indexable source.
        Dim nums = Enumerable.Range(0, 100000000).ToArray()

        ' Create a load-balancing partitioner. Or specify false For  Shared partitioning.
        Dim customPartitioner = Partitioner.Create(nums, True)

        ' The partitioner is the query's data source.
        Dim q = From x In customPartitioner.AsParallel()
                Select x * Math.PI

        q.ForAll(Sub(x) ProcessData(x))

            // Static partitioning requires indexable source. Load balancing
            // can use any IEnumerable.
            var nums = Enumerable.Range(0, 100000000).ToArray();

            // Create a load-balancing partitioner. Or specify false for static partitioning.
            Partitioner<int> customPartitioner = Partitioner.Create(nums, true);

            // The partitioner is the query's data source.
            var q = from x in customPartitioner.AsParallel()
                    select x * Math.PI;

            q.ForAll((x) =>
            {
                ProcessData(x);
            });

La mejor manera de determinar si utilizar el equilibrio de carga en un escenario determinado es experimentar y medir cuánto tiempo tardan las operaciones en completarse con cargas y configuraciones de equipo representativas. Por ejemplo, la creación de particiones estáticas podría proporcionar un aumento de velocidad significativo en un equipo multiproceso con pocos núcleos, pero podría ralentizar los equipos que tienen relativamente más núcleos.

En la tabla siguiente se muestran las sobrecargas disponibles del método Create. Estos particionadores no están limitados a su uso con PLINQ o ForEach. También se pueden utilizar con cualquier construcción paralela personalizada.

Sobrecarga

Utiliza el equilibrio de carga

Create<TSource>(IEnumerable<TSource>)

Siempre

Create<TSource>(TSource[], Boolean)

Cuando el argumento booleano se especifica como verdadero

Create<TSource>(IList<TSource>, Boolean)

Cuando el argumento booleano se especifica como verdadero

Create(Int32, Int32)

Nunca

Create(Int32, Int32, Int32)

Nunca

Create(Int64, Int64)

Nunca

Create(Int64, Int64, Int64)

Nunca

Configurar particionadores por intervalos estáticos para Parallel.ForEach

En un bucle For, el cuerpo del bucle se proporciona al método como un delegado. El costo de invocar ese delegado es más o menos similar a una llamada al método virtual. En algunos escenarios, el cuerpo de un bucle paralelo podría ser lo bastante pequeño como para que el costo de la invocación del delegado en cada iteración del bucle fuera significativa. En tales situaciones, puede utilizar una de las sobrecargas Create para crear una IEnumerable<T> de particiones por intervalos de los elementos de origen. Después puede pasar esta colección de intervalos a un método ForEach cuyo cuerpo está compuesto de un bucle for normal. La ventaja de este enfoque es que solo se incurre en el costo de invocación de delegados una vez por intervalo, en lugar de una vez por elemento. En el siguiente ejemplo se muestra el modelo básico.

Imports System.Threading.Tasks
Imports System.Collections.Concurrent

Module PartitionDemo

    Sub Main()
        ' Source must be array or IList.
        Dim source = Enumerable.Range(0, 100000).ToArray()

        ' Partition the entire source array. 
        ' Let the partitioner size the ranges.
        Dim rangePartitioner = Partitioner.Create(0, source.Length)

        Dim results(source.Length - 1) As Double

        ' Loop over the partitions in parallel. The Sub is invoked
        ' once per partition.
        Parallel.ForEach(rangePartitioner, Sub(range, loopState)

                                               ' Loop over each range element without a delegate invocation.
                                               For i As Integer = range.Item1 To range.Item2 - 1
                                                   results(i) = source(i) * Math.PI
                                               Next
                                           End Sub)
        Console.WriteLine("Operation complete. Print results? y/n")
        Dim input As Char = Console.ReadKey().KeyChar
        If input = "y"c Or input = "Y"c Then
            For Each d As Double In results
                Console.Write("{0} ", d)
            Next
        End If

    End Sub
End Module
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {

        // Source must be array or IList.
        var source = Enumerable.Range(0, 100000).ToArray();

        // Partition the entire source array.
        var rangePartitioner = Partitioner.Create(0, source.Length);

        double[] results = new double[source.Length];

        // Loop over the partitions in parallel.
        Parallel.ForEach(rangePartitioner, (range, loopState) =>
        {
            // Loop over each range element without a delegate invocation.
            for (int i = range.Item1; i < range.Item2; i++)
            {
                results[i] = source[i] * Math.PI;
            }
        });

        Console.WriteLine("Operation complete. Print results? y/n");
        char input = Console.ReadKey().KeyChar;
        if (input == 'y' || input == 'Y')
        {
            foreach(double d in results)
            {
                Console.Write("{0} ", d);
            }           
        }
    }
}

Cada subproceso del bucle recibe su propio Tuple<T1, T2> que contiene los valores de índice de inicio y de fin del subintervalo especificado. El bucle for interno utiliza los valores toExclusive y fromInclusive para recorrer directamente la matriz o IList.

Una de las sobrecargas Create permite especificar el tamaño y el número de las particiones. Esta sobrecarga se puede utilizar en escenarios donde el trabajo por elemento es tan bajo que incluso una llamada al método virtual por elemento tiene un impacto notable en el rendimiento.

Particionadores personalizados

En algunos escenarios, valdría la pena o incluso podría ser preciso implementar un particionador propio. Por ejemplo, podría tener una clase de colección personalizada que puede crear particiones más eficazmente que los particionadores predeterminados, basándose en su conocimiento de la estructura interna de la clase. O tal vez desee crear particiones por intervalos de tamaños diferentes basándose en su conocimiento de cuánto tiempo tardará en procesar los elementos en ubicaciones diferentes de la colección de origen.

Para crear un particionador personalizado básico, derive una clase de System.Collections.Concurrent.Partitioner<TSource> e invalide los métodos virtuales, tal y como se describe en la siguiente tabla.

GetPartitions

El subproceso principal llama a este método una vez y devuelve IList(IEnumerator(TSource)). Cada subproceso de trabajo del bucle o la consulta puede llamar a GetEnumerator en la lista para recuperar IEnumerator<T> de una partición distinta.

SupportsDynamicPartitions

Devuelve true si implementa GetDynamicPartitions, de lo contrario, false.

GetDynamicPartitions

Si SupportsDynamicPartitions es true, se puede llamar a este método opcionalmente en lugar de a GetPartitions.

Si los resultados deben ser ordenables o si necesita acceso indizado a los elementos, derive de System.Collections.Concurrent.OrderablePartitioner<TSource> e invalide sus métodos virtuales tal y como se describe en la siguiente tabla.

GetPartitions

El subproceso principal llama a este método una vez y devuelve IList(IEnumerator(TSource)). Cada subproceso de trabajo del bucle o la consulta puede llamar a GetEnumerator en la lista para recuperar IEnumerator<T> de una partición distinta.

SupportsDynamicPartitions

Devuelve true si implementa GetDynamicPartitions; de lo contrario, falso.

GetDynamicPartitions

Normalmente, solo llama a GetOrderableDynamicPartitions.

GetOrderableDynamicPartitions

Si SupportsDynamicPartitions es true, se puede llamar a este método opcionalmente en lugar de a GetPartitions.

En la siguiente tabla se proporcionan los detalles adicionales sobre cómo los tres tipos de particionadores del equilibrio de carga implementan la clase OrderablePartitioner<TSource>.

Propiedad o método

IList / matriz sin equilibrio de carga

IList / matriz con equilibrio de carga

IEnumerable

GetOrderablePartitions

Utiliza la creación de particiones por intervalos

Utiliza la creación de particiones por fragmentos optimizada para la partitionCount especificada

Utiliza la creación de particiones por fragmentos y crea un número de particiones estáticas.

OrderablePartitioner<TSource>.GetOrderableDynamicPartitions

Produce una excepción no admitida

Utiliza la creación de particiones por fragmentos optimizada para las listas y las particiones dinámicas

Utiliza la creación de particiones por fragmentos creando un número de particiones dinámico.

KeysOrderedInEachPartition

Devuelve true

Devuelve true

Devuelve true

KeysOrderedAcrossPartitions

Devuelve true

Devuelve false

Devuelve false

KeysNormalized

Devuelve true

Devuelve true

Devuelve true

SupportsDynamicPartitions

Devuelve false

Devuelve true

Devuelve true

Particiones dinámicas

Si piensa utilizar el particionador en un método ForEach, debe poder devolver un número de particiones dinámico. Esto significa que el particionador pueden proporcionar un enumerador para una nueva partición a petición en cualquier momento durante la ejecución del bucle. Básicamente, cada vez que el bucle agrega una nueva tarea en paralelo, solicita una nueva partición para esa tarea. Si exige que los datos se puedan ordenar, derive de System.Collections.Concurrent.OrderablePartitioner<TSource> para que cada elemento de cada partición tenga asignado un índice único.

Para obtener más información y un ejemplo, vea Cómo: Implementar las particiones dinámicas.

Contrato para particionadores

Cuando implemente un particionador personalizado, siga estas instrucciones para asegurarse de que la interacción es correcta con PLINQ y ForEach en TPL:

  • Si se llama a GetPartitions con un argumento de cero o menos para partitionsCount, se produce una ArgumentOutOfRangeException. Aunque PLINQ y TPL nunca pasarán en una partitionCount igual a 0, recomendamos, no obstante, que se proteja ante esa posibilidad.

  • GetPartitions y GetOrderablePartitions siempre deberían devolver el número de particiones partitionsCount. Si particionador se ejecuta fuera de los datos y no puede crear tantas particiones como se solicitan, el método debería devolver un enumerador vacío para cada una de las particiones restantes. De lo contrario, PLINQ y TPL producirán una InvalidOperationException.

  • GetPartitions, GetOrderablePartitions, GetDynamicPartitions y GetOrderableDynamicPartitions nunca deberían devolver null (Nothing en Visual Basic). Si lo hacen, PLINQ / TPL producirán una excepción InvalidOperationException.

  • Los métodos que devuelven particiones siempre deberían devolver particiones que puedan enumerar completamente y de forma única el origen de datos. No debería haber ninguna duplicación en el origen de datos ni elementos omitidos a menos que lo requiera específicamente el particionador. Si no se sigue esta regla, se puede alterar el orden del resultado.

  • Los siguientes captadores get booleanos siempre deben devolver con precisión los siguientes valores para que no se altere el orden de salida:

    • KeysOrderedInEachPartition: cada partición devuelve los elementos con índices de clave en aumento.

    • KeysOrderedAcrossPartitions: para todas las particiones que se devuelven, los índices de la clave de la partición i son más altos que los índices de la clave en la partición i-1.

    • KeysNormalized: todos los índices de clave aumentan consecutivamente, comenzando por cero.

  • Todos los índices deben ser únicos. No puede haber índices duplicados. Si no se sigue esta regla, se puede alterar el orden del resultado.

  • Todos los índices deben ser no negativos. Si no se sigue esta regla, PLINQ/TPL pueden producir excepciones.

Vea también

Tareas

Cómo: Implementar las particiones dinámicas

Cómo: Implementar un particionador con un número estático de particiones

Conceptos

Programación paralela en .NET Framework