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

104

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 d'analyse comparative 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 présente-t-elle des défauts? Est-ce suffisant pour montrer que l'implémentation X est plus rapide que l'implémentation Y sur les itérations Z? Pouvez-vous penser à des moyens 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 vérifications du temps n'ont pas d'impact sur les performances?

Sam Safran
la source
Voir également BenchmarkDotNet .
Ben Hutchison

Réponses:

95

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

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 de compiler dans Release avec les optimisations activées et d'exécuter les tests en dehors de Visual Studio . Cette dernière partie est importante car le JIT effectue ses optimisations avec un débogueur attaché, même en mode Release.

Sam Saffron
la source
Vous voudrez peut-être dérouler la boucle un certain nombre de fois, par exemple 10, pour minimiser la surcharge de la boucle.
Mike Dunlavey
2
Je viens de mettre à jour pour utiliser Stopwatch.StartNew. Pas un changement fonctionnel, mais enregistre une ligne de code.
LukeH
1
@Luke, grand changement (j'aimerais pouvoir attribuer +1). @Mike je ne suis pas sûr, je soupçonne que la surcharge de virtualcall sera beaucoup plus élevée que la comparaison et l'affectation, donc la différence de performances sera négligeable
Sam Saffron
Je vous proposerais de passer le nombre d'itérations à l'action et de créer la boucle à cet endroit (peut-être même déroulée). Si vous mesurez un fonctionnement relativement court, c'est la seule option. Et je préférerais voir la métrique inverse - par exemple, le nombre de passes / s.
Alex Yakunin
2
Que pensez-vous de l'affichage du temps moyen. Quelque chose comme ceci: Console.WriteLine ("Temps moyen écoulé {0} ms", watch.ElapsedMilliseconds / iterations);
rudimenter
22

La finalisation ne sera pas nécessairement terminée avant les GC.Collectretours. La finalisation est mise en file d'attente puis exécutée sur un thread distinct. Ce fil pourrait encore ê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 bloquera jusqu'à ce que la file d'attente de finalisation soit effacée:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
LukeH
la source
10
Pourquoi encore GC.Collect()une fois?
colinfang
7
@colinfang Parce que les objets en cours de "finalisation" ne sont pas GC par le finaliseur. La seconde Collectest donc là pour s'assurer que les objets «finalisés» sont également collectés.
MAV
15

Si vous souhaitez supprimer les interactions GC de l'équation, vous souhaiterez peut-être exécuter votre appel "warm up" 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 par le 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é pour chaque itération, alors assurez-vous de comparer les éléments que vous testez à un corps vide. Vous devrez également accepter que vous ne pouvez chronométrer de manière fiable que les choses qui sont plusieurs fois plus longues qu'un appel de méthode.

De plus, en fonction du type de contenu que vous profilez, vous voudrez peut-être exécuter votre chronométrage pendant un certain temps plutôt que pendant 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.

Jonathan Rupp
la source
1
bons points, auriez-vous en tête une implémentation basée sur le temps?
Sam Saffron
6

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

  1. L'appel de délégué est ~ 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, consultez 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, accéder aux champs de fermeture est sensiblement plus que par exemple accéder à une variable sur la pile.

Un exemple de code menant à 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, jetez un œil à cette méthode dans .NET Reflector.

Alex Yakunin
la source
Points intéressants, mais comment créeriez-vous une méthode Profile () réutilisable si vous ne passez pas un délégué? Existe-t-il d'autres moyens de transmettre du code arbitraire à une méthode?
Ash
1
Nous utilisons "en utilisant (nouvelle mesure (...)) {... code mesuré ...}". Nous obtenons donc un objet Measurement implémentant IDisposable au lieu de passer le délégué. Voir code.google.com/p/dataobjectsdotnet/source/browse/Xtensive.Core/…
Alex Yakunin
Cela n'entraînera aucun problème avec les fermetures.
Alex Yakunin
3
@AlexYakunin: votre lien semble rompu. Pourriez-vous inclure le code de la classe Mesure dans votre réponse? Je soupçonne que peu importe comment vous l'implémentez, vous ne pourrez pas exécuter le code à profiler plusieurs fois avec cette approche IDisposable. Cependant, il est en effet très utile dans les situations où vous souhaitez mesurer les performances de différentes parties d'une application complexe (entrelacées), à condition de garder à l'esprit que les mesures peuvent être inexactes et incohérentes lorsqu'elles sont exécutées à des moments différents. J'utilise la même approche dans la plupart de mes projets.
ShdNx
1
L'obligation d'exécuter des tests de performances plusieurs fois est vraiment importante (échauffement + mesures multiples), j'ai donc opté pour une approche avec délégué également. De plus, si vous n'utilisez pas de fermetures, l'appel de délégué est plus rapide que l'appel de méthode d'interface dans le cas de IDisposable.
Alex Yakunin
6

Je pense que le problème le plus difficile à surmonter avec des méthodes d'analyse comparative comme celle-ci est de tenir compte des cas extrêmes et des imprévus. Par exemple - "Comment les deux extraits de code fonctionnent-ils sous une charge CPU élevée / utilisation du réseau / battage de disque / etc." Ils sont parfaits pour les vérifications logiques de base afin de voir si un algorithme particulier fonctionne beaucoup 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 peu de retour sur investissement et peut encourager l'utilisation d'un code trop complexe au lieu d'un simple code maintenable. Ecrire un 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é.

Paul Alexander
la source
1
significatif est l'un de ces termes qui est vraiment chargé. parfois avoir une implémentation 20% plus rapide est important, parfois elle doit être 100 fois plus rapide pour être significative. D'accord avec vous sur la clarté voir: stackoverflow.com/questions/1018407/…
Sam Saffron
Dans ce cas, ce n'est pas tout ce qui est chargé. Vous comparez une ou plusieurs implémentations simultanées et si la différence de performances de ces deux implémentations n'est pas statistiquement significative, cela ne vaut pas la peine de s'engager dans la méthode la plus complexe.
Paul Alexander
5

J'appelais func()plusieurs fois pour l'échauffement, pas une seule.

Alexey Romanov
la source
1
L'intention était de s'assurer que la compilation jit est effectuée, quel avantage retirez-vous d'appeler func plusieurs fois avant la mesure?
Sam Saffron
3
Donner au JIT une chance d'améliorer ses premiers résultats.
Alexey Romanov
1
le .NET JIT n'améliore pas ses résultats au fil du temps (comme le fait Java). Il ne convertit une méthode d'IL en Assembly qu'une seule fois, lors du premier appel.
Matt Warren
4

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 attaché 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 le n ° 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 initialement pas attaché, mais est attaché après un certain temps).

  • Pour détecter si l'optimisation jit est désactivée, lisez la propriété DebuggableAttribute.IsJITOptimizerDisableddes assemblys appropriés:

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

