Contactez-nous

Ecriture de benchmarks

Maîtrisez l'écriture de benchmarks en Go : mesurer la performance, analyser les résultats, optimiser le code et éviter les pièges pour des applications Go ultra-rapides.

Introduction à l'écriture de benchmarks en Go : Mesurer et optimiser la performance

L'écriture de benchmarks est une pratique essentielle pour mesurer et optimiser la performance de votre code Go. Les benchmarks permettent de quantifier objectivement la performance de portions de code spécifiques (fonctions, méthodes, packages, modules), d'identifier les goulots d'étranglement (bottlenecks) qui limitent la performance, de comparer différentes approches ou algorithmes, et de valider les gains d'optimisation après avoir appliqué des modifications au code.

En Go, le package testing, le même package que vous utilisez pour les tests unitaires, fournit également un support intégré et puissant pour l'écriture et l'exécution de benchmarks de performance. Le framework de benchmarking de Go est simple à utiliser, précis, et intégré à la chaîne d'outils Go, facilitant l'écriture de benchmarks et l'analyse des résultats.

Ce chapitre vous propose un guide expert sur l'écriture de benchmarks performants en Go. Nous allons explorer en détail comment écrire des fonctions de benchmark en Go en utilisant le package testing, comment exécuter les benchmarks avec la commande go test benchmark, comment interpréter et analyser les résultats des benchmarks (nanosecondes par opération, opérations par seconde, allocations mémoire, etc.), comment utiliser les benchmarks pour guider l'optimisation de votre code, et les bonnes pratiques pour écrire des benchmarks fiables, précis, et pertinents. Que vous souhaitiez optimiser une fonction critique en termes de performance, comparer différentes approches algorithmiques, ou mesurer l'impact de vos modifications de code sur la performance globale de votre application, ce guide complet vous fournira les clés pour maîtriser l'écriture de benchmarks en Go et faire de la performance une partie intégrante de votre processus de développement.

Fonctions de Benchmark : Syntaxe et structure de base

L'écriture d'un benchmark en Go est très similaire à l'écriture d'un test unitaire. Vous utilisez également le package testing, mais vous suivez des conventions de nommage et une structure légèrement différentes pour les fonctions de benchmark.

Conventions pour les fichiers de benchmark et les fonctions de benchmark :

  • Fichiers de benchmark : *_test.go : Comme pour les tests unitaires, les fichiers contenant les benchmarks en Go doivent avoir le suffixe _test.go dans leur nom de fichier (par exemple, mon_fonction_benchmark_test.go, calculatrice_benchmark_test.go). Les benchmarks sont généralement placés dans les mêmes fichiers que les tests unitaires (fichiers *_test.go) ou dans des fichiers de test séparés (par exemple, *_benchmark_test.go pour séparer clairement les benchmarks des tests unitaires).
  • Fonctions de benchmark : BenchmarkNomDeLaFonctionABenchmarker : Les fonctions de benchmark doivent commencer par le mot-clé func, avoir un nom commençant par Benchmark (majuscule) suivi du nom de la fonction à benchmarker (avec la première lettre en majuscule), et prendre en argument un seul paramètre de type *testing.B (pointeur vers un type testing.B fourni par le package testing, utilisé pour contrôler et mesurer le benchmark). Par exemple, pour benchmarker une fonction Additionner, le nom de la fonction de benchmark pourrait être BenchmarkAdditionner.

Structure d'une fonction de benchmark : Boucle for b.N

Une fonction de benchmark typique en Go suit généralement la structure suivante :

