« Cette version est plus rapide » : sans mesure, ce n’est qu’une intuition. Un Stopwatch autour d’une boucle donne des chiffres faussés par le JIT, le GC et le bruit du système. BenchmarkDotNet est l’outil de référence en .NET pour mesurer proprement du code : préchauffage, itérations multiples, statistiques, mesure des allocations. On l’applique ici à du code typique d’une application IoT : générer l’identifiant d’un capteur et exporter ses mesures en CSV.
1. Pourquoi pas un Stopwatch ?
Prenons une plateforme qui collecte les mesures de capteurs répartis sur plusieurs sites. Chaque capteur est identifié par un libellé du type SNS-0012-000042 (site sur 4 chiffres, numéro de capteur sur 6), produit par une petite méthode :
using System.Globalization;
namespace Sensors.Domain;
public static class SensorLabel
{
// SNS-0012-000042
public static string Format(int site, int sensor) =>
string.Create(CultureInfo.InvariantCulture, $"SNS-{site:D4}-{sensor:D6}");
}
Première tentative de mesure :
// ❌ Mesure naïve : résultat quasiment inexploitable
var sw = Stopwatch.StartNew();
for (var i = 0; i < 1_000; i++)
_ = SensorLabel.Format(12, i);
sw.Stop();
Console.WriteLine(sw.Elapsed);
Ce code mesure pêle-mêle la compilation JIT du premier appel, d’éventuelles collectes du GC, le tiered compilation qui recompile la méthode en cours de route, et n’importe quel processus qui tourne en parallèle sur la machine. Une seule exécution, aucune statistique : impossible de dire si un écart de 10 % est réel.
BenchmarkDotNet automatise tout ce qu’il faudrait faire à la main :
graph LR
A["Build Release<br/>(projet généré)"]
B["Pilot<br/>(nb d'itérations)"]
C["Overhead<br/>(coût à vide)"]
D["Warmup<br/>(JIT, caches)"]
E["Workload<br/>(mesures réelles)"]
F["Statistiques<br/>Mean, Error, Allocated"]
A --> B --> C --> D --> E --> F
style A fill:#4A90E2
style D fill:#F5A623
style E fill:#F5A623
style F fill:#7ED321
Chaque benchmark s’exécute dans un processus séparé, pour éviter qu’un benchmark n’influence le suivant (JIT, état du GC, caches).
2. Créer le projet de benchmark
Un benchmark vit dans un projet console dédié, jamais dans le projet de tests unitaires :
dotnet new console -n Sensors.Benchmarks -o benchmarks/Sensors.Benchmarks
dotnet add benchmarks/Sensors.Benchmarks package BenchmarkDotNet --version 0.15.8
dotnet add benchmarks/Sensors.Benchmarks reference src/Sensors.Domain
Le point d’entrée délègue à BenchmarkSwitcher, qui permet de choisir les benchmarks en ligne de commande :
// Program.cs
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
Et surtout, on lance toujours en Release :
# Tous les benchmarks
dotnet run -c Release --project benchmarks/Sensors.Benchmarks -- --filter "*"
# Seulement ceux dont le nom contient "Label"
dotnet run -c Release --project benchmarks/Sensors.Benchmarks -- --filter "*Label*"
En Debug, le JIT n’optimise pas le code : les chiffres n’ont aucun sens. BenchmarkDotNet le détecte et refuse de lancer les mesures sur un assembly non optimisé.
3. Premier benchmark : le libellé du capteur
Le libellé SNS-0012-000042 est généré à chaque mesure reçue : c’est un chemin chaud. On compare quatre façons de le construire :
using System.Globalization;
using System.Text;
using BenchmarkDotNet.Attributes;
namespace Sensors.Benchmarks;
[MemoryDiagnoser] // ajoute les colonnes Gen0 et Allocated
public class SensorLabelBenchmarks
{
// Champs et non constantes : évite que le JIT calcule le résultat à la compilation
private int _site = 12;
private int _sensor = 42;
[Benchmark(Baseline = true)]
public string Concatenation() =>
"SNS-" + _site.ToString("D4", CultureInfo.InvariantCulture)
+ "-" + _sensor.ToString("D6", CultureInfo.InvariantCulture);
[Benchmark]
public string Interpolation() =>
string.Create(CultureInfo.InvariantCulture, $"SNS-{_site:D4}-{_sensor:D6}");
[Benchmark]
public string Builder() =>
new StringBuilder(15)
.Append("SNS-")
.Append(CultureInfo.InvariantCulture, $"{_site:D4}")
.Append('-')
.Append(CultureInfo.InvariantCulture, $"{_sensor:D6}")
.ToString();
[Benchmark]
public string StringCreate() =>
// Longueur connue : 4 ("SNS-") + 4 + 1 + 6 = 15 caractères
string.Create(15, (_site, _sensor), static (span, state) =>
{
"SNS-".CopyTo(span);
state._site.TryFormat(span[4..8], out _, "D4", CultureInfo.InvariantCulture);
span[8] = '-';
state._sensor.TryFormat(span[9..], out _, "D6", CultureInfo.InvariantCulture);
});
}
Points importants :
- Chaque méthode retourne son résultat. BenchmarkDotNet consomme la valeur retournée, ce qui empêche le JIT de supprimer le calcul comme du code mort.
Baseline = truedésigne la référence : les autres méthodes sont exprimées en ratio par rapport à elle.[MemoryDiagnoser]mesure les allocations managées par opération.
4. Lire les résultats
Après quelques dizaines de secondes, BenchmarkDotNet affiche un tableau (et l’exporte dans BenchmarkDotNet.Artifacts/results). Voici l’allure des résultats ; les valeurs sont indicatives et varient selon la machine, l’OS et la version du runtime :
| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio |
|-------------- |----------:|----------:|----------:|------:|-------:|----------:|------------:|
| Concatenation | ~30 ns | ~0.5 ns | ~0.4 ns | 1.00 | 0.0095 | 120 B | 1.00 |
| Interpolation | ~25 ns | ~0.3 ns | ~0.3 ns | ~0.85 | 0.0044 | 56 B | 0.47 |
| Builder | ~35 ns | ~0.6 ns | ~0.5 ns | ~1.15 | 0.0120 | ~150 B | ~1.25 |
| StringCreate | ~15 ns | ~0.2 ns | ~0.2 ns | ~0.50 | 0.0044 | 56 B | 0.47 |
| Colonne | Signification |
|---|---|
| Mean | Moyenne arithmétique du temps d’une opération |
| Error | Demi-largeur de l’intervalle de confiance à 99,9 % : la vraie moyenne est dans Mean ± Error |
| StdDev | Écart-type des mesures : une valeur élevée signale du bruit |
| Ratio | Temps rapporté à la baseline (< 1 = plus rapide) |
| Gen0 | Nombre de collectes de génération 0 pour 1 000 opérations |
| Allocated | Mémoire managée allouée par opération |
| Alloc Ratio | Allocations rapportées à la baseline |
Ce qu’on en retient :
- La concaténation alloue deux chaînes intermédiaires (
ToString("D4"),ToString("D6")) en plus du résultat. - L’interpolation est déjà très bonne depuis .NET 6 : le compilateur utilise
DefaultInterpolatedStringHandler, qui formate dans un buffer emprunté et n’alloue que la chaîne finale. StringBuildern’apporte rien sur une petite chaîne : on paie l’objet et son buffer.string.Createécrit directement dans la mémoire de la chaîne finale : c’est le plus rapide, au prix d’un code moins lisible.
Conclusion pragmatique : l’interpolation est le bon choix par défaut ; string.Create se justifie sur un chemin très chaud, mesuré comme tel.
Pour comprendre pourquoi ces allocations comptent, voir l’article sur la pile et le tas : chaque objet alloué sur le tas finit par coûter du temps au GC.
5. Faire varier les entrées avec [Params]
Un résultat dépend souvent de la taille des données. Les mesures d’un capteur sont régulièrement exportées en CSV, une ligne par mesure : on compare concaténation et StringBuilder pour 10, 100 et 1 000 mesures. Une mesure est un simple record :
namespace Sensors.Domain;
// Horodatage en secondes Unix, valeur en degrés Celsius
public sealed record SensorReading(string SensorId, long UnixTime, double Value);
using System.Globalization;
using System.Text;
using BenchmarkDotNet.Attributes;
using Sensors.Domain;
namespace Sensors.Benchmarks;
[MemoryDiagnoser]
public class CsvExportBenchmarks
{
[Params(10, 100, 1_000)] // chaque benchmark est exécuté pour chaque valeur
public int ReadingCount { get; set; }
private SensorReading[] _readings = [];
// Exécuté une fois par combinaison de paramètres, hors mesure
[GlobalSetup]
public void Setup() =>
_readings = Enumerable.Range(1, ReadingCount)
.Select(i => new SensorReading("SNS-0012-000042", 1_775_000_000 + i * 60L, 18.0 + i % 70 / 10.0))
.ToArray();
[Benchmark(Baseline = true)]
public string Concatenation()
{
var csv = "";
foreach (var r in _readings)
csv += string.Create(CultureInfo.InvariantCulture,
$"{r.SensorId};{r.UnixTime};{r.Value}\n");
return csv;
}
[Benchmark]
public string Builder()
{
var sb = new StringBuilder();
foreach (var r in _readings)
sb.Append(CultureInfo.InvariantCulture, $"{r.SensorId};{r.UnixTime};{r.Value}").Append('\n');
return sb.ToString();
}
}
Résultats indicatifs :
| Method | ReadingCount | Mean | Ratio | Allocated | Alloc Ratio |
|-------------- |------------- |------------:|------:|-----------:|------------:|
| Concatenation | 10 | ~1 µs | 1.00 | ~5 KB | 1.00 |
| Builder | 10 | ~0.8 µs | ~0.80 | ~2.5 KB | ~0.50 |
| Concatenation | 100 | ~20 µs | 1.00 | ~350 KB | 1.00 |
| Builder | 100 | ~8 µs | ~0.40 | ~25 KB | ~0.07 |
| Concatenation | 1000 | ~2 000 µs | 1.00 | ~30 MB | 1.00 |
| Builder | 1000 | ~80 µs | ~0.04 | ~250 KB | ~0.008 |
Le comportement quadratique de la concaténation en boucle saute aux yeux : chaque += recopie toute la chaîne existante. Avec 10 mesures, la différence est négligeable ; avec 1 000, elle est de plusieurs ordres de grandeur. C’est exactement ce que [Params] permet de révéler : un benchmark sur une seule taille peut mentir.
D’autres attributs complètent [Params] :
| Attribut | Usage |
|---|---|
[Params(...)] |
Valeurs constantes d’une propriété ou d’un champ public |
[ParamsSource(nameof(X))] |
Valeurs calculées par une propriété ou une méthode |
[Arguments(...)] |
Arguments passés directement à la méthode benchmark |
[GlobalSetup] / [GlobalCleanup] |
Préparation et nettoyage, hors mesure |
6. LINQ ou boucle ?
Autre question classique : calculer la moyenne des mesures d’un capteur. On ajoute deux méthodes à la classe CsvExportBenchmarks (ou à une classe dédiée avec le même [GlobalSetup]) :
[Benchmark]
public double AverageLinq() => _readings.Average(r => r.Value);
[Benchmark]
public double AverageLoop()
{
var sum = 0.0;
foreach (var r in _readings)
sum += r.Value;
return sum / _readings.Length;
}
La boucle évite l’appel du délégué à chaque élément et sera généralement un peu plus rapide, avec des allocations nulles ou marginales dans les deux cas (le lambda ne capture rien, il est mis en cache par le compilateur). L’écart reste souvent faible devant le coût d’un accès réseau ou base de données : c’est le genre de micro-optimisation qu’il ne faut appliquer qu’après mesure, sur un chemin chaud. Les bonnes pratiques LINQ détaillent les cas où LINQ coûte réellement cher (allocations cachées, énumérations multiples).
7. Les pièges classiques
Mesurer en Debug ou avec un débogueur attaché
Toujours -c Release, sans débogueur, en ligne de commande. Lancer les benchmarks depuis l’IDE avec F5 fausse les résultats.
Le code mort
// ❌ Le résultat n'est pas utilisé : le JIT peut supprimer le calcul
[Benchmark]
public void DeadCode() => _ = SensorLabel.Format(_site, _sensor);
// ❌ Constantes : le résultat peut être calculé à la compilation
[Benchmark]
public double Folded() => 21.5 * 1.8 + 32;
// ✅ On retourne le résultat, calculé à partir de champs
[Benchmark]
public string Alive() => SensorLabel.Format(_site, _sensor);
Ne pas mesurer en isolation
- Machine au calme : fermer navigateur, IDE, Teams ; brancher un portable sur secteur (les modes économie d’énergie brident le CPU).
- Pas de benchmark en CI partagée pour en tirer des conclusions absolues : les machines virtuelles mutualisées sont bruyantes. On peut en revanche y comparer des ratios.
- Comparer ce qui est comparable : même runtime, même machine, même exécution. Un
[Params]ou plusieurs runtimes dans une même session valent mieux que deux sessions à des jours d’écart.
Inclure le setup dans la mesure
// ❌ La création des données est mesurée avec le reste
[Benchmark]
public string Builder()
{
var readings = Enumerable.Range(1, 1_000)
.Select(i => new SensorReading("SNS-0012-000042", 1_775_000_000 + i * 60L, 21.5))
.ToArray();
// ... construction du CSV
}
// ✅ Préparation dans [GlobalSetup], comme dans CsvExportBenchmarks
Généraliser un micro-benchmark
Un benchmark mesure un morceau de code isolé, avec des caches chauds. En production, ce code partage le CPU, la mémoire et le GC avec tout le reste. Un gain de 50 % sur 30 ns n’a aucun impact visible sur un endpoint qui attend 20 ms la base de données. Le micro-benchmark valide une hypothèse ; le profilage et la télémétrie en production disent où se trouve le vrai problème.
Comparer plusieurs runtimes
Pour vérifier le gain d’une migration, on cible plusieurs frameworks dans le .csproj (<TargetFrameworks>net9.0;net10.0</TargetFrameworks>) et on demande les deux runtimes :
dotnet run -c Release -f net10.0 --project benchmarks/Sensors.Benchmarks -- --filter "*Csv*" --runtimes net9.0 net10.0
Résumé
| Élément | Rôle |
|---|---|
Projet console dédié + dotnet run -c Release |
Mesures sur du code optimisé, hors tests unitaires |
BenchmarkSwitcher + --filter |
Choisir les benchmarks à lancer |
[Benchmark] / Baseline = true |
Méthode mesurée / référence des ratios |
[MemoryDiagnoser] |
Colonnes Gen0 et Allocated |
[Params] / [GlobalSetup] |
Faire varier les entrées, préparer hors mesure |
| Mean ± Error | Temps moyen et intervalle de confiance à 99,9 % |
| Allocated | Mémoire allouée par opération |
| Retourner le résultat, utiliser des champs | Éviter code mort et constant folding |
| Machine au calme, même session | Mesurer en isolation, comparer ce qui est comparable |
Là où les tests disent si le code est correct, BenchmarkDotNet dit combien il coûte, en temps comme en mémoire. Sur notre plateforme de capteurs, il a confirmé deux intuitions et en a chiffré une troisième : l’interpolation suffit pour un libellé, string.Create ne se justifie que sur un chemin très chaud, et la concaténation en boucle devient vite catastrophique. Pour la partie correction, voir xUnit v3 : organiser ses tests.