web-dev-qa-db-fra.com

Gagnez du temps avec la boucle FOR parallèle

J'ai une question concernant le parallèle pour les boucles. J'ai le code suivant:

    public static void MultiplicateArray(double[] array, double factor)
    {
        for (int i = 0; i < array.Length; i++)
        {
            array[i] = array[i] * factor;
        }
    }

    public static void MultiplicateArray(double[] arrayToChange, double[] multiplication)
    {
        for (int i = 0; i < arrayToChange.Length; i++)
        {
            arrayToChange[i] = arrayToChange[i] * multiplication[i];
        }
    }

    public static void MultiplicateArray(double[] arrayToChange, double[,] multiArray, int dimension)
    {
        for (int i = 0; i < arrayToChange.Length; i++)
        {
            arrayToChange[i] = arrayToChange[i] * multiArray[i, dimension];
        }
    }

Maintenant, j'essaie d'ajouter une fonction parallèle:

    public static void MultiplicateArray(double[] array, double factor)
    {
        Parallel.For(0, array.Length, i =>
            {
                array[i] = array[i] * factor;
            });
    }

    public static void MultiplicateArray(double[] arrayToChange, double[] multiplication)
    {
        Parallel.For(0, arrayToChange.Length, i =>
        {
            arrayToChange[i] = arrayToChange[i] * multiplication[i];
        });
    }

    public static void MultiplicateArray(double[] arrayToChange, double[,] multiArray, int dimension)
    {
        Parallel.For(0, arrayToChange.Length, i =>
        {
            arrayToChange[i] = arrayToChange[i] * multiArray[i, dimension];
        });
    }

Le problème est que je veux gagner du temps, ne pas le perdre. Avec la boucle standard, il calcule environ 2 minutes, mais avec la boucle parallèle, cela prend 3 minutes. Pourquoi?

44
tro

Parallel.For() peut améliorer considérablement les performances en parallélisant votre code, mais il a également une surcharge (synchronisation entre les threads, appel du délégué à chaque itération). Et puisque dans votre code, chaque itération est très courte (en gros, juste quelques instructions CPU), cette surcharge peut devenir importante.

Pour cette raison, je pensais que l'utilisation de Parallel.For() n'était pas la bonne solution pour vous. Au lieu de cela, si vous parallélisez votre code manuellement (ce qui est très simple dans ce cas), vous pouvez voir les performances s'améliorer.

Pour vérifier cela, j'ai effectué quelques mesures: j'ai exécuté différentes implémentations de MultiplicateArray() sur un tableau de 200 000 000 éléments (le code que j'ai utilisé est ci-dessous). Sur ma machine, la version série prenait systématiquement 0,21 s et Parallel.For() prenait généralement environ 0,45 s, mais de temps en temps, elle atteignait 8 à 9 s!

Tout d'abord, je vais essayer d'améliorer le cas commun et je reviendrai plus tard sur ces pointes. Nous voulons traiter le tableau par N CPU, nous le divisons donc en N pièces de taille égale et traiter chaque pièce séparément. Le résultat? 0,35 s. C'est encore pire que la version série. Mais for boucle sur chaque élément d'un tableau est l'une des constructions les plus optimisées. Ne pouvons-nous pas faire quelque chose pour aider le compilateur? Extraire le calcul de la limite de la boucle pourrait aider. Il s'avère que oui: 0,18 s. C'est mieux que la version série, mais pas beaucoup. Et, fait intéressant, changer le degré de parallélisme de 4 à 2 sur ma machine à 4 cœurs (pas d'HyperThreading) ne change pas le résultat: toujours 0,18 s. Cela me fait conclure que le CPU n'est pas le goulot d'étranglement ici, la bande passante mémoire l'est.

Maintenant, revenons aux pointes: ma parallélisation personnalisée n'en a pas, mais Parallel.For() oui, pourquoi? Parallel.For() utilise un partitionnement de plage, ce qui signifie que chaque thread traite sa propre partie du tableau. Mais, si un thread se termine tôt, il essaiera d'aider à traiter la plage d'un autre thread qui n'est pas encore terminé. Si cela se produit, vous obtiendrez beaucoup de faux partages, ce qui pourrait ralentir beaucoup le code. Et mon propre test pour forcer le faux partage semble indiquer que cela pourrait effectivement être le problème. Forcer le degré de parallélisme de la Parallel.For() semble aider un peu avec les pointes.

Bien sûr, toutes ces mesures sont spécifiques au matériel de mon ordinateur et seront différentes pour vous, vous devez donc faire vos propres mesures.

Le code que j'ai utilisé:

static void Main()
{
    double[] array = new double[200 * 1000 * 1000];

    for (int i = 0; i < array.Length; i++)
        array[i] = 1;

    for (int i = 0; i < 5; i++)
    {
        Stopwatch sw = Stopwatch.StartNew();
        Serial(array, 2);
        Console.WriteLine("Serial: {0:f2} s", sw.Elapsed.TotalSeconds);

        sw = Stopwatch.StartNew();
        ParallelFor(array, 2);
        Console.WriteLine("Parallel.For: {0:f2} s", sw.Elapsed.TotalSeconds);

        sw = Stopwatch.StartNew();
        ParallelForDegreeOfParallelism(array, 2);
        Console.WriteLine("Parallel.For (degree of parallelism): {0:f2} s", sw.Elapsed.TotalSeconds);

        sw = Stopwatch.StartNew();
        CustomParallel(array, 2);
        Console.WriteLine("Custom parallel: {0:f2} s", sw.Elapsed.TotalSeconds);

        sw = Stopwatch.StartNew();
        CustomParallelExtractedMax(array, 2);
        Console.WriteLine("Custom parallel (extracted max): {0:f2} s", sw.Elapsed.TotalSeconds);

        sw = Stopwatch.StartNew();
        CustomParallelExtractedMaxHalfParallelism(array, 2);
        Console.WriteLine("Custom parallel (extracted max, half parallelism): {0:f2} s", sw.Elapsed.TotalSeconds);

        sw = Stopwatch.StartNew();
        CustomParallelFalseSharing(array, 2);
        Console.WriteLine("Custom parallel (false sharing): {0:f2} s", sw.Elapsed.TotalSeconds);
    }
}

static void Serial(double[] array, double factor)
{
    for (int i = 0; i < array.Length; i++)
    {
        array[i] = array[i] * factor;
    }
}

static void ParallelFor(double[] array, double factor)
{
    Parallel.For(
        0, array.Length, i => { array[i] = array[i] * factor; });
}

static void ParallelForDegreeOfParallelism(double[] array, double factor)
{
    Parallel.For(
        0, array.Length, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
        i => { array[i] = array[i] * factor; });
}

static void CustomParallel(double[] array, double factor)
{
    var degreeOfParallelism = Environment.ProcessorCount;

    var tasks = new Task[degreeOfParallelism];

    for (int taskNumber = 0; taskNumber < degreeOfParallelism; taskNumber++)
    {
        // capturing taskNumber in lambda wouldn't work correctly
        int taskNumberCopy = taskNumber;

        tasks[taskNumber] = Task.Factory.StartNew(
            () =>
            {
                for (int i = array.Length * taskNumberCopy / degreeOfParallelism;
                    i < array.Length * (taskNumberCopy + 1) / degreeOfParallelism;
                    i++)
                {
                    array[i] = array[i] * factor;
                }
            });
    }

    Task.WaitAll(tasks);
}

static void CustomParallelExtractedMax(double[] array, double factor)
{
    var degreeOfParallelism = Environment.ProcessorCount;

    var tasks = new Task[degreeOfParallelism];

    for (int taskNumber = 0; taskNumber < degreeOfParallelism; taskNumber++)
    {
        // capturing taskNumber in lambda wouldn't work correctly
        int taskNumberCopy = taskNumber;

        tasks[taskNumber] = Task.Factory.StartNew(
            () =>
            {
                var max = array.Length * (taskNumberCopy + 1) / degreeOfParallelism;
                for (int i = array.Length * taskNumberCopy / degreeOfParallelism;
                    i < max;
                    i++)
                {
                    array[i] = array[i] * factor;
                }
            });
    }

    Task.WaitAll(tasks);
}

static void CustomParallelExtractedMaxHalfParallelism(double[] array, double factor)
{
    var degreeOfParallelism = Environment.ProcessorCount / 2;

    var tasks = new Task[degreeOfParallelism];

    for (int taskNumber = 0; taskNumber < degreeOfParallelism; taskNumber++)
    {
        // capturing taskNumber in lambda wouldn't work correctly
        int taskNumberCopy = taskNumber;

        tasks[taskNumber] = Task.Factory.StartNew(
            () =>
            {
                var max = array.Length * (taskNumberCopy + 1) / degreeOfParallelism;
                for (int i = array.Length * taskNumberCopy / degreeOfParallelism;
                    i < max;
                    i++)
                {
                    array[i] = array[i] * factor;
                }
            });
    }

    Task.WaitAll(tasks);
}

static void CustomParallelFalseSharing(double[] array, double factor)
{
    var degreeOfParallelism = Environment.ProcessorCount;

    var tasks = new Task[degreeOfParallelism];

    int i = -1;

    for (int taskNumber = 0; taskNumber < degreeOfParallelism; taskNumber++)
    {
        tasks[taskNumber] = Task.Factory.StartNew(
            () =>
            {
                int j = Interlocked.Increment(ref i);
                while (j < array.Length)
                {
                    array[j] = array[j] * factor;
                    j = Interlocked.Increment(ref i);
                }
            });
    }

    Task.WaitAll(tasks);
}