Concernant le n ° 2:

Cela peut être fait de plusieurs manières. Une façon est de permettre à plusieurs délégués d'être fournis, puis de mesurer ces délégués individuellement.

Concernant le n ° 3:

Cela pourrait également être fait de nombreuses manières, et différents cas d'utilisation exigeraient des solutions très différentes. Si le benchmark est appelé manuellement, l'écriture sur la console peut être correcte. Cependant, si le benchmark est effectué automatiquement par le système de construction, alors l'écriture sur la console n'est probablement pas si bien.

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 utilisé dans différents contextes.


Etimo.Benchmarks

Une autre approche consiste à utiliser un composant existant pour effectuer les benchmarks. 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 ramasse-miettes, la gigue, les échauffements, etc., comme le suggèrent certaines des autres réponses ici. Il présente également les trois fonctionnalités que j'ai suggérées ci-dessus. Il gère plusieurs des problématiques abordées dans le blog d'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 - Exemple de sortie de console

Il existe 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 d'obtenir l'exemple de package et de simplement modifier les exemples de délégués si nécessaire. Si vous n'êtes pas pressé, il peut être judicieux de lire l'article du blog pour en comprendre les détails.

Joakim
la source
1

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

Alex Yakunin
la source
il est effectué avant la mesure
Sam Saffron
1

Selon le code que vous comparez et la plate-forme sur laquelle il s'exécute, vous devrez peut-être tenir compte de la façon dont 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 d'application ou des processus distincts?), Parfois en appelant d'abord le «code de remplissage» pour le forcer à être compilé en JIT, de manière à ce que le code soit benchmarkés pour être alignés différemment. Un résultat de test complet donnerait le meilleur et le pire des cas pour les différents alignements de code.

Edward Brey
la source
1

Si vous essayez d'éliminer l'impact du nettoyage de la mémoire du benchmark terminé, cela vaut-il la peine de le définir GCSettings.LatencyMode?

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

Danny Tuppeny
la source
0

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 un langage garbage collector 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 du garbage collection. Un profil approprié représente également les pires performances du garbage collection réparties sur de nombreux appels (ce nombre est en quelque sorte inutile car la machine virtuelle peut s'arrêter sans jamais collecter les déchets restants, mais est toujours utile pour comparer 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 pires performances du garbage collection 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 pour établir le profil est l'idée qu'il faut mesurer plusieurs statistiques différentes et pas seulement un type de statistique.

Steven Stewart-Gallus
la source