func BenchmarkNomDeLaFonctionABenchmarker(b *testing.B) {
    // 1. Préparation (setup) du benchmark (optionnel, exécuté une seule fois avant le benchmark)
    // ...

    // 2. Boucle de benchmark : for b.N
    for i := 0; i < b.N; i++ {
        // Code à benchmarker (la fonction ou la portion de code dont vous souhaitez mesurer la performance)
        FonctionABenchmarker(inputs)
    }

    // 3. Nettoyage (teardown) après le benchmark (optionnel, exécuté une seule fois après le benchmark)
    // ...
}

  • Boucle for i := 0; i < b.N; i++ { ... } : La boucle de benchmark : La partie essentielle d'une fonction de benchmark est la boucle for b.N. Le benchmark est exécuté à l'intérieur de cette boucle. La variable b.N (fournie par le framework testing) représente le nombre d'itérations pour lesquelles le benchmark doit être exécuté. Le framework testing ajuste automatiquement la valeur de b.N lors de l'exécution du benchmark pour obtenir des mesures de performance suffisamment précises et stables. Le code à benchmarker (l'appel à FonctionABenchmarker(inputs) dans l'exemple) doit être placé à l'intérieur de cette boucle for b.N, car c'est le code dont le temps d'exécution sera mesuré et reporté par le framework de benchmarking.
  • Préparation (setup) et Nettoyage (teardown) (optionnels) : Vous pouvez inclure du code de préparation (setup) avant la boucle for b.N (pour initialiser les données de test, configurer l'environnement de test, etc.) et du code de nettoyage (teardown) après la boucle for b.N (pour libérer les ressources, restaurer l'état initial, etc.). Le code de setup et de teardown est exécuté une seule fois, avant et après l'exécution de la boucle de benchmark, et n'est pas inclus dans le temps de benchmark mesuré. Utilisez le setup et le teardown pour initialiser et nettoyer l'environnement de test, mais assurez-vous que le code à benchmarker (celui dont vous souhaitez mesurer la performance) est bien placé à l'intérieur de la boucle for b.N.

Exemple de fonction de benchmark pour la fonction Additionner :

package calculatrice

import "testing"

// Fonction à benchmarker : Additionner deux entiers (même fonction que pour les tests unitaires)
func Additionner(a, b int) int {
    return a + b
}

// Fonction de benchmark pour la fonction 'Additionner'
func BenchmarkAdditionner(b *testing.B) {
    // Pas de setup spécifique pour cet exemple simple

    // Boucle de benchmark : for b.N (le code à benchmarker est à l'intérieur de la boucle)
    for i := 0; i < b.N; i++ {
        Additionner(2, 3) // Appel de la fonction à benchmarker
    }

    // Pas de teardown spécifique pour cet exemple simple
}

Exécution des benchmarks : go test -bench

Pour exécuter les benchmarks d'un package Go, utilisez la commande go test -bench dans le répertoire du package (ou dans le répertoire parent pour exécuter les benchmarks de tous les sous-packages). L'option -bench prend un pattern en argument pour sélectionner les benchmarks à exécuter. Utilisez -bench=. pour exécuter tous les benchmarks du package courant, ou -bench=NomDuBenchmark pour exécuter un benchmark spécifique (ou un groupe de benchmarks dont le nom correspond au pattern).

go test -bench=. ./calculatrice          # Exécuter tous les benchmarks du package 'calculatrice'
go test -bench=BenchmarkAdditionner ./... # Exécuter uniquement les benchmarks dont le nom contient 'BenchmarkAdditionner'
go test -bench=Additionner -benchmem ./... # Exécuter les benchmarks avec mesure de l'allocation mémoire (-benchmem)

La commande go test -bench va compiler les fichiers de test (*_test.go) du package, exécuter toutes les fonctions de benchmark (celles commençant par Benchmark), et afficher un résumé des résultats de benchmark (performance, allocations mémoire, etc.).

Interprétation des résultats de benchmarks : Performance, allocations mémoire et analyse

L'interprétation des résultats de benchmarks est essentielle pour comprendre la performance de votre code Go et pour quantifier les gains d'optimisation. La commande go test -bench affiche un résumé des résultats de benchmark dans la sortie standard, incluant des métriques clés de performance et d'allocation mémoire.

