web-dev-qa-db-fra.com

Dois-je toujours utiliser Parallel.Foreach car plus de threads DOIVENT accélérer tout?

Cela vous semble-t-il judicieux d'utiliser pour chaque boucle normale foreach une boucle parallel.foreach?

Quand dois-je commencer à utiliser parallel.foreach, en itérant seulement 1 000 000 d'articles?

45
Elisabeth

Non, cela n'a pas de sens pour chaque foreach. Certaines raisons:

  • Votre code peut ne pas en fait être parallélisable. Par exemple, si vous utilisez les "résultats jusqu'à présent" pour la prochaine itération et que l'ordre est important)
  • Si vous agrégez (par exemple, en additionnant des valeurs), il existe des moyens d'utiliser Parallel.ForEach pour cela, mais vous ne devriez pas le faire aveuglément
  • Si votre travail se termine de toute façon très rapidement, il n'y a aucun avantage, et cela pourrait bien ralentir les choses

Fondamentalement rien dans le filetage doit être fait à l'aveugle. Pensez à l'endroit où cela fait réellement sens pour paralléliser. Oh, et mesurez l'impact pour vous assurer que l'avantage vaut la complexité supplémentaire. (Il sera être plus difficile pour des choses comme le débogage.) TPL est génial, mais ce n'est pas un déjeuner gratuit.

73
Jon Skeet

Non, tu ne devrais certainement pas faire ça. Le point important ici n'est pas vraiment le nombre d'itérations, mais le travail à faire. Si votre travail est vraiment simple, exécuter 1000000 délégués en parallèle ajoutera un énorme surcoût et sera probablement plus lent qu'une solution traditionnelle à un seul thread. Vous pouvez contourner ce problème en partitionnant les données, vous exécutez donc des morceaux de travail à la place.

Par exemple. considérez la situation ci-dessous:

Input = Enumerable.Range(1, Count).ToArray();
Result = new double[Count];

Parallel.ForEach(Input, (value, loopState, index) => { Result[index] = value*Math.PI; });

L'opération ici est si simple, que la surcharge de faire cela en parallèle éclipsera le gain d'utilisation de plusieurs cœurs. Ce code s'exécute beaucoup plus lentement qu'une boucle foreach standard.

En utilisant une partition, nous pouvons réduire la surcharge et effectivement observer un gain de performances.

Parallel.ForEach(Partitioner.Create(0, Input.Length), range => {
   for (var index = range.Item1; index < range.Item2; index++) {
      Result[index] = Input[index]*Math.PI;
   }
});

Le moral de l'histoire ici est que le parallélisme est difficile et vous ne devriez utiliser cela qu'après avoir examiné de près la situation actuelle. En outre, vous devez profiler le code avant et après l'ajout du parallélisme.

N'oubliez pas que, indépendamment de tout gain de performances potentiel, le parallélisme ajoute toujours de la complexité au code, donc si les performances sont déjà suffisamment bonnes, il n'y a pas de raison d'ajouter de la complexité.

19
Brian Rasmussen

La réponse courte est non , vous ne devez pas simplement utiliser Parallel.ForEach ou des constructions associées sur chaque boucle que vous pouvez. Parallel a une surcharge, ce qui n'est pas justifié dans les boucles avec peu d'itérations rapides. De plus, break est beaucoup plus complexe à l'intérieur de ces boucles.

Parallel.ForEach est une demande de planification de la boucle comme le planificateur de tâches le juge approprié, en fonction du nombre d'itérations dans la boucle, du nombre de cœurs de processeur sur le matériel et de la charge actuelle sur ce matériel. L'exécution parallèle réelle n'est pas toujours garantie et est moins probable s'il y a moins de cœurs, le nombre d'itérations est faible et/ou la charge actuelle est élevée.

Voir aussi Parallel.ForEach limite-t-il le nombre de threads actifs? et Parallel.For utilise-t-il une tâche par itération?

La réponse longue:

On peut classer les boucles par leur façon de tomber sur deux axes:

  1. Peu d'itérations jusqu'à plusieurs itérations.
  2. Chaque itération est rapide jusqu'à chaque itération est lente.

