web-dev-qa-db-fra.com

Multithreading .NET vs multitraitement: Mauvaises performances de Parallel.ForEach

J'ai codé un programme très simple de "décompte de mots" qui lit un fichier et compte l'occurrence de chaque mot dans le fichier. Voici une partie du code:

class Alaki
{
    private static List<string> input = new List<string>();

    private static void exec(int threadcount)
    {
        ParallelOptions options = new ParallelOptions();
        options.MaxDegreeOfParallelism = threadcount;
        Parallel.ForEach(Partitioner.Create(0, input.Count),options, (range) =>
        {
            var dic = new Dictionary<string, List<int>>();
            for (int i = range.Item1; i < range.Item2; i++)
            {
                //make some delay!
                //for (int x = 0; x < 400000; x++) ;                    

                var tokens = input[i].Split();
                foreach (var token in tokens)
                {
                    if (!dic.ContainsKey(token))
                        dic[token] = new List<int>();
                    dic[token].Add(1);
                }
            }
        });

    }

    public static void Main(String[] args)
    {            
        StreamReader reader=new StreamReader((@"c:\txt-set\agg.txt"));
        while(true)
        {
            var line=reader.ReadLine();
            if(line==null)
                break;
            input.Add(line);
        }

        DateTime t0 = DateTime.Now;
        exec(Environment.ProcessorCount);
        Console.WriteLine("Parallel:  " + (DateTime.Now - t0));
        t0 = DateTime.Now;
        exec(1);
        Console.WriteLine("Serial:  " + (DateTime.Now - t0));
    }
}

C'est simple et direct. J'utilise un dictionnaire pour compter l'occurrence de chaque mot. Le style est approximativement basé sur le modèle de programmation MapReduce . Comme vous pouvez le constater, chaque tâche utilise son propre dictionnaire privé. Donc, il n'y a PAS de variables partagées; juste un tas de tâches qui comptent les mots par eux-mêmes. Voici la sortie lorsque le code est exécuté sur un processeur i7 quad-core:

Parallèle: 00: 00: 01.6220927
Série: 00: 00: 02.0471171

L'accélération est d'environ 1,25, ce qui signifie une tragédie! Mais lorsque j'ajoute un certain délai lors du traitement de chaque ligne, je peux atteindre des valeurs d'accélération d'environ 4.

Dans l'exécution parallèle initiale sans délai, l'utilisation de la CPU n'atteint guère les 30% et, par conséquent, l'accélération n'est pas prometteuse. Mais, quand on ajoute un peu de retard, l'utilisation du processeur atteint 97%.

Premièrement, je pensais que la cause était liée à la nature du programme liée aux entrées-sorties (mais je pense que l'insertion dans un dictionnaire est un peu intensive en ressources processeur) et cela semble logique car tous les threads lisent des données à partir d'un bus de mémoire partagée. Cependant, le point surprenant est que lorsque je lance simultanément 4 instances de programmes en série (sans aucun délai), l'utilisation du processeur atteint environ les augmentations et toutes les quatre instances se terminent en environ 2,3 secondes!

Cela signifie que lorsque le code est exécuté dans une configuration multitraitement, il atteint une valeur d'accélération d'environ 3,5, mais lorsqu'il est exécuté dans une configuration multithreading, l'accélération est d'environ 1,25. 

Quelle est votre idée? Y at-il un problème avec mon code? Parce que je pense qu'il n'y a pas du tout de données partagées et que le code ne doit pas faire l'objet de contentions… .. Existe-t-il une faille dans l'exécution de .NET? 

Merci d'avance.

28
Saeed Shahrivari

Parallel.For ne divise pas l'entrée en n morceaux (où n est la MaxDegreeOfParallelism); à la place, il crée de nombreux petits lots et garantit que n au plus sont traités simultanément. (Ainsi, si un lot prend beaucoup de temps à traiter, Parallel.For peut toujours exécuter des travaux sur d'autres threads. Voir Parallélisme dans .NET - Partie 5, Partitionnement du travail pour plus de détails.)

En raison de cette conception, votre code crée et supprime des dizaines d'objets de dictionnaire, des centaines d'objets de liste et des milliers d'objets String. Cela met une pression énorme sur le ramasse-miettes.

