web-dev-qa-db-fra.com

Benchmarking de petits échantillons de code en C #, cette implémentation peut-elle être améliorée?

Assez souvent sur SO je me retrouve à comparer de petits morceaux de code pour voir quelle implémentation est la plus rapide.

Très souvent, je vois des commentaires selon lesquels le code d'analyse comparative ne prend pas en compte le jitting ou le garbage collector.

J'ai la fonction de benchmarking simple suivante que j'ai lentement évoluée:

  static void Profile(string description, int iterations, Action func) {
        // warm up 
        func();
        // clean up
        GC.Collect();

        var watch = new Stopwatch();
        watch.Start();
        for (int i = 0; i < iterations; i++) {
            func();
        }
        watch.Stop();
        Console.Write(description);
        Console.WriteLine(" Time Elapsed {0} ms", watch.ElapsedMilliseconds);
    }

Usage:

Profile("a descriptions", how_many_iterations_to_run, () =>
{
   // ... code being profiled
});

Cette implémentation a-t-elle des défauts? Est-il suffisant de montrer que l'implémentation X est plus rapide que l'implémentation Y sur les itérations Z? Pouvez-vous penser à des façons d'améliorer cela?

EDIT Il est assez clair qu'une approche basée sur le temps (par opposition aux itérations), est préférée, est-ce que quelqu'un a des implémentations où les contrôles de temps n'ont pas d'impact sur les performances?

104
Sam Saffron

Voici la fonction modifiée: comme recommandé par la communauté, n'hésitez pas à modifier ce wiki de sa communauté.

static double Profile(string description, int iterations, Action func) {
    //Run at highest priority to minimize fluctuations caused by other processes/threads
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
    Thread.CurrentThread.Priority = ThreadPriority.Highest;

    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
    return watch.Elapsed.TotalMilliseconds;
}

Assurez-vous que vous compilez dans la version avec les optimisations activées et exécutez les tests en dehors de Visual Studio. Cette dernière partie est importante car le JIT stints ses optimisations avec un débogueur attaché, même en mode Release.

92
Sam Saffron

La finalisation ne sera pas nécessairement terminée avant le GC.Collect Retour. La finalisation est mise en file d'attente, puis exécutée sur un thread distinct. Ce fil pourrait toujours être actif pendant vos tests, affectant les résultats.

Si vous voulez vous assurer que la finalisation est terminée avant de commencer vos tests, vous pouvez appeler GC.WaitForPendingFinalizers , qui se bloquera jusqu'à ce que la file d'attente de finalisation soit effacée:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
22
LukeH

Si vous souhaitez supprimer les interactions GC de l'équation, vous pouvez exécuter votre appel de préchauffage après l'appel GC.Collect, pas avant. De cette façon, vous savez que .NET aura déjà suffisamment de mémoire allouée à partir du système d'exploitation pour l'ensemble de travail de votre fonction.

Gardez à l'esprit que vous effectuez un appel de méthode non intégrée pour chaque itération, alors assurez-vous de comparer les choses que vous testez à un corps vide. Vous devrez également accepter que vous ne pouvez chronométrer de manière fiable que des choses qui sont plusieurs fois plus longues qu'un appel de méthode.

En outre, en fonction du type de contenu que vous profilez, vous souhaiterez peut-être exécuter votre timing en fonction d'un certain temps plutôt que d'un certain nombre d'itérations - cela peut avoir tendance à conduire à des nombres plus facilement comparables sans avoir un très court terme pour la meilleure implémentation et/ou un très long pour le pire.

15
Jonathan Rupp

J'éviterais de passer le délégué du tout:

  1. L'appel délégué est ~ l'appel de méthode virtuelle. Pas bon marché: ~ 25% de la plus petite allocation de mémoire dans .NET. Si vous êtes intéressé par les détails, voir par exemple, ce lien .
  2. Les délégués anonymes peuvent conduire à l'utilisation de fermetures, que vous ne remarquerez même pas. Encore une fois, l'accès aux champs de fermeture est sensiblement plus p. Ex. accéder à une variable sur la pile.

Un exemple de code conduisant à l'utilisation de la fermeture:

public void Test()
{
  int someNumber = 1;
  Profiler.Profile("Closure access", 1000000, 
    () => someNumber + someNumber);
}

Si vous n'êtes pas au courant des fermetures, consultez cette méthode dans .NET Reflector.

6
Alex Yakunin

Je pense que le problème le plus difficile à surmonter avec des méthodes d'analyse comparative comme celle-ci est la prise en compte des cas Edge et de l'inattendu. Par exemple - "Comment les deux extraits de code fonctionnent-ils sous une charge CPU élevée/utilisation réseau/thrashing de disque/etc." Ils sont parfaits pour les vérifications logiques de base pour voir si un algorithme particulier fonctionne de manière significative plus rapidement qu'un autre. Mais pour tester correctement la plupart des performances du code, vous devez créer un test qui mesure les goulots d'étranglement spécifiques de ce code particulier.

Je dirais quand même que tester de petits blocs de code a souvent un faible retour sur investissement et peut encourager l'utilisation d'un code trop complexe au lieu d'un simple code maintenable. Écrire du code clair que d'autres développeurs, ou moi-même 6 mois plus tard, pouvons comprendre rapidement aura plus d'avantages en termes de performances qu'un code hautement optimisé.

6
Paul Alexander

