web-dev-qa-db-fra.com

Pourquoi Parallel.ForEach est-il beaucoup plus rapide que AsParallel (). ForAll () alors que MSDN suggère le contraire?

J'ai fait des recherches pour voir comment nous pouvons créer une application multithread qui s'exécute dans un arbre.

Pour savoir comment cela peut être mis en œuvre de la meilleure façon possible, j'ai créé une application de test qui s'exécute sur mon disque C:\et ouvre tous les répertoires.

class Program
{
    static void Main(string[] args)
    {
        //var startDirectory = @"C:\The folder\RecursiveFolder";
        var startDirectory = @"C:\";

        var w = Stopwatch.StartNew();

        ThisIsARecursiveFunction(startDirectory);

        Console.WriteLine("Elapsed seconds: " + w.Elapsed.TotalSeconds);

        Console.ReadKey();
    }

    public static void ThisIsARecursiveFunction(String currentDirectory)
    {
        var lastBit = Path.GetFileName(currentDirectory);
        var depth = currentDirectory.Count(t => t == '\\');
        //Console.WriteLine(depth + ": " + currentDirectory);

        try
        {
            var children = Directory.GetDirectories(currentDirectory);

            //Edit this mode to switch what way of parallelization it should use
            int mode = 3;

            switch (mode)
            {
                case 1:
                    foreach (var child in children)
                    {
                        ThisIsARecursiveFunction(child);
                    }
                    break;
                case 2:
                    children.AsParallel().ForAll(t =>
                    {
                        ThisIsARecursiveFunction(t);
                    });
                    break;
                case 3:
                    Parallel.ForEach(children, t =>
                    {
                        ThisIsARecursiveFunction(t);
                    });
                    break;
                default:
                    break;
            }

        }
        catch (Exception eee)
        {
            //Exception might occur for directories that can't be accessed.
        }
    }
}

Ce que j’ai rencontré, c’est que lorsqu’il est exécuté en mode 3 (Parallel.ForEach), le code s’achève en 2,5 secondes environ (oui, j’ai un disque SSD;)). L'exécution du code sans parallélisation se termine en 8 secondes environ. Et en exécutant le code en mode 2 (AsParalle.ForAll ()), cela prend une quantité de temps presque infinie.

En enregistrant le processus Explorer, je rencontre également quelques faits étranges:

Mode1 (No Parallelization):
Cpu:     ~25%
Threads: 3
Time to complete: ~8 seconds

Mode2 (AsParallel().ForAll()):
Cpu:     ~0%
Threads: Increasing by one per second (I find this strange since it seems to be waiting on the other threads to complete or a second timeout.)
Time to complete: 1 second per node so about 3 days???

Mode3 (Parallel.ForEach()):
Cpu:     100%
Threads: At most 29-30
Time to complete: ~2.5 seconds

Ce que je trouve particulièrement étrange, c’est que Parallel.ForEach semble ignorer tous les threads/tâches parents qui sont toujours en cours d’exécution alors que AsParallel (). ForAll () semble attendre que la tâche précédente soit terminée (ce qui ne sera pas bientôt, car toutes les tâches parent attendent toujours que leurs tâches enfants soient terminées).

De plus, ce que j'ai lu sur MSDN était: "Préférez ForAll à ForEach quand c'est possible"

Source: http://msdn.Microsoft.com/en-us/library/dd997403(v=vs.110).aspx

Quelqu'un at-il une idée pourquoi cela pourrait être?

Modifier 1:

À la demande de Matthew Watson, j'ai d'abord chargé l'arbre en mémoire avant de le parcourir en boucle. Maintenant, le chargement de l'arbre est effectué de manière séquentielle.

Les résultats sont cependant les mêmes. Unparallelized et Parallel.ForEach complètent à présent tout l'arbre en environ 0,05 seconde, tandis que AsParallel (). ForAll ne fait encore que 1 pas par seconde.

Code:

class Program
{
    private static DirWithSubDirs RootDir;

    static void Main(string[] args)
    {
        //var startDirectory = @"C:\The folder\RecursiveFolder";
        var startDirectory = @"C:\";

        Console.WriteLine("Loading file system into memory...");
        RootDir = new DirWithSubDirs(startDirectory);
        Console.WriteLine("Done");


        var w = Stopwatch.StartNew();

        ThisIsARecursiveFunctionInMemory(RootDir);

        Console.WriteLine("Elapsed seconds: " + w.Elapsed.TotalSeconds);

        Console.ReadKey();
    }        