Métriques de performance courantes dans les résultats de benchmarks Go :

  • NomDuBenchmark : Le nom de la fonction de benchmark exécutée.
  • N : Le nombre d'itérations (boucle for b.N) pour lesquelles le benchmark a été exécuté. Le framework testing ajuste automatiquement b.N pour obtenir des mesures de performance suffisamment précises et stables (généralement en augmentant b.N jusqu'à atteindre un temps d'exécution total suffisant).
  • ns/op (Nanoseconds per operation) : Le temps d'exécution moyen par opération (par itération de la boucle for b.N), exprimé en nanosecondes (ns). C'est la métrique principale pour mesurer la performance brute du code benchmarké. Plus la valeur ns/op est faible, plus le code est rapide.
  • MB/s (Megabytes per second) (optionnel) : Le débit (throughput) du benchmark, exprimé en mégaoctets par seconde (MB/s). Cette métrique est affichée uniquement si le benchmark utilise la fonction b.SetBytes(n int64) pour indiquer la taille des données traitées par opération (par exemple, le nombre d'octets traités par itération). Le débit MB/s est utile pour mesurer la performance des opérations de traitement de données (lecture/écriture, parsing, sérialisation, compression, etc.). Plus la valeur MB/s est élevée, plus le débit est important.
  • allocs/op (Allocations per operation) : Le nombre moyen d'allocations mémoire effectuées par opération (par itération de la boucle for b.N). Cette métrique mesure l'intensité d'allocation mémoire du code benchmarké. Moins la valeur allocs/op est élevée, moins le code alloue de mémoire, ce qui est généralement préférable pour la performance et l'efficacité mémoire.
  • B/op (Bytes per operation) : Le nombre moyen d'octets alloués en mémoire par opération (par itération de la boucle for b.N). Cette métrique mesure la quantité de mémoire allouée par opération. Moins la valeur B/op est élevée, moins le code consomme de mémoire, ce qui est généralement préférable pour la performance et l'efficacité mémoire.

Exemple d'interprétation des résultats de benchmark :

Supposons que vous exécutez le benchmark BenchmarkAdditionner (exemple précédent) avec la commande go test -bench=BenchmarkAdditionner ./calculatrice et que vous obtenez la sortie suivante :

goos: darwin
 goarch: arm64
 pkg: certiquizz/calculatrice
 BenchmarkAdditionner-10              1000000000               0.2854 ns/op          0 B/op          0 allocs/op
 PASS
 ok      certiquizz/calculatrice   0.294s

Interprétation des résultats :

  • BenchmarkAdditionner-10 : Nom du benchmark exécuté (BenchmarkAdditionner), suivi du nombre de coeurs CPU utilisés pour le benchmark (-10, ici 10 coeurs, valeur par défaut).
  • 1000000000 : Nombre d'itérations (b.N) pour lesquelles le benchmark a été exécuté (1 milliard d'itérations).
  • 0.2854 ns/op : Temps d'exécution moyen par opération : 0.2854 nanosecondes par opération (par appel à la fonction Additionner). C'est une valeur très faible, indiquant que la fonction Additionner est extrêmement rapide.
  • 0 B/op : Nombre moyen d'octets alloués en mémoire par opération : 0 octets par opération. Indique que la fonction Additionner n'alloue pas de mémoire sur le tas (heap) lors de son exécution, ce qui est excellent pour la performance et l'efficacité mémoire.
  • 0 allocs/op : Nombre moyen d'allocations mémoire par opération : 0 allocation par opération. Confirme l'absence d'allocations mémoire par la fonction Additionner.