J'appellerais func() plusieurs fois pour l'échauffement, pas seulement une.

5
Alexey Romanov

Suggestions d'amélioration

  1. Détecter si l'environnement d'exécution est bon pour l'analyse comparative (comme détecter si un débogueur est connecté ou si l'optimisation jit est désactivée, ce qui entraînerait des mesures incorrectes).

  2. Mesurer des parties du code indépendamment (pour voir exactement où se trouve le goulot d'étranglement).

  3. Comparaison de différentes versions/composants/morceaux de code (dans votre première phrase, vous dites "... comparer de petits morceaux de code pour voir quelle implémentation est la plus rapide.").

Concernant # 1:

  • Pour détecter si un débogueur est attaché, lisez la propriété System.Diagnostics.Debugger.IsAttached (N'oubliez pas de gérer également le cas où le débogueur n'est pas attaché initialement, mais est attaché après un certain temps).

  • Pour détecter si l'optimisation jit est désactivée, lisez la propriété DebuggableAttribute.IsJITOptimizerDisabled des assemblées concernées:

    private bool IsJitOptimizerDisabled(Assembly assembly)
    {
        return Assembly.GetCustomAttributes(typeof (DebuggableAttribute), false)
            .Select(customAttribute => (DebuggableAttribute) customAttribute)
            .Any(attribute => attribute.IsJITOptimizerDisabled);
    }
    

Concernant # 2:

Cela peut se faire de plusieurs manières. Une façon consiste à autoriser l'approvisionnement de plusieurs délégués, puis à les mesurer individuellement.

Concernant # 3:

Cela pourrait également se faire de plusieurs manières, et différents cas d'utilisation exigeraient des solutions très différentes. Si la référence est invoquée manuellement, l'écriture sur la console peut être correcte. Cependant, si le test est effectué automatiquement par le système de génération, l'écriture sur la console n'est probablement pas si fine.

Une façon de procéder consiste à renvoyer le résultat de référence sous la forme d'un objet fortement typé qui peut facilement être consommé dans différents contextes.


Etimo.Benchmarks

Une autre approche consiste à utiliser un composant existant pour effectuer les tests de performance. En fait, dans mon entreprise, nous avons décidé de publier notre outil de référence dans le domaine public. À la base, il gère le garbage collector, la gigue, les échauffements, etc., comme le suggèrent certaines des autres réponses ici. Il possède également les trois fonctionnalités que j'ai suggérées ci-dessus. Il gère plusieurs des problèmes discutés dans blog Eric Lippert .

Il s'agit d'un exemple de sortie où deux composants sont comparés et les résultats sont écrits sur la console. Dans ce cas, les deux composants comparés sont appelés "KeyedCollection" et "MultiplyIndexedKeyedCollection":

Etimo.Benchmarks - Sample Console Output

Il y a un package NuGet , un exemple de package NuGet et le code source est disponible sur GitHub . Il y a aussi un article de blog .

Si vous êtes pressé, je vous suggère de récupérer l'exemple de package et de simplement modifier les exemples de délégués selon vos besoins. Si vous n'êtes pas pressé, ce pourrait être une bonne idée de lire le billet de blog pour comprendre les détails.

4
Joakim

Vous devez également exécuter une passe de "préchauffage" avant la mesure réelle pour exclure le temps que le compilateur JIT consacre au jitting de votre code.

1
Alex Yakunin

Selon le code que vous comparez et la plate-forme sur laquelle il fonctionne, vous devrez peut-être tenir compte de comment l'alignement du code affecte les performances . Pour ce faire, il faudrait probablement un wrapper externe qui a exécuté le test plusieurs fois (dans des domaines ou des processus d'application distincts?), Certaines des premières fois appelant "code de remplissage" pour le forcer à être compilé JIT, afin de provoquer le code étant étalonné pour être aligné différemment. Un résultat de test complet donnerait les synchronisations dans le meilleur et le pire des cas pour les divers alignements de code.

1
Edward Brey

Si vous essayez d'éliminer l'impact de Garbage Collection sur le benchmark complet, vaut-il la peine de définir GCSettings.LatencyMode?

Si ce n'est pas le cas, et que vous souhaitez que l'impact des déchets créés dans func fasse partie du benchmark, ne devriez-vous pas également forcer la collecte à la fin du test (à l'intérieur du minuteur)?

1
Danny Tuppeny

Le problème fondamental de votre question est l'hypothèse qu'une seule mesure peut répondre à toutes vos questions. Vous devez mesurer plusieurs fois pour obtenir une image efficace de la situation et en particulier dans une langue de récupération des ordures comme C #.

Une autre réponse donne une bonne façon de mesurer les performances de base.

static void Profile(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

Cependant, cette mesure unique ne tient pas compte de la collecte des ordures. Un profil approprié représente en outre les performances les plus défavorables de la collecte des ordures réparties sur de nombreux appels (ce nombre est en quelque sorte inutile car le VM peut se terminer sans jamais collecter les déchets restants, mais il est toujours utile pour comparaison de deux implémentations différentes de func.)

static void ProfileGarbageMany(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

Et on peut également vouloir mesurer les performances les plus défavorables de la récupération de place pour une méthode qui n'est appelée qu'une seule fois.

static void ProfileGarbage(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

Mais plus important que de recommander des mesures supplémentaires spécifiques possibles au profil est l'idée qu'il faut mesurer plusieurs statistiques différentes et pas seulement un type de statistique.

0