    public static void ThisIsARecursiveFunctionInMemory(DirWithSubDirs currentDirectory)
    {
        var depth = currentDirectory.Path.Count(t => t == '\\');
        Console.WriteLine(depth + ": " + currentDirectory.Path);

        var children = currentDirectory.SubDirs;

        //Edit this mode to switch what way of parallelization it should use
        int mode = 2;

        switch (mode)
        {
            case 1:
                foreach (var child in children)
                {
                    ThisIsARecursiveFunctionInMemory(child);
                }
                break;
            case 2:
                children.AsParallel().ForAll(t =>
                {
                    ThisIsARecursiveFunctionInMemory(t);
                });
                break;
            case 3:
                Parallel.ForEach(children, t =>
                {
                    ThisIsARecursiveFunctionInMemory(t);
                });
                break;
            default:
                break;
        }
    }
}

class DirWithSubDirs
{
    public List<DirWithSubDirs> SubDirs = new List<DirWithSubDirs>();
    public String Path { get; private set; }

    public DirWithSubDirs(String path)
    {
        this.Path = path;
        try
        {
            SubDirs = Directory.GetDirectories(path).Select(t => new DirWithSubDirs(t)).ToList();
        }
        catch (Exception eee)
        {
            //Ignore directories that can't be accessed
        }
    }
}

Edit 2:

Après avoir lu la mise à jour sur le commentaire de Matthew, j'ai essayé d'ajouter le code suivant au programme:

ThreadPool.SetMinThreads(4000, 16);
ThreadPool.SetMaxThreads(4000, 16);

Cela ne change toutefois pas la façon dont l'AsParallel se comporte. Les 8 premières étapes sont encore exécutées en un instant avant de ralentir à 1 étape/seconde.

(Remarque supplémentaire, j'ignore actuellement les exceptions qui se produisent lorsque je ne peux pas accéder à un répertoire à l'aide du bloc Try Catch autour de Directory.GetDirectories ())

Edit 3:

De plus, ce qui m'intéresse principalement, c’est la différence entre Parallel.ForEach et AsParallel.ForAll, car pour moi, il est juste étrange que pour une raison quelconque, le second crée un thread pour chaque récursion alors que le premier traite tout autour de 30 threads. max. (Et aussi pourquoi MSDN suggère d'utiliser AsParallel même s'il crée autant de threads avec un délai d'expiration d'environ 1 seconde)

Edit 4:

Une autre chose étrange que j'ai découverte: Lorsque j'essaie de définir le nombre maximal de threads sur le pool de threads au-dessus de 1023, il semble ignorer la valeur et redimensionner vers 8 ou 16: ThreadPool.SetMinThreads (1023, 16);

Néanmoins, lorsque j'utilise 1023, les 1023 premiers éléments sont très rapides, suivis par le retour au rythme lent que je vis depuis le début.

Remarque: plus de 1 000 threads sont maintenant créés (comparés à 30 pour l’ensemble de Parallel.ForEach).

Cela signifie-t-il que Parallel.ForEach est simplement plus intelligent dans la gestion des tâches?

Quelques informations supplémentaires, ce code s'imprime deux fois 8 à 8 lorsque vous définissez une valeur supérieure à 1023: (lorsque vous définissez les valeurs sur 1023 ou moins, la valeur correcte est imprimée)

        int threadsMin;
        int completionMin;
        ThreadPool.GetMinThreads(out threadsMin, out completionMin);
        Console.WriteLine("Cur min threads: " + threadsMin + " and the other thing: " + completionMin);

        ThreadPool.SetMinThreads(1023, 16);
        ThreadPool.SetMaxThreads(1023, 16);

        ThreadPool.GetMinThreads(out threadsMin, out completionMin);
        Console.WriteLine("Now min threads: " + threadsMin + " and the other thing: " + completionMin);

Edit 5:

À la demande de Dean, j'ai créé un autre cas pour créer manuellement des tâches:

case 4:
    var taskList = new List<Task>();
    foreach (var todo in children)
    {
        var itemTodo = todo;
        taskList.Add(Task.Run(() => ThisIsARecursiveFunctionInMemory(itemTodo)));
    }
    Task.WaitAll(taskList.ToArray());
    break;

C'est aussi rapide que la boucle Parallel.ForEach (). Nous ne savons donc toujours pas pourquoi AsParallel (). ForAll () est tellement plus lent.

30
Devedse

