Benchmarks et profiling Go : l'IA optimise-t-elle vraiment ton code ?
Ou : Pourquoi « j’ai optimisé ta fonction » est devenue la phrase la moins vérifiable du dev moderne
Tu pousses un commit avec une implémentation naïve : un tableau d’entiers, une boucle, un +=. Un collègue ouvre une PR trois heures plus tard. Titre : “Perf: optimize ComputeTotal, +40% throughput”. Le diff remplace ton for par un reduce fonctionnel, préalloue un slice, et substitue un strings.Builder à une concaténation. Le collègue, en l’occurrence, c’est Claude Code. Tu regardes. Ça compile. Les tests passent. Tu merges.
Deux semaines plus tard, la latence p99 du endpoint a augmenté de 15 %. Tu profiles. La nouvelle implémentation fait deux allocations supplémentaires par appel. Le strings.Builder a été placé à l’intérieur d’une boucle qui tourne cent fois par request, et réinitialise son buffer à chaque itération. Le « +40 % throughput » du commit message, c’était sur un micro-benchmark mesuré sur des inputs de taille fixe, dans une configuration qui ne ressemble à rien de ce qui tourne en prod.
C’est l’histoire de toutes les optimisations IA que je vois passer. Parfois elles sont réelles. Parfois elles sont neutres. Parfois ce sont des régressions déguisées en progrès. Le problème n’est pas l’IA — le problème, c’est que 80 % des devs qui lisent ces PRs ne mesurent pas. Ils regardent si le code « a l’air » plus rapide, ils lisent le commit message, ils mergent.
Dans les sept articles précédents de cette série — du manifeste sur le testing face à l’IA au fuzzing natif — on a testé la correction du code : est-ce qu’il fait ce qu’il est censé faire ? Il reste un angle qu’on n’a pas touché : est-ce qu’il le fait efficacement ? Est-ce que la version « optimisée » par l’IA est vraiment plus rapide, ou juste différente ?
La réponse, c’est testing.B, benchstat, et pprof. Go est probablement le langage mainstream le mieux équipé pour y répondre, et pourtant la compétence « lire un benchmark » reste étrangement rare.
Benchmarks Go avec testing.B : mesurer, comparer, profiler avant d’optimiser
testing.B : la même API que tes tests, avec un timer
Un benchmark Go ressemble à un test. Fichier _test.go, fonction qui commence par Benchmark, signature *testing.B au lieu de *testing.T. La seule différence en apparence : au lieu de vérifier une propriété, tu appelles la fonction à mesurer dans une boucle.
| |
Tu lances :
| |
Sortie typique :
BenchmarkConcatPlus-8 5132454 232.1 ns/op 80 B/op 4 allocs/op
BenchmarkConcatBuilder-8 8214036 142.6 ns/op 56 B/op 2 allocs/op
Lecture : 5132454 itérations ont été faites pour stabiliser la mesure. Chaque opération prend 232.1 ns, alloue 80 octets, fait 4 allocations heap. La version Builder est 1.6× plus rapide et fait moitié moins d’allocations. Ici, l’optimisation hypothétique serait réelle.
Deux points importants sur b.N. Ce n’est pas un nombre d’itérations que tu choisis — c’est celui que le framework choisit pour que la mesure soit fiable1. Il commence à 1, puis 100, puis 10 000, et augmente jusqu’à ce que le benchmark tourne assez longtemps (une seconde par défaut) pour que la variance soit raisonnable. Ton job, c’est de mettre tout ton code mesuré à l’intérieur de la boucle for i := 0; i < b.N; i++. Tout ce qui est dehors (le words := []string{...}) ne sera exécuté qu’une seule fois.
Sauf que… non. Pas exactement. Et c’est le premier piège.
Le piège numéro un : b.ResetTimer() et le setup qui gonfle les nombres
Le setup est exécuté une seule fois, mais le timer a démarré avant que tu entres dans la fonction. Si tu fais un setup coûteux — charger un fichier, initialiser une DB de test, parser un gros JSON — sa durée s’ajoute à la mesure. Le b.N est calibré en conséquence, donc tu crois mesurer ta fonction alors que tu mesures en réalité « setup + fonction ».
La solution classique :
| |
b.ResetTimer() remet à zéro le temps écoulé et les compteurs d’allocation mémoire. C’est crucial : sans ça, tu peux mesurer 10 allocations par op alors que ta fonction en fait 2, juste parce que le loader de fixture en a fait 8 avant que la boucle ne démarre.
Les cousins utiles : b.StopTimer() pause le chrono (pour un setup au milieu d’une itération), b.StartTimer() le redémarre. Rarement nécessaires, mais bons à connaître pour les cas où chaque itération a besoin d’un reset d’état qu’on ne veut pas compter.
Le piège numéro deux, c’est de l’oublier. L’IA oublie b.ResetTimer trois fois sur quatre. Si le benchmark est écrit après le code, sans setup apparent, personne ne le remarque. Le jour où quelqu’un ajoute un setup et ne pense pas à ResetTimer, le benchmark devient silencieusement biaisé. Rien ne casse. Les nombres sont juste un peu faux — et tu n’as aucun moyen de le savoir sans relire chaque benchmark.
Le piège numéro trois : le compilateur qui supprime ton benchmark
Tu écris :
| |
Tu t’attends à mesurer combien de temps Square prend. Tu obtiens 0.3 ns/op. Trop rapide pour être réel. C’est parce que le compilateur a vu que tu n’utilisais pas le résultat de Square(42), que l’argument est constant, et a purement supprimé l’appel. Ton benchmark mesure le temps d’une boucle vide.
La parade historique :
| |
Le sink global force le compilateur à considérer que le résultat est utilisé. Ça marche. C’est moche. Tout projet Go non trivial a deux ou trois variables sink qui traînent dans des fichiers _test.go parce qu’un jour quelqu’un a vu un benchmark mesurer zéro nanoseconde et a cherché pourquoi.
Cette parade a toujours été une rustine. En 2025, elle est devenue obsolète.
Go 1.24 et b.Loop() : la réécriture qu’il faut adopter
Go 1.24, sorti le 11 février 2025, introduit testing.B.Loop(), une nouvelle façon d’écrire la boucle de benchmark qui corrige d’un coup les pièges 1, 2 et 32.
| |
Trois propriétés magiques de b.Loop() qui en font la nouvelle norme :
1. Le setup avant b.Loop() est automatiquement exclu du timing. Plus besoin de b.ResetTimer() après un chargement de fixture. Le compilateur reconnaît le pattern et sait que seul le corps de la boucle doit être chronométré.
2. La dead code elimination est désactivée sur les arguments et les résultats du corps. Square(42) ne sera pas supprimé, même sans variable sink. Le runtime garde artificiellement les valeurs « vivantes » pour le compilateur. Tes micro-benchmarks mesurent enfin ce qu’ils prétendent mesurer.
3. La fonction de benchmark elle-même n’est exécutée qu’une fois par -count, au lieu de plusieurs fois comme avec b.N (le framework devait boucler pour calibrer, ce qui pouvait ré-exécuter le setup). Les opérations coûteuses (ouvrir une DB, charger un gros fichier) sont donc vraiment payées une seule fois.
Le code équivalent en ancien style :
| |
En nouveau style :
| |
Moins de lignes, moins de pièges, résultat plus robuste. La doc officielle recommande b.Loop() pour tous les nouveaux benchmarks. Les anciens qui marchent n’ont pas besoin d’être migrés tout de suite — mais si tu touches à un benchmark existant pour le corriger, migre-le en passant.
L’IA, au moment où j’écris, commence à peine à utiliser b.Loop() dans ses générations par défaut. Elle continue à produire for i := 0; i < b.N; i++ parce que c’est encore très majoritaire dans son corpus d’entraînement. Ça va s’inverser, mais d’ici là, chaque fois que tu vois une IA générer un benchmark, vérifie lequel des deux patterns elle a choisi. Et remplace par b.Loop() si tu peux.
Table-driven benchmarks : rendre la nuance visible
Comme pour les tests, le pattern table-driven fonctionne pour les benchmarks, avec b.Run() à la place de t.Run().
| |
Tu obtiens une matrice : l’implémentation × la taille d’input. Les résultats typiques ressemblent à ça :
BenchmarkConcat/plus/small_3_words-8 ~ 23 ns/op 16 B/op 1 alloc/op
BenchmarkConcat/builder/small_3_words-8 ~ 35 ns/op 64 B/op 2 allocs/op
BenchmarkConcat/plus/medium_50_words-8 ~ 1240 ns/op 800 B/op 10 allocs/op
BenchmarkConcat/builder/medium_50_words-8 ~ 580 ns/op 240 B/op 3 allocs/op
BenchmarkConcat/plus/large_500_words-8 ~ 28400 ns/op 120K B/op 50 allocs/op
BenchmarkConcat/builder/large_500_words-8 ~ 4200 ns/op 2.5K B/op 6 allocs/op
Lecture : à trois mots, += est plus rapide que strings.Builder. Ça surprend tout le monde à première vue, mais c’est logique — strings.Builder a un coût fixe d’initialisation qui domine quand il n’y a presque rien à concaténer. À cinq cents mots, Builder est 7× plus rapide et fait 8× moins d’allocations. Le seuil de bascule est quelque part entre dix et trente mots.
C’est exactement ce qu’on veut savoir. Quand quelqu’un te dit « toujours utiliser strings.Builder », la bonne réponse, c’est « non, seulement quand la taille d’input dépasse N, et N dépend du use case ». Les table-driven benchmarks rendent la nuance visible. Sans eux, tu retiens une règle approximative qui est fausse dans la moitié des cas.
benchstat : est-ce qu’une différence est significative ?
Tu lances ton benchmark avant ton changement, puis après. Tu compares visuellement. L’avant dit 142 ns/op, l’après dit 138 ns/op. C’est une amélioration ? Sur un seul run, tu n’en sais rien. La variance d’un benchmark Go peut facilement être de 3-5 % d’un run à l’autre, sans aucun changement de code, juste parce que le CPU a décidé de thermal-throttle au milieu ou qu’un cron a tourné.
La parade, c’est benchstat, l’outil officiel de l’équipe Go pour analyser la significativité statistique des différences de benchmark3.
Installation :
| |
Workflow :
| |
-count=10 tourne chaque benchmark dix fois, ce qui donne à benchstat de quoi calculer une médiane et un intervalle de confiance. La sortie ressemble à :
│ old.txt │ new.txt │
│ sec/op │ sec/op vs base │
ConcatPlus-8 232.1n ± 2% 234.0n ± 3% ~ (p=0.631 n=10)
ConcatBuilder-8 142.6n ± 1% 118.4n ± 2% -16.97% (p=0.000 n=10)
Lecture ligne par ligne. Pour ConcatPlus, la différence est de moins d’un pourcent avec une p-value de 0.631, c’est-à-dire 63 % de chance que la différence soit due au hasard. Benchstat affiche ~ pour dire « pas significatif ». Pour ConcatBuilder, l’amélioration est de 17 % avec p=0.000, ce qui veut dire que la probabilité que ce soit du bruit est inférieure à 0.1 %. C’est significatif.
Cette distinction est ce qui sépare une mesure sérieuse d’un pifomètre. Un senior qui dit « j’ai gagné 5 % » sans benchstat n’a rien mesuré du tout — il a observé une variation qui, sans test statistique, peut très bien être du bruit. Un senior qui dit « j’ai gagné 17 %, p < 0.001 » a une vraie donnée.
Benchstat gère aussi les comparaisons multi-fichiers (comparer trois versions du code à la fois), les colonnes customisées, le regroupement par plateforme. Pour 99 % des cas, le workflow « old.txt vs new.txt avec -count=10 » suffit.
pprof : savoir pourquoi c’est lent
Un benchmark te dit qu’une fonction est lente. Il ne te dit pas où elle est lente. Pour ça, il te faut un profiler, et Go en intègre un natif via pprof4.
| |
Tu entres dans un REPL interactif. Les commandes essentielles :
top— les dix fonctions qui consomment le plus de CPU.top -cum— idem, mais en temps cumulé (inclut les fonctions appelées par celle-ci).list <nom_fonction>— affiche le source de la fonction avec le temps passé sur chaque ligne.web— génère un graphe SVG du call graph et l’ouvre dans ton navigateur (nécessite Graphviz).
(pprof) top
Showing nodes accounting for 2.3s, 76.4% of 3.01s total
flat flat% sum% cum cum%
920ms 30.56% 30.56% 920ms 30.56% runtime.mallocgc
480ms 15.95% 46.51% 480ms 15.95% encoding/json.(*decodeState).literalStore
380ms 12.63% 59.14% 1.12s 37.21% encoding/json.(*decodeState).object
...
Ici, 30 % du temps est passé dans runtime.mallocgc. Ça veut dire que la fonction alloue beaucoup, et la plupart du coût est de l’allocation mémoire, pas du calcul. La piste d’optimisation n’est pas « rendre le parsing plus rapide », c’est « faire moins d’allocations ».
Le profil mémoire (go tool pprof mem.prof) suit la même logique mais pour les allocs heap. Il y a deux modes : -alloc_space (total alloué, même si libéré depuis) et -inuse_space (encore en mémoire au moment de la snapshot). Pour un profiling de performance, -alloc_space est presque toujours ce que tu veux — tu cherches la pression sur l’allocateur, pas juste la mémoire résidente.
pprof a plus de fonctionnalités qu’un article ne peut en couvrir. Le strict nécessaire : top, list, web. Si tu maîtrises ces trois commandes, tu couvres 95 % des besoins d’optimisation en Go. Pour aller plus loin : go tool pprof -http=:8080 cpu.prof lance une UI web complète incluant un onglet Flame Graph — c’est la même chose que les commandes texte, mais avec du pixel.
L’exercice : soumettre une « optimisation IA » à la mesure
Prends une fonction de ton projet. Demande à Claude (ou Copilot, ou Cursor) : « optimise cette fonction pour la performance ». Tu obtiendras presque toujours une version différente, souvent plus verbeuse, parfois plus courte, rarement accompagnée d’un benchmark.
Garde les deux versions. Écris un benchmark b.Loop() qui les compare avec des inputs réalistes — pas des tableaux de trois éléments, prends la vraie taille moyenne que tu vois en prod. Lance -count=10. Passe dans benchstat.
Il y a trois résultats possibles :
- Amélioration significative (p < 0.05, gain > 5 %). L’IA a vraiment optimisé. Tu merges.
- Pas de différence significative (
~, p > 0.1). L’IA a réécrit le code sans l’accélérer. Tu gardes la version la plus lisible. - Régression significative (p < 0.05, perte > 5 %). L’IA a rendu le code plus lent en le faisant paraître plus propre. Tu rejettes.
Le cas 3 arrive plus souvent qu’on ne le croit. Exemples classiques que j’ai vus :
strings.Builderplacé dans une boucle interne, réinitialisé à chaque itération → plus d’allocations que le+=original.appendremplacé par une création de slice pré-allouée, mais avec une taille mal estimée → grow-and-copy supplémentaire plus cher.- Channel remplaçant un mutex pour « être plus idiomatique Go » → chaque send/receive synchronise le scheduler, ce qui coûte plus cher que le mutex qu’on a remplacé.
- Goroutines ajoutées pour « paralléliser », mais la fonction tourne en quelques microsecondes → l’overhead de création de goroutine domine, la version parallèle est plus lente.
Tous ces patterns passent le code review visuel. Ils ont l’air d’optimisations. Ils ne sont détectés qu’en mesurant.
Junior Jules : J’ai fait optimiser notre fonction
SerializeReportpar Claude. Il l’a refactorée, c’est plus propre, les tests passent.Senior Sam : T’as benchmarké ?
Junior Jules : Le commit message dit « +30 % throughput ».
Senior Sam : Le commit message a été écrit par Claude.
Junior Jules : …
Senior Sam : Écris un
BenchmarkSerializeReportavecb.Loop. Lance-count=10surmain, switch sur la branche, relance.benchstat old.txt new.txt.(cinq minutes plus tard)
Junior Jules : Il dit
~avec p=0.472.Senior Sam : Donc c’est pas plus rapide. Le « +30 % » venait d’où ?
Junior Jules : D’un micro-benchmark que Claude a écrit, sur une struct à trois champs, alors qu’on sérialise des structs à trente champs en prod.
Senior Sam : Et la nouvelle version, lisibilité ?
Junior Jules : …plus verbeuse, en fait.
Senior Sam : Revert. Tu viens de gagner la capacité de ne pas merger du bruit.
Ce que tu peux faire maintenant
- Ajoute
b.Loop()à ton vocabulaire et arrête d’écrirefor i := 0; i < b.N; i++pour les nouveaux benchmarks. Sur Go 1.24 et plus,b.Loop()est simplement meilleur. Sur Go 1.22/1.23, tu as au moinsfor range b.N(plus compact) avecb.ResetTimer()juste après le setup. - Installe
benchstatune fois pour toutes :go install golang.org/x/perf/cmd/benchstat@latest. Ça prend dix secondes, et ça te suit pour toutes les comparaisons de benchmark à venir. - Règle : aucune PR qui prétend améliorer la performance ne passe sans benchstat. Pas « j’ai testé localement ». Pas « le commit message dit 30 % ».
benchstat old.txt new.txtou rien. - La prochaine fois que tu te demandes si ta fonction est lente, lance un profil :
go test -bench=XXX -cpuprofile=cpu.prof, puisgo tool pprof cpu.prof, puistopetlist. La cause du problème est presque toujours différente de ce que tu aurais deviné. - Pour chaque fonction critique de ton projet (parsing, sérialisation, calcul dans la hot path), écris un benchmark table-driven avec au moins trois tailles d’input réalistes. Commit-le. Il deviendra ton filet de régression : la prochaine fois que quelqu’un — IA ou humain — modifie ce code, le CI pourra comparer les nouveaux chiffres aux anciens.
La suite
On a parcouru toute la panoplie de testing Go isolé : table-driven, subtests, httptest, mocks, Testify, fuzzing, et maintenant benchmarks et profiling. Huit articles. On sait tester la correction d’une fonction, la détecter contre les inputs chaotiques, mesurer ses performances. On sait tout ce qu’il faut pour tester du code Go dans un fichier _test.go.
Sauf que ton code ne tourne pas dans un _test.go. Il tourne contre une vraie base de données. Un vrai cache Redis. Un vrai système de fichiers. Et à chaque fois qu’on a testé une dépendance externe dans cette série, on l’a mockée. C’est une simplification utile, mais ce n’est pas la réalité.
Dans le prochain article, on change de catégorie. On sort des tests unitaires et on entre dans les tests d’intégration : faire tourner un vrai PostgreSQL, un vrai Redis, dans les tests, en local et en CI, sans douleur. L’outil principal s’appelle Testcontainers-Go, et c’est le pont entre « tester vite » et « tester juste ».
b.Nest calibré dynamiquement : le framework démarre à 1 itération, mesure, puis augmente progressivement (1 → 100 → 10 000 → jusqu’à atteindre la durée cible, par défaut 1 seconde, configurable via-benchtime). C’est ce mécanisme qui force la fonction de benchmark à pouvoir être réexécutée plusieurs fois — d’où la règle « tout setup coûteux doit être dans la fonction, avecb.ResetTimer» dans l’ancien style.b.Loop()en Go 1.24+ brise cette contrainte : la fonction n’est appelée qu’une fois, seule la boucle interne itère. Détail d’implémentation visible dans le sourcetesting/benchmark.go. ↩︎L’introduction officielle de
testing.B.Loopest annoncée dans le release post Go 1.24 (11 février 2025) et détaillée dans l’article More predictable benchmarking with testing.B.Loop. Motivation initiale : l’issue #61515, où l’équipe Go documentait trois classes de bugs systémiques dans les benchmarks : setup compté dans le timing, dead code elimination, et ré-exécution partielle du setup lors du calibrage deb.N.b.Loop()corrige les trois. Le compilateur Go 1.24 détecte spécifiquement le patternfor b.Loop() { ... }et désactive l’élimination de code mort à l’intérieur — c’est une optimisation anti-optimisation, et c’est exactement ce qu’on veut pour un benchmark. ↩︎benchstatcalcule pour chaque benchmark la médiane et un intervalle de confiance à 95 %, puis teste la significativité de la différence entre deux colonnes via un test non paramétrique sur les rangs, avec un seuil α par défaut à 0.05. La p-value affichée est la probabilité que les deux distributions soient identiques ;p < 0.05est significatif,p ≥ 0.05est affiché~. L’intervalle après±donne la dispersion :142.6n ± 2%signifie médiane à 142.6 ns avec une dispersion de ±2 %. Si tes deux colonnes ont des dispersions de 10 % ou plus, ton benchmark est instable — réduis le bruit (ferme les apps, désactive le turbo boost CPU, augmente-count) avant d’interpréter quoi que ce soit. ↩︎go tool pprofest basé sur github.com/google/pprof et intégré à la toolchain Go depuis la réécriture de 2016. Il supporte quatre types de profils natifs (cpu,mem,block,mutex) et lit aussi les profils générés par le packagenet/http/pprofexposé par un serveur en production — pratique pour profiler un vrai service sous vraie charge sans toucher au code métier. Pour les graphesweb, il a besoin de Graphviz installé localement. La visualisation flame graph se fait viago tool pprof -http=:8080 cpu.profqui lance une UI web complète incluant l’onglet « Flame Graph ». ↩︎