Exemple de sortie:

Serial: 0,20 s
Parallel.For: 0,50 s
Parallel.For (degree of parallelism): 8,90 s
Custom parallel: 0,33 s
Custom parallel (extracted max): 0,18 s
Custom parallel (extracted max, half parallelism): 0,18 s
Custom parallel (false sharing): 7,53 s
Serial: 0,21 s
Parallel.For: 0,52 s
Parallel.For (degree of parallelism): 0,36 s
Custom parallel: 0,31 s
Custom parallel (extracted max): 0,18 s
Custom parallel (extracted max, half parallelism): 0,19 s
Custom parallel (false sharing): 7,59 s
Serial: 0,21 s
Parallel.For: 11,21 s
Parallel.For (degree of parallelism): 0,36 s
Custom parallel: 0,32 s
Custom parallel (extracted max): 0,18 s
Custom parallel (extracted max, half parallelism): 0,18 s
Custom parallel (false sharing): 7,76 s
Serial: 0,21 s
Parallel.For: 0,46 s
Parallel.For (degree of parallelism): 0,35 s
Custom parallel: 0,31 s
Custom parallel (extracted max): 0,18 s
Custom parallel (extracted max, half parallelism): 0,18 s
Custom parallel (false sharing): 7,58 s
Serial: 0,21 s
Parallel.For: 0,45 s
Parallel.For (degree of parallelism): 0,40 s
Custom parallel: 0,38 s
Custom parallel (extracted max): 0,18 s
Custom parallel (extracted max, half parallelism): 0,18 s
Custom parallel (false sharing): 7,58 s
60
svick

Svick a déjà fourni une excellente réponse mais je tiens à souligner que le point clé n'est pas de "paralléliser votre code manuellement" au lieu d'utiliser Parallel.For() mais que vous devez traiter de plus gros morceaux de données.

Cela peut toujours être fait en utilisant Parallel.For() comme ceci:

static void My(double[] array, double factor)
{
    int degreeOfParallelism = Environment.ProcessorCount;

    Parallel.For(0, degreeOfParallelism, workerId =>
    {
        var max = array.Length * (workerId + 1) / degreeOfParallelism;
        for (int i = array.Length * workerId / degreeOfParallelism; i < max; i++)
            array[i] = array[i] * factor;
    });
}

qui fait la même chose que svicks CustomParallelExtractedMax() mais est plus court, plus simple et (sur ma machine) fonctionne encore un peu plus vite:

Serial: 3,94 s
Parallel.For: 9,28 s
Parallel.For (degree of parallelism): 9,58 s
Custom parallel: 2,05 s
Custom parallel (extracted max): 1,19 s
Custom parallel (extracted max, half parallelism): 1,49 s
Custom parallel (false sharing): 27,88 s
My: 0,95 s

Btw, le mot-clé qui manque à toutes les autres réponses est granularité.

17
Roman Reiner

Voir Partitionneurs personnalisés pour PLINQ et TPL :

Dans une boucle For, le corps de la boucle est fourni à la méthode en tant que délégué. Le coût de l'appel de ce délégué est à peu près le même qu'un appel de méthode virtuelle. Dans certains scénarios, le corps d'une boucle parallèle peut être suffisamment petit pour que le coût de l'appel de délégué à chaque itération de boucle devienne significatif. Dans de telles situations, vous pouvez utiliser l'une des surcharges Create pour créer un IEnumerable<T> des partitions de plage sur les éléments source. Ensuite, vous pouvez passer cette collection de plages à une méthode ForEach dont le corps consiste en une boucle for régulière. L'avantage de cette approche est que le coût d'invocation des délégués n'est engagé qu'une fois par plage, plutôt qu'une fois par élément.

Dans votre corps de boucle, vous effectuez une seule multiplication et la surcharge de l'appel délégué sera très perceptible.

Essaye ça:

public static void MultiplicateArray(double[] array, double factor)
{
    var rangePartitioner = Partitioner.Create(0, array.Length);

    Parallel.ForEach(rangePartitioner, range =>
    {
        for (int i = range.Item1; i < range.Item2; i++)
        {
            array[i] = array[i] * factor;
        }
    });
}

Voir également: Parallel.ForEach documentation et Partitioner.Create documentation .

7
Kris Vandermotten

Parallel.For implique une gestion de la mémoire plus complexe. Ce résultat peut varier en fonction des spécifications du processeur, comme #cores, cache L1 et L2 ...

Veuillez consulter cet article intéressant:

http://msdn.Microsoft.com/en-us/magazine/cc872851.aspx

6
Jordi