Ce problème est assez débogable, un luxe rare lorsque vous avez des problèmes avec les threads. Votre outil de base ici est la fenêtre de débogage Debug> Windows> Threads. Vous montre les threads actifs et vous donne un aperçu de leur trace de pile. Vous verrez facilement que, une fois que cela devient lent, vous aurez des dizaines de threads actifs qui sont tous bloqués. Leur trace de pile se ressemblent toutes:

    mscorlib.dll!System.Threading.Monitor.Wait(object obj, int millisecondsTimeout, bool exitContext) + 0x16 bytes  
    mscorlib.dll!System.Threading.Monitor.Wait(object obj, int millisecondsTimeout) + 0x7 bytes 
    mscorlib.dll!System.Threading.ManualResetEventSlim.Wait(int millisecondsTimeout, System.Threading.CancellationToken cancellationToken) + 0x182 bytes    
    mscorlib.dll!System.Threading.Tasks.Task.SpinThenBlockingWait(int millisecondsTimeout, System.Threading.CancellationToken cancellationToken) + 0x93 bytes   
    mscorlib.dll!System.Threading.Tasks.Task.InternalRunSynchronously(System.Threading.Tasks.TaskScheduler scheduler, bool waitForCompletion) + 0xba bytes  
    mscorlib.dll!System.Threading.Tasks.Task.RunSynchronously(System.Threading.Tasks.TaskScheduler scheduler) + 0x13 bytes  
    System.Core.dll!System.Linq.Parallel.SpoolingTask.SpoolForAll<ConsoleApplication1.DirWithSubDirs,int>(System.Linq.Parallel.QueryTaskGroupState groupState, System.Linq.Parallel.PartitionedStream<ConsoleApplication1.DirWithSubDirs,int> partitions, System.Threading.Tasks.TaskScheduler taskScheduler) Line 172  C#
// etc..

Chaque fois que vous voyez quelque chose comme ceci, vous devriez immédiatement penser au problème fire-hose. Probablement le troisième bogue le plus fréquent avec les threads, après les courses et les blocages.

Ce que vous pouvez raisonner, maintenant que vous en connaissez la cause, le problème avec le code est que chaque thread qui complète ajoute N autres threads. Où N est le nombre moyen de sous-répertoires dans un répertoire. En effet, le nombre de threads augmente de manière exponentielle, c'est toujours mauvais. Il ne gardera le contrôle que si N = 1, ce qui ne se produit bien sûr jamais sur un disque typique.

Attention, comme dans presque tous les problèmes de threading, cette mauvaise conduite a tendance à mal se répéter. Le SSD de votre machine a tendance à le cacher. Il en va de même pour la RAM de votre ordinateur: le programme pourrait s’achever rapidement et sans problème la deuxième fois que vous l’exécutez. Puisque vous allez maintenant lire le cache du système de fichiers au lieu du disque, très rapidement. Le bricolage avec ThreadPool.SetMinThreads () le masque également, mais il ne peut pas le réparer. Cela ne règle jamais aucun problème, il ne fait que les cacher. Car quoi qu'il arrive, le nombre exponentiel dépassera toujours le nombre minimum de threads défini. Vous ne pouvez qu'espérer que l'itération du lecteur soit terminée avant que cela ne se produise. Un espoir au ralenti pour un utilisateur gros lecteur.

La différence entre ParallelEnumerable.ForAll () et Parallel.ForEach () est maintenant peut-être aussi facilement expliquée. A partir de la trace de la pile, vous pouvez dire que ForAll () fait quelque chose de vilain, la méthode RunSynchronously () bloque jusqu'à ce que tous les threads soient terminés. Le blocage est une chose que les threads threadpool ne devraient pas faire, il gomme le pool de threads et ne lui permet pas de planifier le processeur pour un autre travail. Et, à l’effet observé, le pool de threads est rapidement submergé par les threads qui attendent la fin des N autres threads. Ce qui ne se produit pas, ils attendent dans la piscine et ne sont pas planifiés car ils sont déjà nombreux à être actifs.

Il s'agit d'un scénario de blocage, un scénario assez courant, mais le gestionnaire de pools de threads propose une solution de contournement. Il surveille les threads de pool de threads actifs et intervient lorsqu'ils ne se terminent pas à temps. Il permet ensuite à un thread extra _ de démarrer, un de plus que le minimum défini par SetMinThreads (). Mais pas plus que le maximum défini par SetMaxThreads (), avoir trop de threads tp actifs est risqué et risque de déclencher un MOO. Cela résout le blocage, il obtient l'un des appels ForAll () à compléter. Mais cela se produit très lentement, le pool de threads ne le fait que deux fois par seconde. Vous allez manquer de patience avant qu'il ne rattrape son retard.

Parallel.ForEach () n'a pas ce problème, il ne bloque pas et ne gomme pas le pool. 

