web-dev-qa-db-fra.com

Task.Factory.StartNew vs Parallel.Invoke

Dans mon application, j'exécute de quelques dizaines à quelques centaines d'actions en parallèle (pas de valeur de retour pour les actions).

Quelle approche serait la plus optimale:

  1. Utilisation de Task.Factory.StartNew Dans la boucle foreach itérant sur Action array (Action[])

    Task.Factory.StartNew(() => someAction());

  2. Utilisation de la classe Parallelactions est Action array (Action[])

    Parallel.Invoke(actions);

Ces deux approches sont-elles équivalentes? Y a-t-il des implications en termes de performances?

MODIFIER

J'ai effectué des tests de performances et sur ma machine (2 CPU 2 cœurs chacun), les résultats semblent très similaires. Je ne sais pas à quoi cela va ressembler sur d'autres machines comme 1 CPU. De plus, je ne sais pas (je ne sais pas comment le tester de manière très précise) quelle est la consommation de mémoire.

35
Alexandar

La différence la plus importante entre ces deux est que Parallel.Invoke attendra que toutes les actions soient terminées avant de continuer avec le code, tandis que StartNew passera à la ligne de code suivante, permettant aux tâches de se terminer à leur guise.

Cette différence sémantique devrait être votre première (et probablement seule) considération. Mais à titre informatif, voici une référence:

/* This is a benchmarking template I use in LINQPad when I want to do a
 * quick performance test. Just give it a couple of actions to test and
 * it will give you a pretty good idea of how long they take compared
 * to one another. It's not perfect: You can expect a 3% error margin
 * under ideal circumstances. But if you're not going to improve
 * performance by more than 3%, you probably don't care anyway.*/
void Main()
{
    // Enter setup code here
    var actions2 =
    (from i in Enumerable.Range(1, 10000)
    select (Action)(() => {})).ToArray();

    var awaitList = new Task[actions2.Length];
    var actions = new[]
    {
        new TimedAction("Task.Factory.StartNew", () =>
        {
            // Enter code to test here
            int j = 0;
            foreach(var action in actions2)
            {
                awaitList[j++] = Task.Factory.StartNew(action);
            }
            Task.WaitAll(awaitList);
        }),
        new TimedAction("Parallel.Invoke", () =>
        {
            // Enter code to test here
            Parallel.Invoke(actions2);
        }),
    };
    const int TimesToRun = 100; // Tweak this as necessary
    TimeActions(TimesToRun, actions);
}


#region timer helper methods
// Define other methods and classes here
public void TimeActions(int iterations, params TimedAction[] actions)
{
    Stopwatch s = new Stopwatch();
    int length = actions.Length;
    var results = new ActionResult[actions.Length];
    // Perform the actions in their initial order.
    for(int i = 0; i < length; i++)
    {
        var action = actions[i];
        var result = results[i] = new ActionResult{Message = action.Message};
        // Do a dry run to get things ramped up/cached
        result.DryRun1 = s.Time(action.Action, 10);
        result.FullRun1 = s.Time(action.Action, iterations);
    }
    // Perform the actions in reverse order.
    for(int i = length - 1; i >= 0; i--)
    {
        var action = actions[i];
        var result = results[i];
        // Do a dry run to get things ramped up/cached
        result.DryRun2 = s.Time(action.Action, 10);
        result.FullRun2 = s.Time(action.Action, iterations);
    }
    results.Dump();
}

public class ActionResult
{
    public string Message {get;set;}
    public double DryRun1 {get;set;}
    public double DryRun2 {get;set;}
    public double FullRun1 {get;set;}
    public double FullRun2 {get;set;}
}

public class TimedAction
{
    public TimedAction(string message, Action action)
    {
        Message = message;
        Action = action;
    }
    public string Message {get;private set;}
    public Action Action {get;private set;}
}

public static class StopwatchExtensions
{
    public static double Time(this Stopwatch sw, Action action, int iterations)
    {
        sw.Restart();
        for (int i = 0; i < iterations; i++)
        {
            action();
        }
        sw.Stop();

        return sw.Elapsed.TotalMilliseconds;
    }
}
#endregion

Résultats:

Message               | DryRun1 | DryRun2 | FullRun1 | FullRun2
----------------------------------------------------------------
Task.Factory.StartNew | 43.0592 | 50.847  | 452.2637 | 463.2310
Parallel.Invoke       | 10.5717 |  9.948  | 102.7767 | 101.1158 