L'exécution de PerfMonitor sur mon ordinateur indique que 43% du temps total d'exécution est passé en CPG. Si vous réécrivez votre code pour utiliser moins d'objets temporaires, vous devriez voir l'accélération 4x souhaitée. Voici quelques extraits du rapport PerfMonitor:

Plus de 10% du temps CPU total a été passé dans le ramasse-miettes . La plupart des applications bien réglées sont comprises entre 0 et 10%. C'est typiquement causé par un modèle d’allocation qui permet aux objets de vivre très longtemps assez pour nécessiter une collection chère Gen 2.

Ce programme avait un taux maximal d’allocation de tas GC de plus de 10 Mo/s . C'est assez élevé. Il n’est pas rare que ce soit simplement un bug de performance.

Edit: Selon votre commentaire, je vais tenter d’expliquer les horaires que vous avez signalés. Sur mon ordinateur, avec PerfMonitor, j'ai mesuré entre 43% et 52% du temps passé en CPG. Pour simplifier, supposons que 50% du temps CPU est de travailler et 50% est GC. Ainsi, si nous accélérons le travail 4 × (par le multi-threading) tout en conservant la même quantité de GC (cela se produira parce que le nombre de lots traités est identique dans les configurations parallèle et en série), le meilleur l’amélioration que nous pourrions obtenir est de 62,5% du temps initial, soit 1,6 ×.

Cependant, nous ne voyons qu'une accélération de 1,25 ×, car GC n'est pas multithread par défaut (dans la station de travail GC). Conformément à Principes de base de la récupération de place , tous les threads gérés sont mis en pause pendant une collecte Gen 0 ou Gen 1. (Le GC simultané et d'arrière-plan, dans .NET 4 et .NET 4.5, peut collecter la génération 2 sur un thread d'arrière-plan.) Votre programme ne subit qu'une accélération de 1,25 × (et vous constatez une utilisation globale de 30% du processeur) le temps est suspendu pour GC (car le modèle d’allocation de mémoire de ce programme de test est très médiocre).

Si vous activez server GC , il effectuera un nettoyage de la mémoire sur plusieurs threads. Si je fais cela, le programme s'exécute 2 × plus vite (avec presque 100% d'utilisation du processeur).

Lorsque vous exécutez quatre instances du programme simultanément, chacune a son propre segment de mémoire géré et le garbage collection des quatre processus peut être exécuté en parallèle. C'est pourquoi vous voyez utiliser 100% du processeur (chaque processus utilise 100% d'un processeur). Le temps total légèrement plus long (2,3 pour tous contre 2,05 pour un) est probablement dû à des imprécisions dans les mesures, à des conflits pour le disque, au temps nécessaire pour charger le fichier, à l’initialisation du pool de threads, à la surcharge du changement de contexte facteur d'environnement. 

52
Bradley Grainger

Une tentative pour expliquer les résultats: 

  • une analyse rapide dans le profileur VS montre qu'il atteint à peine 40% d'utilisation du processeur.
  • String.Split est le point d'accès principal.
  • donc quelque chose partagé doit bloquer le CPU. 
  • que quelque chose est l'allocation de mémoire la plus probable. Vos goulots d'étranglement sont
var dic = new Dictionary<string, List<int>>();
...
   dic[token].Add(1);

J'ai remplacé ceci par 

var dic = new Dictionary<string, int>();
...
... else dic[token] += 1;

et le résultat est plus proche d'une accélération 2x. 

Mais ma contre question serait: est-ce important? Votre code est très artificiel et incomplet. La version parallèle crée plusieurs dictionnaires sans les fusionner. Ce n'est même pas proche d'une situation réelle. Et comme vous pouvez le constater, les petits détails importent. 

Votre exemple de code est trop complexe pour faire des déclarations générales sur Parallel.ForEach().
Il est trop simple de résoudre/analyser un problème réel.

9
Henk Holterman

Juste pour le plaisir, voici une version plus courte de PLINQ:

File.ReadAllText("big.txt").Split().AsParallel().GroupBy(t => t)
                                                .ToDictionary(g => g.Key, g => g.Count());
0
Slai