Cela semble être la solution, mais gardez à l’esprit que votre programme garde toujours le feu à la mémoire de votre machine, ajoutant de plus en plus de threads en attente au pool. Cela risque également de bloquer votre programme, ce qui est moins probable, car vous avez beaucoup de mémoire et que le pool de threads n'en utilise pas beaucoup pour garder trace d'une requête. Certains programmeurs cependant accomplissent cela aussi .

La solution est très simple, mais n'utilisez pas de threading. C’est nuisible, il n’ya pas de concurrence lorsque vous n’avez qu’un seul disque. Et cela fait pas comme si on le commandait par plusieurs threads. Particulièrement mauvais sur un entraînement de broche, la tête cherche très, très lentement. Les disques SSD le font beaucoup mieux, mais cela prend tout de même 50 secondes, une surcharge que vous ne voulez ou dont vous avez besoin. Le nombre idéal de threads pour accéder à un disque que vous ne pouvez pas vous attendre autrement à bien mettre en cache est toujours one.

45
Hans Passant

La première chose à noter est que vous essayez de paralléliser une opération liée aux entrées-sorties, ce qui fausserait considérablement les timings.

La deuxième chose à noter est la nature des tâches parallélisées: vous descendez de manière récursive une arborescence de répertoires. Si vous créez plusieurs threads à cette fin, il est probable que chaque thread accède simultanément à une partie différente du disque - ce qui fera que la tête de lecture du disque sautera partout et ralentira considérablement les choses.

Essayez de modifier votre test pour créer une arborescence en mémoire, et accédez-y avec plusieurs threads. Vous pourrez ensuite comparer les temps correctement sans que les résultats soient déformés au-delà de toute utilité.

En outre, vous pouvez créer un grand nombre de threads, qui seront (par défaut) des threads de pool de threads. Avoir un grand nombre de threads ralentira les choses quand ils dépasseront le nombre de cœurs de processeur.