Un troisième facteur est que si la durée des tâches varie beaucoup - par exemple, si vous calculez des points sur l'ensemble de Mandelbrot, certains points sont rapides à calculer, d'autres prennent beaucoup plus de temps.

Lorsqu'il y a peu d'itérations rapides, cela ne vaut probablement pas la peine d'utiliser la parallélisation de quelque manière que ce soit, très probablement cela finira plus lentement en raison des frais généraux. Même si la parallélisation accélère une petite boucle particulière et rapide, il est peu probable qu'elle soit intéressante: les gains seront faibles et ce n'est pas un goulot d'étranglement dans les performances de votre application, alors optimisez la lisibilité et non les performances.

Lorsqu'une boucle a très peu d'itérations lentes et que vous souhaitez plus de contrôle, vous pouvez envisager d'utiliser des tâches pour les gérer, comme suit:

var tasks = new List<Task>(actions.Length); 
foreach(var action in actions) 
{ 
    tasks.Add(Task.Factory.StartNew(action)); 
} 
Task.WaitAll(tasks.ToArray());

Lorsqu'il existe de nombreuses itérations, Parallel.ForEach est dans son élément.

Le documentation Microsoft indique que

Lorsqu'une boucle parallèle s'exécute, le TPL partitionne la source de données afin que la boucle puisse fonctionner simultanément sur plusieurs parties. En arrière-plan, le Planificateur de tâches partitionne la tâche en fonction des ressources système et de la charge de travail. Lorsque cela est possible, le planificateur redistribue le travail entre plusieurs threads et processeurs si la charge de travail devient déséquilibrée.

Ce partitionnement et cette reprogrammation dynamique seront plus difficiles à faire efficacement à mesure que le nombre d'itérations de boucle diminuera, et est plus nécessaire si les itérations varient en durée et en présence d'autres tâches s'exécutant sur la même machine.

J'ai exécuté du code.