En analysant les résultats de benchmarks, vous pouvez quantifier objectivement la performance de votre code, identifier les zones de code lentes ou gourmandes en ressources (goulots d'étranglement), et mesurer les gains d'optimisation après avoir appliqué des modifications au code. Les benchmarks sont un outil indispensable pour l'optimisation de la performance en Go.

Benchmarking comparatif : Mesurer l'impact des optimisations

L'un des principaux intérêts des benchmarks est de permettre le benchmarking comparatif : mesurer et comparer la performance de différentes versions de votre code (avant et après une optimisation), de différentes approches algorithmiques, ou de différentes implémentations d'une même fonctionnalité. Le benchmarking comparatif vous permet de quantifier objectivement l'impact de vos modifications de code sur la performance, de valider les gains d'optimisation, et de choisir l'approche la plus performante.

Workflow de benchmarking comparatif :

  1. Ecrire un benchmark de référence (baseline benchmark) : Commencez par écrire un benchmark de référence (baseline benchmark) pour la version non optimisée de votre code (ou pour l'approche algorithmique de référence que vous souhaitez comparer). Exécutez ce benchmark et enregistrez les résultats (métriques de performance).
  2. Implémenter l'optimisation ou l'approche alternative : Implémentez l'optimisation que vous souhaitez tester (par exemple, en modifiant l'algorithme, en optimisant le code, en réduisant les allocations mémoire, en utilisant la concurrence, etc.), ou implémentez l'approche algorithmique alternative que vous souhaitez comparer.
  3. Ecrire un nouveau benchmark (benchmark optimisé) : Ecrivez un nouveau benchmark (ou modifiez le benchmark existant) pour mesurer la performance de la version optimisée de votre code (ou de l'approche alternative). Assurez-vous que le nouveau benchmark teste exactement la même fonctionnalité et utilise les mêmes inputs que le benchmark de référence, afin de comparer des pommes avec des pommes.
  4. Exécuter les benchmarks comparatifs : Exécutez les deux benchmarks (benchmark de référence et benchmark optimisé) en utilisant la commande go test -bench. Analysez et comparez les résultats des deux benchmarks (ns/op, B/op, allocs/op, MB/s si applicable) pour quantifier le gain de performance obtenu grâce à l'optimisation ou pour comparer la performance des différentes approches.
  5. Interpréter et analyser les résultats comparatifs : Analysez les différences de performance mesurées par les benchmarks comparatifs. Calculez le pourcentage d'amélioration (ou de dégradation) de la performance grâce à l'optimisation. Déterminez si le gain de performance obtenu est significatif et justifie la complexité ou les compromis potentiels introduits par l'optimisation. Utilisez les résultats des benchmarks comparatifs pour prendre des décisions éclairées sur l'opportunité d'appliquer ou non l'optimisation, ou pour choisir l'approche la plus performante parmi plusieurs alternatives.

Outils pour faciliter le benchmarking comparatif : benchstat

L'outil benchstat (disponible via go get golang.org/x/perf/cmd/benchstat) est un outil en ligne de commande très pratique pour faciliter l'analyse comparative des résultats de benchmarks Go. benchstat prend en entrée les sorties de deux (ou plusieurs) exécutions de go test -bench (benchmark de référence et benchmark optimisé) et affiche un tableau comparatif des métriques de performance (ns/op, B/op, allocs/op, MB/s), en calculant automatiquement les différences et les pourcentages de variation entre les benchmarks, et en indiquant si les différences sont statistiquement significatives.

Exemple de benchmarking comparatif avec benchstat :

Supposons que vous ayez deux versions de votre code (version non optimisée et version optimisée) et que vous ayez exécuté les benchmarks pour chaque version, en enregistrant les sorties dans deux fichiers (benchmark_baseline.txt et benchmark_optimized.txt). Pour comparer les résultats des benchmarks avec benchstat, exécutez la commande suivante :

benchstat benchmark_baseline.txt benchmark_optimized.txt

benchstat affichera un tableau comparatif des résultats, mettant en évidence les gains ou les pertes de performance obtenus grâce à l'optimisation, et indiquant si les différences sont statistiquement significatives (avec les indications +-% et delta dans la sortie de benchstat).

Le benchmarking comparatif avec benchstat est une technique essentielle pour valider objectivement l'impact de vos optimisations de code et pour prendre des décisions éclairées sur la performance de vos applications Go.

Bonnes pratiques pour l'écriture de benchmarks performants et fiables

Pour écrire des benchmarks performants, fiables, et utiles pour l'optimisation de votre code Go, voici quelques bonnes pratiques à suivre :

  • Benchmarker le code pertinent et représentatif : Concentrez vos efforts de benchmarking sur le code critique en termes de performance et sur les zones chaudes (hotspots) de votre application (celles identifiées par le profiling). Benchmarker du code non critique ou non représentatif de la charge de travail réelle de votre application peut être une perte de temps et ne pas apporter de gains significatifs en termes de performance globale.
  • Isoler le code benchmarké (setup et teardown) : Isolez clairement le code à benchmarker (celui placé à l'intérieur de la boucle for b.N) du code de setup (préparation) et de teardown (nettoyage), en plaçant le code de setup et de teardown en dehors de la boucle for b.N. Le code de setup et de teardown est exécuté une seule fois, et son temps d'exécution ne doit pas être inclus dans les mesures de performance du benchmark.
  • Utiliser des données de test réalistes et représentatives : Utilisez des données de test (inputs) pour vos benchmarks qui soient aussi réalistes et représentatives que possible de la charge de travail réelle de votre application en production. Des données de test non réalistes ou non représentatives peuvent conduire à des résultats de benchmark biaisés ou non pertinents pour l'optimisation de la performance en conditions réelles.
  • Exécuter les benchmarks de manière répétée et stable : Exécutez vos benchmarks plusieurs fois et sur une durée suffisamment longue (augmentez la valeur de b.N si nécessaire) pour obtenir des mesures de performance stables et précises. Les performances peuvent varier légèrement d'une exécution à l'autre en raison de facteurs externes (charge système, variations de température du CPU, garbage collection, etc.). Répéter l'exécution plusieurs fois et calculer la moyenne et l'écart type des résultats permet d'obtenir des mesures plus robustes et plus fiables.
  • Comparer les benchmarks avec benchstat pour quantifier les gains d'optimisation : Utilisez l'outil benchstat pour comparer les résultats de benchmarks entre différentes versions de code et quantifier objectivement les gains ou les pertes de performance. benchstat permet de déterminer si les différences de performance sont statistiquement significatives et de prendre des décisions éclairées sur l'opportunité d'appliquer ou non les optimisations.
  • Analyser les allocations mémoire (-benchmem) et utiliser le Memory Profiler (pprof) : Mesurez et analysez les allocations mémoire de vos benchmarks avec l'option -benchmem de go test. Utilisez le Memory Profiler (pprof) pour identifier les fonctions qui allouent le plus de mémoire et détecter d'éventuelles fuites de mémoire ou allocations excessives. La réduction des allocations mémoire inutiles peut améliorer significativement la performance et l'efficacité mémoire de vos applications Go.
  • Combiner benchmarking et profiling pour une optimisation itérative : Utilisez une approche d'optimisation itérative basée sur le cycle mesurer (benchmarking) -> analyser (profiling) -> optimiser -> re-mesurer (benchmarking). Commencez par mesurer la performance de référence avec des benchmarks, identifiez les goulots d'étranglement avec le profiling, appliquez des optimisations ciblées sur les zones chaudes, et re-mesurez la performance avec les benchmarks pour valider les gains d'optimisation. Répétez ce cycle itérativement pour optimiser progressivement la performance de votre code.

En appliquant ces bonnes pratiques, vous écrirez des benchmarks performants, fiables et utiles pour l'optimisation de vos applications Go, et vous ferez de la performance une partie intégrante de votre processus de développement, en visant un code Go ultra-rapide et efficace.