Comme vous pouvez le voir, l'utilisation de Parallel.Invoke peut être environ 4,5 fois plus rapide que d'attendre la fin d'un tas de tâches nouvelles. Bien sûr, c'est là que vos actions ne font absolument rien. Plus chaque action fait, moins vous remarquerez de différence.

44
StriplingWarrior

Dans le grand schéma des choses, les différences de performances entre les deux méthodes sont négligeables si l'on considère le surcoût lié au traitement de nombreuses tâches dans tous les cas.

Le Parallel.Invoke Exécute essentiellement la Task.Factory.StartNew() pour vous. Donc, je dirais que la lisibilité est plus importante ici.

En outre, comme le mentionne StriplingWarrior, le Parallel.Invoke Effectue pour vous un WaitAll (blocage du code jusqu'à ce que toutes les tâches soient terminées), vous n'avez donc pas à le faire non plus. Si vous souhaitez que les tâches s'exécutent en arrière-plan sans se soucier de leur achèvement, vous voulez Task.Factory.StartNew().

13
Colin Mackay

J'ai utilisé les tests de StriplingWarror pour savoir d'où vient la différence. Je l'ai fait parce que lorsque je regarde avec Reflector dans le code, la classe Parallel ne fait rien de différent que de créer un tas de tâches et de les laisser s'exécuter.

D'un point de vue théorique, les deux approches devraient être équivalentes en termes de durée d'exécution. Mais comme les tests (pas très réalistes) avec une action vide ont montré que la classe Parallel est beaucoup plus rapide.

La version de la tâche passe presque tout son temps à créer de nouvelles tâches, ce qui entraîne de nombreuses récupérations de place. La différence de vitesse que vous voyez est uniquement due au fait que vous créez de nombreuses tâches qui deviennent rapidement des ordures.

La classe Parallel crée à la place sa propre classe dérivée de tâche qui s'exécute simultanément sur tous les CPU. Il n'y a qu'une seule tâche phyiscale en cours d'exécution sur tous les cœurs. La synchronisation se produit maintenant à l'intérieur du délégué de tâche, ce qui explique la vitesse beaucoup plus rapide de la classe Parallel.

ParallelForReplicatingTask task2 = new ParallelForReplicatingTask(parallelOptions, delegate {
        for (int k = Interlocked.Increment(ref actionIndex); k <= actionsCopy.Length; k = Interlocked.Increment(ref actionIndex))
        {
            actionsCopy[k - 1]();
        }
    }, TaskCreationOptions.None, InternalTaskOptions.SelfReplicating);
task2.RunSynchronously(parallelOptions.EffectiveTaskScheduler);
task2.Wait();

Alors quoi de mieux alors? La meilleure tâche est celle qui n'est jamais exécutée. Si vous devez créer autant de tâches qu'elles deviennent un fardeau pour le garbage collector, vous devez rester à l'écart des API de tâche et coller la classe Parallel qui vous donne une exécution parallèle directe sur tous les cœurs sans nouvelles tâches.

Si vous devez devenir encore plus rapide, il se peut que la création de threads à la main et l'utilisation de structures de données optimisées à la main pour vous donner une vitesse maximale pour votre modèle d'accès soit la solution la plus performante. Mais il est peu probable que vous réussissiez à le faire car les API TPL et parallèle sont déjà fortement optimisées. Habituellement, vous devez utiliser l'une des nombreuses surcharges pour configurer vos tâches en cours d'exécution ou la classe Parallel pour y parvenir avec beaucoup moins de code.

Mais si vous avez un modèle de filetage non standard, il est possible que vous fassiez mieux sans utiliser TPL pour tirer le meilleur parti de vos cœurs. Même Stephen Toub a mentionné que les API TPL n'étaient pas conçues pour des performances ultra rapides, mais l'objectif principal était de faciliter le filetage pour le programmeur "moyen". Pour battre le TPL dans des cas spécifiques, vous devez être bien au-dessus de la moyenne et vous devez savoir beaucoup de choses sur les lignes de cache du processeur, la planification des threads, les modèles de mémoire, la génération de code JIT, ... pour trouver dans votre scénario spécifique quelque chose mieux.

12
Alois Kraus