Les résultats des tests ci-dessous montrent une machine avec rien d'autre en cours d'exécution et aucun autre thread du pool de threads .Net en cours d'utilisation. Ce n'est pas typique (en fait, dans un scénario de serveur Web, c'est extrêmement irréaliste). En pratique, il se peut que vous ne voyiez aucune parallélisation avec un petit nombre d'itérations.

Le code de test est:

namespace ParallelTests 
{ 
    class Program 
    { 
        private static int Fibonacci(int x) 
        { 
            if (x <= 1) 
            { 
                return 1; 
            } 
            return Fibonacci(x - 1) + Fibonacci(x - 2); 
        } 

        private static void DummyWork() 
        { 
            var result = Fibonacci(10); 
            // inspect the result so it is no optimised away. 
            // We know that the exception is never thrown. The compiler does not. 
            if (result > 300) 
            { 
                throw new Exception("failed to to it"); 
            } 
        } 

        private const int TotalWorkItems = 2000000; 

        private static void SerialWork(int outerWorkItems) 
        { 
            int innerLoopLimit = TotalWorkItems / outerWorkItems; 
            for (int index1 = 0; index1 < outerWorkItems; index1++) 
            { 
                InnerLoop(innerLoopLimit); 
            } 
        } 

        private static void InnerLoop(int innerLoopLimit) 
        { 
            for (int index2 = 0; index2 < innerLoopLimit; index2++) 
            { 
                DummyWork(); 
            } 
        } 

        private static void ParallelWork(int outerWorkItems) 
        { 
            int innerLoopLimit = TotalWorkItems / outerWorkItems; 
            var outerRange = Enumerable.Range(0, outerWorkItems); 
            Parallel.ForEach(outerRange, index1 => 
            { 
                InnerLoop(innerLoopLimit); 
            }); 
        } 

        private static void TimeOperation(string desc, Action operation) 
        { 
            Stopwatch timer = new Stopwatch(); 
            timer.Start(); 
            operation(); 
            timer.Stop(); 

            string message = string.Format("{0} took {1:mm}:{1:ss}.{1:ff}", desc, timer.Elapsed); 
            Console.WriteLine(message); 
        } 

        static void Main(string[] args) 
        { 
            TimeOperation("serial work: 1", () => Program.SerialWork(1)); 
            TimeOperation("serial work: 2", () => Program.SerialWork(2)); 
            TimeOperation("serial work: 3", () => Program.SerialWork(3)); 
            TimeOperation("serial work: 4", () => Program.SerialWork(4)); 
            TimeOperation("serial work: 8", () => Program.SerialWork(8)); 
            TimeOperation("serial work: 16", () => Program.SerialWork(16)); 
            TimeOperation("serial work: 32", () => Program.SerialWork(32)); 
            TimeOperation("serial work: 1k", () => Program.SerialWork(1000)); 
            TimeOperation("serial work: 10k", () => Program.SerialWork(10000)); 
            TimeOperation("serial work: 100k", () => Program.SerialWork(100000)); 

            TimeOperation("parallel work: 1", () => Program.ParallelWork(1)); 
            TimeOperation("parallel work: 2", () => Program.ParallelWork(2)); 
            TimeOperation("parallel work: 3", () => Program.ParallelWork(3)); 
            TimeOperation("parallel work: 4", () => Program.ParallelWork(4)); 
            TimeOperation("parallel work: 8", () => Program.ParallelWork(8)); 
            TimeOperation("parallel work: 16", () => Program.ParallelWork(16)); 
            TimeOperation("parallel work: 32", () => Program.ParallelWork(32)); 
            TimeOperation("parallel work: 64", () => Program.ParallelWork(64)); 
            TimeOperation("parallel work: 1k", () => Program.ParallelWork(1000)); 
            TimeOperation("parallel work: 10k", () => Program.ParallelWork(10000)); 
            TimeOperation("parallel work: 100k", () => Program.ParallelWork(100000)); 

            Console.WriteLine("done"); 
            Console.ReadLine(); 
        } 
    } 
} 

les résultats sur une machine Windows 7 à 4 cœurs sont:

serial work: 1 took 00:02.31 
serial work: 2 took 00:02.27 
serial work: 3 took 00:02.28 
serial work: 4 took 00:02.28 
serial work: 8 took 00:02.28 
serial work: 16 took 00:02.27 
serial work: 32 took 00:02.27 
serial work: 1k took 00:02.27 
serial work: 10k took 00:02.28 
serial work: 100k took 00:02.28 

parallel work: 1 took 00:02.33 
parallel work: 2 took 00:01.14 
parallel work: 3 took 00:00.96 
parallel work: 4 took 00:00.78 
parallel work: 8 took 00:00.84 
parallel work: 16 took 00:00.86 
parallel work: 32 took 00:00.82 
parallel work: 64 took 00:00.80 
parallel work: 1k took 00:00.77 
parallel work: 10k took 00:00.78 
parallel work: 100k took 00:00.77 
done

L'exécution de code compilé dans .Net 4 et .Net 4.5 donne à peu près les mêmes résultats.

Les séries de travaux sont identiques. Peu importe comment vous le découpez, il s'exécute en environ 2,28 secondes.

Le travail parallèle avec 1 itération est légèrement plus long que pas de parallélisme du tout. 2 éléments sont plus courts, donc 3 et avec 4 itérations ou plus, c'est environ 0,8 seconde.

Il utilise tous les cœurs, mais pas avec une efficacité de 100%. Si le travail en série était divisé en 4 façons sans frais généraux, il se terminerait en 0,57 seconde (2,28/4 = 0,57).

Dans d'autres scénarios, je n'ai vu aucune accélération du tout avec 2-3 itérations parallèles. Vous n'avez pas de contrôle précis sur cela avec Parallel.ForEach et l'algorithme peut décider de les "partitionner" en un seul bloc et de l'exécuter sur 1 cœur si la machine est occupée.

15
Anthony

Il n'y a pas de limite inférieure pour effectuer des opérations parallèles. Si vous n'avez que 2 éléments sur lesquels travailler mais que chacun prendra un certain temps, il peut être judicieux d'utiliser Parallel.ForEach. En revanche, si vous avez 1000000 éléments mais qu'ils ne font pas grand-chose, la boucle parallèle peut ne pas aller plus vite que la boucle régulière.

Par exemple, j'ai écrit un programme simple pour chronométrer les boucles imbriquées où la boucle externe s'exécutait à la fois avec une boucle for et avec Parallel.ForEach. Je l'ai chronométré sur mon ordinateur portable à 4 processeurs (double cœur, hyperthread).

Voici une course avec seulement 2 éléments sur lesquels travailler, mais chacun prend un certain temps:

 2 itérations externes, 100000000 itérations internes: 
 Pour la boucle: 00: 00: 00.1460441 
 Pour chacune: 00: 00: 00.0842240 

Voici une course avec des millions d'éléments sur lesquels travailler, mais ils ne font pas grand-chose:

 100000000 itérations externes, 2 itérations internes: 
 Pour la boucle: 00: 00: 00.0866330 
 Pour chacune: 00: 00: 02.1303315 

La seule vraie façon de savoir est de l'essayer.

9
Gabe

En général, une fois que vous dépassez un thread par noyau, chaque thread supplémentaire impliqué dans une opération le rendra plus lent et non plus rapide.

Cependant, si une partie de chaque opération se bloque (l'exemple classique étant en attente sur le disque ou les E/S réseau, un autre étant des producteurs et des consommateurs qui ne sont pas synchronisés les uns avec les autres), alors plus de threads que de cœurs peuvent recommencer à accélérer, car les tâches peuvent être effectuées alors que d'autres threads ne peuvent pas progresser jusqu'à ce que l'opération d'E/S revienne.

Pour cette raison, lorsque les machines monocœur étaient la norme, les seules vraies justifications du multithread étaient le blocage du type d'E/S introduit ou bien l'amélioration de la réactivité (légèrement plus lent pour effectuer une tâche, mais beaucoup plus rapide). pour recommencer à répondre aux entrées utilisateur).

Pourtant, de nos jours, les machines monocœur sont de plus en plus rares, il semblerait donc que vous devriez pouvoir tout faire au moins deux fois plus rapidement avec un traitement parallèle.

Ce ne sera toujours pas le cas si l'ordre est important, ou quelque chose d'inhérent au groupe de travail l'oblige à avoir un goulot d'étranglement synchronisé, ou si le nombre d'opérations est si petit que l'augmentation de la vitesse du traitement parallèle est compensée par les frais généraux impliqués dans la mise en place de ce traitement parallèle. Cela peut ou non être le cas si une ressource de partage nécessite que les threads se bloquent sur d'autres threads effectuant la même opération parallèle (selon le degré de contention du verrouillage).

De plus, si votre code est intrinsèquement multithread pour commencer, vous pouvez être dans une situation où vous êtes essentiellement en concurrence pour les ressources avec vous-même (un cas classique étant le code ASP.NET gérant les requêtes simultanées). Ici, l'avantage en fonctionnement parallèle peut signifier qu'une seule opération de test sur une machine à 4 cœurs approche 4 fois les performances, mais une fois que le nombre de demandes nécessitant la même tâche à effectuer atteint 4, alors puisque chacune de ces 4 demandes est chacune en essayant d'utiliser chaque noyau, cela devient un peu mieux que s'ils avaient chacun un noyau (peut-être légèrement mieux, peut-être légèrement pire). Les avantages du fonctionnement parallèle disparaissent donc lorsque l'utilisation passe d'un test à requête unique à une multitude de requêtes réelles.

1
Jon Hanna

Vous ne devez pas remplacer aveuglément chaque boucle foreach de votre application par la foreach parallèle. Plus de threads ne signifient pas nécessairement que votre application fonctionnera plus rapidement. Vous devez découper la tâche en tâches plus petites qui pourraient s'exécuter en parallèle si vous souhaitez vraiment bénéficier de plusieurs threads. Si votre algorithme n'est pas parallélisable, vous n'en tirerez aucun avantage.

1
Darin Dimitrov

Ce sont mes repères montrant que la série pure est la plus lente, ainsi que divers niveaux de partitionnement.

class Program
{
    static void Main(string[] args)
    {
        NativeDllCalls(true, 1, 400000000, 0);  // Seconds:     0.67 |)   595,203,995.01 ops
        NativeDllCalls(true, 1, 400000000, 3);  // Seconds:     0.91 |)   439,052,826.95 ops
        NativeDllCalls(true, 1, 400000000, 4);  // Seconds:     0.80 |)   501,224,491.43 ops
        NativeDllCalls(true, 1, 400000000, 8);  // Seconds:     0.63 |)   635,893,653.15 ops
        NativeDllCalls(true, 4, 100000000, 0);  // Seconds:     0.35 |) 1,149,359,562.48 ops
        NativeDllCalls(true, 400, 1000000, 0);  // Seconds:     0.24 |) 1,673,544,236.17 ops
        NativeDllCalls(true, 10000, 40000, 0);  // Seconds:     0.22 |) 1,826,379,772.84 ops
        NativeDllCalls(true, 40000, 10000, 0);  // Seconds:     0.21 |) 1,869,052,325.05 ops
        NativeDllCalls(true, 1000000, 400, 0);  // Seconds:     0.24 |) 1,652,797,628.57 ops
        NativeDllCalls(true, 100000000, 4, 0);  // Seconds:     0.31 |) 1,294,424,654.13 ops
        NativeDllCalls(true, 400000000, 0, 0);  // Seconds:     1.10 |)   364,277,890.12 ops
    }


static void NativeDllCalls(bool useStatic, int nonParallelIterations, int parallelIterations = 0, int maxParallelism = 0)
{
    if (useStatic) {
        Iterate<string, object>(
            (msg, cntxt) => { 
                ServiceContracts.ForNativeCall.SomeStaticCall(msg); 
            }
            , "test", null, nonParallelIterations,parallelIterations, maxParallelism );
    }
    else {
        var instance = new ServiceContracts.ForNativeCall();
        Iterate(
            (msg, cntxt) => {
                cntxt.SomeCall(msg);
            }
            , "test", instance, nonParallelIterations, parallelIterations, maxParallelism);
    }
}

static void Iterate<T, C>(Action<T, C> action, T testMessage, C context, int nonParallelIterations, int parallelIterations=0, int maxParallelism= 0)
{
    var start = DateTime.UtcNow;            
    if(nonParallelIterations == 0)
        nonParallelIterations = 1; // normalize values

    if(parallelIterations == 0)
        parallelIterations = 1; 

    if (parallelIterations > 1) {                    
        ParallelOptions options;
        if (maxParallelism == 0) // default max parallelism
            options = new ParallelOptions();
        else
            options = new ParallelOptions { MaxDegreeOfParallelism = maxParallelism };

        if (nonParallelIterations > 1) {
            Parallel.For(0, parallelIterations, options
            , (j) => {
                for (int i = 0; i < nonParallelIterations; ++i) {
                    action(testMessage, context);
                }
            });
        }
        else { // no nonParallel iterations
            Parallel.For(0, parallelIterations, options
            , (j) => {                        
                action(testMessage, context);
            });
        }
    }
    else {
        for (int i = 0; i < nonParallelIterations; ++i) {
            action(testMessage, context);
        }
    }

    var end = DateTime.UtcNow;

    Console.WriteLine("\tSeconds: {0,8:0.00} |) {1,16:0,000.00} ops",
        (end - start).TotalSeconds, (Math.Max(parallelIterations, 1) * nonParallelIterations / (end - start).TotalSeconds));

}

}
0
AaronLS

Non. Vous devez comprendre ce que fait le code et savoir s'il peut faire l'objet d'une parallélisation. Les dépendances entre vos éléments de données peuvent rendre la parallélisation difficile, c'est-à-dire que si un thread utilise la valeur calculée pour l'élément précédent, il doit de toute façon attendre que la valeur soit calculée et ne puisse pas s'exécuter en parallèle. Vous devez également comprendre votre architecture cible, cependant, vous aurez généralement un processeur multicœur sur à peu près tout ce que vous achetez ces jours-ci. Même sur un seul cœur, vous pouvez bénéficier de plus de threads, mais uniquement si vous avez des tâches de blocage. Vous devez également garder à l'esprit qu'il existe des frais généraux dans la création et l'organisation des threads parallèles. Si cette surcharge représente une fraction importante (ou supérieure) du temps nécessaire à votre tâche, vous pouvez la ralentir.

0
tvanfosson