Mesurer la performance d’un bout de code .NET sans BenchmarkDotNet, c’est tenter de chronométrer un Usain Bolt avec une horloge solaire. La bibliothèque, projet officiel de la .NET Foundation adoptée par plus de 22 000 dépôts GitHub (dont le runtime .NET lui-même), expose une API qui transforme une méthode en mesure rigoureuse, statistiquement valide, comparant runtimes, GC modes, environnements x86/ARM/AOT, et qui restitue allocations mémoire, latences percentiles, et même le code assembleur généré. La version 0.15.0 de février 2026 ajoute le support .NET 10, AVX-512 vectorisation, et la visualisation par box plot. Ce tutoriel reprend l’outillage pas-à-pas avec des benchmarks réels.
Prérequis
- .NET 10 LTS installé
- Notions C# moderne (cf. C# 14 features)
- Un IDE avec support .NET (Visual Studio, Rider, ou VS Code + C# Dev Kit)
- Temps estimé : 75 minutes
Étape 1 — Créer un projet de benchmarks dédié
Un benchmark ne doit jamais vivre dans le même assembly que le code production : il doit tourner en mode Release sans symboles de debug, avec un environnement contrôlé. La convention est un projet console séparé qu’on lance avec dotnet run -c Release.
dotnet new console -n MonApp.Benchmarks -o benchmarks
cd benchmarks
dotnet add package BenchmarkDotNet --version 0.15.0
dotnet add reference ../src/MonApp.Core/MonApp.Core.csproj
# Program.cs minimal
cat > Program.cs <<'EOF'
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<StringConcatBenchmark>();
EOF
Le projet doit cibler le ou les runtimes qu’on veut mesurer. <TargetFrameworks>net10.0;net8.0</TargetFrameworks> permet de comparer côte-à-côte deux versions de .NET sur le même code. <Optimize>true</Optimize> et <DebugType>portable</DebugType> garantissent que le code mesure des optimisations réelles.
Étape 2 — Premier benchmark : trois implémentations de concaténation
La classe contient les méthodes à comparer, annotées [Benchmark]. BenchmarkDotNet répète chaque méthode des milliers de fois, écarte les outliers, vérifie la stabilité statistique, et produit un rapport.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using System.Text;
[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class StringConcatBenchmark
{
private readonly string[] _mots = Enumerable.Range(0, 100)
.Select(i => $"mot-{i}").ToArray();
[Benchmark(Baseline = true)]
public string ConcatPlus()
{
var resultat = "";
foreach (var m in _mots) resultat += m + " ";
return resultat;
}
[Benchmark]
public string ConcatStringBuilder()
{
var sb = new StringBuilder();
foreach (var m in _mots) sb.Append(m).Append(' ');
return sb.ToString();
}
[Benchmark]
public string ConcatStringJoin() => string.Join(' ', _mots) + ' ';
[Benchmark]
public string ConcatStringConcat() => string.Concat(_mots.Select(m => m + " "));
}
Trois attributs cruciaux. [MemoryDiagnoser] ajoute les colonnes Gen0/Gen1/Gen2 (nombre de collectes GC) et Allocated (octets alloués) — souvent plus révélateur que la durée seule. [Orderer(FastestToSlowest)] trie le rapport pour mettre le gagnant en haut. Baseline = true sur la version « naïve » sert de référence pour les ratios. Sur 100 mots, la version += alloue typiquement 100× plus de mémoire que StringBuilder avec une latence 50× plus longue.
Étape 3 — Lancer et interpréter le rapport
L’exécution prend de 30 secondes à plusieurs minutes selon le nombre de benchmarks. BenchmarkDotNet écrit le rapport en Markdown, HTML et CSV dans BenchmarkDotNet.Artifacts/results/.
cd benchmarks
dotnet run -c Release
# Pendant l'exécution, BenchmarkDotNet imprime :
# - Phase Pilot (estimation du nombre d'itérations)
# - Phase Warmup
# - Phase Actual measurement
# - Statistical analysis
# Exemple de sortie :
# | Method | Mean | Error | StdDev | Ratio | Allocated |
# |-------------------- |-----------:|---------:|---------:|------:|----------:|
# | ConcatStringJoin | 1.234 μs | 0.012 μs | 0.011 μs | 0.05 | 968 B |
# | ConcatStringBuilder | 1.567 μs | 0.015 μs | 0.014 μs | 0.06 | 1.45 KB |
# | ConcatStringConcat | 2.890 μs | 0.024 μs | 0.022 μs | 0.12 | 2.78 KB |
# | ConcatPlus | 24.567 μs | 0.198 μs | 0.176 μs | 1.00 | 41.23 KB |
Trois lectures clés. Mean est la moyenne (en nanosecondes, microsecondes, ou millisecondes selon l’ordre de grandeur). Error est l’erreur standard à 99,9 % — si elle dépasse 5 % de la mean, refaire le benchmark dans des conditions plus stables (fermer Chrome, désactiver l’antivirus, brancher le laptop). Ratio donne immédiatement le coût relatif par rapport au baseline.
Étape 4 — Paramétrisation avec [Params]
Pour comparer une méthode sur plusieurs tailles d’entrée, l’attribut [Params(...)] sur un champ ou propriété fait varier la valeur entre exécutions. Le rapport affiche une ligne par couple (méthode, paramètre).
[MemoryDiagnoser]
public class HashBenchmark
{
[Params(10, 100, 1_000, 10_000, 100_000)]
public int N { get; set; }
private byte[] _data = [];
[GlobalSetup]
public void Setup() => _data = new byte[N].Tap(d => Random.Shared.NextBytes(d));
[Benchmark(Baseline = true)]
public byte[] Sha256() => SHA256.HashData(_data);
[Benchmark]
public byte[] Sha512() => SHA512.HashData(_data);
[Benchmark]
public byte[] Md5() => MD5.HashData(_data);
}
Le [GlobalSetup] initialise les données une fois par couple (méthode, N) avant la phase de mesure — son temps n’est pas inclus. Pour des données regénérées entre itérations, utiliser [IterationSetup] à la place. Cette paramétrisation permet de visualiser des courbes : SHA-256 est typiquement 2× plus rapide que SHA-512 sur petites entrées, mais l’écart se réduit sur grandes (effet vectorisation).
Étape 5 — Multi-runtime avec [SimpleJob]
Pour comparer la perf d’un même code sur .NET 8, 9, 10 ou entre JIT et AOT, l’attribut [SimpleJob] avec RuntimeMoniker configure les environnements d’exécution. BenchmarkDotNet build et lance le benchmark séparément sur chacun.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
[SimpleJob(RuntimeMoniker.Net80, baseline: true)]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net100)]
[SimpleJob(RuntimeMoniker.NativeAot10)]
[MemoryDiagnoser]
public class JsonBenchmark
{
private readonly Article _article = new("Hello", 42, DateTime.UtcNow);
[Benchmark]
public string Serialize() => JsonSerializer.Serialize(_article);
}
public record Article(string Titre, int Vues, DateTime CreeLe);
Le rapport ajoute une colonne Runtime et permet de visualiser les gains de migration. Typiquement, sérialiser via System.Text.Json est 20-40 % plus rapide sur .NET 10 que sur .NET 8 (effet vectorisation AVX-512). L’AOT démarre instantanément mais plafonne à -10/-20 % de débit soutenu vs JIT (pas de tiered compilation).
Étape 6 — DisassemblyDiagnoser : voir l’assembleur généré
Pour valider qu’une optimisation manuelle (loop unrolling, vectorisation explicite, branchless) est bien appliquée par le JIT, [DisassemblyDiagnoser] imprime le code assembleur des méthodes mesurées. Indispensable pour les développeurs de bibliothèques de bas niveau.
[DisassemblyDiagnoser(printSource: true, maxDepth: 2)]
[MemoryDiagnoser]
public class SumBenchmark
{
private int[] _data = new int[1024];
[GlobalSetup]
public void Setup() => Random.Shared.NextBytes(MemoryMarshal.AsBytes(_data.AsSpan()));
[Benchmark]
public int SumNaive()
{
int s = 0;
foreach (var v in _data) s += v;
return s;
}
[Benchmark]
public int SumVectorized() =>
System.Numerics.Tensors.TensorPrimitives.Sum(_data.AsSpan());
}
Le rapport inclut, pour chaque méthode, le code x86-64 ou ARM64 produit par le JIT. On y voit les instructions VPADDD/VMOVDQU (AVX-512), les boucles déroulées, les vérifications de bornes éliminées. C’est ainsi qu’on confirme — ou qu’on infirme — qu’un changement de code produit réellement l’effet attendu côté machine.
Étape 7 — Comparer GC et environnements
Les configurations GC influencent dramatiquement les charges allocant beaucoup. [GcServer], [GcConcurrent], et le mode ServerGC changent l’équilibre débit/latence. BenchmarkDotNet permet de tester chaque combinaison.
[Config(typeof(GcConfig))]
[MemoryDiagnoser]
public class GcBenchmark
{
[Benchmark]
public List<byte[]> AllouerBeaucoup()
{
var list = new List<byte[]>();
for (int i = 0; i < 1000; i++)
list.Add(new byte[1024]);
return list;
}
}
public class GcConfig : ManualConfig
{
public GcConfig()
{
AddJob(Job.Default.WithGcServer(true).WithGcConcurrent(true).WithId("ServerConcurrent"));
AddJob(Job.Default.WithGcServer(true).WithGcConcurrent(false).WithId("ServerNonConcurrent"));
AddJob(Job.Default.WithGcServer(false).WithGcConcurrent(true).WithId("WorkstationConcurrent"));
}
}
Sur une charge allouant 1 Mo par appel, ServerGC + Concurrent donne typiquement 30-50 % de débit en plus que Workstation + Concurrent, au prix de pauses GC légèrement plus longues. C’est le bon choix pour les API web. Pour des outils CLI courts, Workstation reste meilleur car il évite le surcoût d’initialisation Server GC.
Étape 8 — Intégration CI et historique
Pour traquer les régressions de performance entre commits, on lance les benchmarks en CI (sur un runner dédié et stable) et on archive les résultats. La librarie complémentaire BenchmarkDotNet.Diagnostics.Windows ou des outils tiers (ResultsComparer) permettent de comparer deux runs.
# .github/workflows/bench.yml
name: Benchmarks
on:
pull_request:
schedule:
- cron: "0 2 * * 1" # lundi 02h00 UTC
jobs:
benchmark:
runs-on: self-hosted # runner dédié avec CPU stable
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- run: dotnet run -c Release --project benchmarks -- --exporters json --filter '*'
- uses: actions/upload-artifact@v5
with:
name: bench-results
path: benchmarks/BenchmarkDotNet.Artifacts/results/
Trois pratiques pour des benchmarks CI utiles. Runner dédié : les VM partagées de GitHub Actions ont des perfs très variables, faussant les mesures. Pas en concurrence avec d’autres jobs : un benchmark doit avoir la machine pour lui seul. Filtres ciblés : ne lancez pas toute la suite en CI, juste les benchmarks critiques (10-30 % du total), le reste tourne en cron nocturne.
Erreurs fréquentes
| Symptôme | Cause | Solution |
|---|---|---|
| Erreur « Benchmarks not found » | Pas en Release ou méthodes non publiques | dotnet run -c Release et méthodes public |
| Erreur élevée (5%+) | Bruit système (Chrome, antivirus, throttling thermique) | Fermer apps, brancher secteur, mode performance OS |
| Mémoire allouée à 0 B | Méthode renvoie une valeur cached | Vérifier que chaque appel alloue vraiment |
| JIT artefacts au premier call | Phase Warmup insuffisante | BenchmarkDotNet gère automatiquement, augmenter WarmupCount si besoin |
| Disassembly vide | Méthode trop courte, inlinée | Ajouter [MethodImpl(MethodImplOptions.NoInlining)] |
| NativeAOT crash | Réflexion non préservée | Ajouter rd.xml ou [DynamicallyAccessedMembers] |
Foire aux questions
BenchmarkDotNet ou Stopwatch ?
Stopwatch pour la production (mesurer en runtime). BenchmarkDotNet pour le développement (comparer des implémentations). Les deux ne se substituent pas.
Combien d’itérations par benchmark ?
BenchmarkDotNet décide automatiquement (Pilot stage). Pour les méthodes rapides (< 1 μs), ~10-100 millions. Pour les lentes (> 100 ms), 5-20.
Comparer avec node.js / Java ?
Pour des comparaisons cross-langage, utiliser un benchmark synthétique (TechEmpower) ou écrire le même algorithme dans chaque, mesurer à environnement identique.
Comment mesurer un async ?
BenchmarkDotNet supporte nativement les async Task. Il await la méthode et mesure la durée totale.
BenchmarkDotNet en production ?
Non. Production = télémétrie (App Insights, OpenTelemetry, Prometheus). BenchmarkDotNet est strictement pour le développement.
Pour aller plus loin
Avec une suite de benchmarks établie, le cluster C# .NET 10 est complet : langage moderne, EF Core, services d’arrière-plan, gRPC, et mesure de performance. Revenir au guide principal C# et .NET pour la vue d’ensemble.