Notez également que lorsque vous dépassez le nombre minimal de threads du pool de threads (défini par ThreadPool.GetMinThreads() ), un délai est introduit par le gestionnaire de pool de threads entre chaque nouvelle création de threads du pool de threads. (Je pense que c'est environ 0.5s par nouveau fil).

De même, si le nombre de threads dépasse la valeur renvoyée par ThreadPool.GetMaxThreads(), le thread en création sera bloqué jusqu'à ce que l'un des autres threads soit terminé. Je pense que cela va probablement arriver.

Vous pouvez tester cette hypothèse en appelant ThreadPool.SetMaxThreads() et ThreadPool.SetMinThreads() pour augmenter ces valeurs et voir si cela fait une différence.

(Enfin, notez que si vous essayez réellement de descendre récursivement de C:\, vous obtiendrez presque certainement une exception IO quand il atteindra un dossier protégé du système d'exploitation.)

REMARQUE: Définissez les threads de pool de threads max/min comme suit:

ThreadPool.SetMinThreads(4000, 16);
ThreadPool.SetMaxThreads(4000, 16);

Suivre

J'ai essayé votre code de test avec le nombre de threads du pool de threads défini comme décrit ci-dessus, avec les résultats suivants (ne s'exécute pas sur la totalité de mon lecteur C: \, mais sur un sous-ensemble plus petit):

  • Le mode 1 a pris 06,5 secondes.
  • Le mode 2 a pris 15,7 secondes.
  • Le mode 3 a pris 16,4 secondes.

Cela correspond à mes attentes. l'ajout d'une charge de threading pour le faire le rend plus lent que les threads simples, et les deux approches parallèles prennent à peu près le même temps.


Au cas où quelqu'un d'autre voudrait étudier cela, voici un code de test déterminant (le code de l'OP n'est pas reproductible car nous ne connaissons pas la structure de son répertoire).

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;

namespace Demo
{
    internal class Program
    {
        private static DirWithSubDirs RootDir;

        private static void Main()
        {
            Console.WriteLine("Loading file system into memory...");
            RootDir = new DirWithSubDirs("Root", 4, 4);
            Console.WriteLine("Done");

            //ThreadPool.SetMinThreads(4000, 16);
            //ThreadPool.SetMaxThreads(4000, 16);

            var w = Stopwatch.StartNew();
            ThisIsARecursiveFunctionInMemory(RootDir);

            Console.WriteLine("Elapsed seconds: " + w.Elapsed.TotalSeconds);
            Console.ReadKey();
        }

        public static void ThisIsARecursiveFunctionInMemory(DirWithSubDirs currentDirectory)
        {
            var depth = currentDirectory.Path.Count(t => t == '\\');
            Console.WriteLine(depth + ": " + currentDirectory.Path);

            var children = currentDirectory.SubDirs;

            //Edit this mode to switch what way of parallelization it should use
            int mode = 3;

            switch (mode)
            {
                case 1:
                    foreach (var child in children)
                    {
                        ThisIsARecursiveFunctionInMemory(child);
                    }
                    break;

                case 2:
                    children.AsParallel().ForAll(t =>
                    {
                        ThisIsARecursiveFunctionInMemory(t);
                    });
                    break;

                case 3:
                    Parallel.ForEach(children, t =>
                    {
                        ThisIsARecursiveFunctionInMemory(t);
                    });
                    break;

                default:
                    break;
            }
        }
    }

    internal class DirWithSubDirs
    {
        public List<DirWithSubDirs> SubDirs = new List<DirWithSubDirs>();

        public String Path { get; private set; }

        public DirWithSubDirs(String path, int width, int depth)
        {
            this.Path = path;

            if (depth > 0)
                for (int i = 0; i < width; ++i)
                    SubDirs.Add(new DirWithSubDirs(path + "\\" + i, width, depth - 1));
        }
    }
}
6
Matthew Watson

Les méthodes Parallel.For et .ForEach sont implémentées en interne, ce qui revient à exécuter des itérations dans Tasks, par exemple. qu'une boucle comme:

Parallel.For(0, N, i => 
{ 
  DoWork(i); 
});

est équivalent à:

var tasks = new List<Task>(N); 
for(int i=0; i<N; i++) 
{ 
tasks.Add(Task.Factory.StartNew(state => DoWork((int)state), i)); 
} 
Task.WaitAll(tasks.ToArray());

Et du point de vue de chaque itération potentiellement parallèle, il s'agit d'un modèle correct mental, mais il ne se produit pas dans la réalité. En fait, le parallèle n'utilise pas {nécessairement} une tâche par itération, ce qui représente une charge beaucoup plus importante que nécessaire. Parallel.ForEach essaie d'utiliser le nombre minimum de tâches nécessaires pour terminer la boucle le plus rapidement possible. Elle accélère les tâches à mesure que les threads deviennent disponibles pour traiter ces tâches, et chacune de ces tâches participe à un schéma de gestion (je pense qu'il s'appelle chunking): une tâche demande l'exécution de plusieurs itérations, les obtient, puis les processus qui fonctionnent, et puis retourne pour plus. La taille des morceaux varie en fonction du nombre de tâches impliquées, de la charge sur la machine, etc.

.AsParallel () de PLINQ a une implémentation différente, mais il peut «de même» extraire plusieurs itérations dans un magasin temporaire, effectuer les calculs dans un thread (mais pas en tant que tâche) et placer les résultats de la requête dans un petit tampon. (Vous obtenez quelque chose basé sur ParallelQuery, puis d'autres fonctions .Wowels () se lient à un ensemble alternatif de méthodes d'extension offrant des implémentations parallèles).

Alors maintenant que nous avons une petite idée du fonctionnement de ces deux mécanismes, je vais essayer de répondre à votre question initiale:

Alors pourquoi .AsParallel () est-il plus lent que Parallel.ForEach? La raison découle de ce qui suit. Les tâches (ou leur implémentation équivalente ici) ne bloquent PAS lors d'appels de type E/S. Ils "attendent" et libèrent le processeur pour faire autre chose. Mais (citant le livre de synthèse en C #): “PLINQ ne peut pas exécuter de travail lié aux E/S sans bloquer les threads”. Les appels sont synchrone _. Ils ont été écrits dans le but d'augmenter le degré de parallélisme si (et UNIQUEMENT si) vous effectuez des tâches telles que le téléchargement de pages Web par tâche qui ne prend pas trop de temps CPU.

Et la raison pour laquelle vos appels de fonction sont exactement analogues aux appels liés aux entrées/sorties} est la suivante: l'un de vos threads (l'appelez T) bloque et ne fait rien jusqu'à ce que tous ses threads soient terminés , ce qui peut être un processus lent ici. T lui-même ne nécessite pas beaucoup de temps d’attente en attendant que les enfants se débloquent, il ne fait rien d’attendre. Par conséquent, il est identique à un appel de fonction lié d’entrée/sortie typique.

3
Dean

Basé sur la réponse acceptée à Comment fonctionne exactement AsParallel?

.AsParallel.ForAll() renvoie à IEnumerable avant d'appeler .ForAll() 

il crée donc 1 nouveau thread + N appels récursifs (qui génèrent chacun un nouveau thread).

0
user1023602