You cannot optimise what you cannot measure. Stopwatch and manual timing loops are unreliable — they ignore JIT warm-up, GC interference, CPU frequency scaling, and a dozen other factors that skew results. BenchmarkDotNet handles all of this for you and produces statistically rigorous results.
Getting Started
Create a console project and add the NuGet package:
dotnet new console -n MyBenchmarks
cd MyBenchmarks
dotnet add package BenchmarkDotNet
Write a benchmark class:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<StringBenchmarks>();
[MemoryDiagnoser]
public class StringBenchmarks
{
private readonly string[] _items = Enumerable
.Range(0, 100)
.Select(i => i.ToString())
.ToArray();
[Benchmark(Baseline = true)]
public string Concatenation()
{
var result = "";
foreach (var item in _items)
result += item;
return result;
}
[Benchmark]
public string StringBuilder()
{
var sb = new System.Text.StringBuilder();
foreach (var item in _items)
sb.Append(item);
return sb.ToString();
}
[Benchmark]
public string StringJoin()
{
return string.Join("", _items);
}
}
Run it in Release mode — this is critical:
dotnet run -c Release
BenchmarkDotNet will refuse to run in Debug mode because debug builds disable inlining and optimisations, producing meaningless results.
Reading the Output
A typical results table looks like this:
| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated |
|--------------- |----------:|---------:|---------:|------:|-------:|----------:|
| Concatenation | 2,847 ns | 21.4 ns | 17.9 ns | 1.00 | 3.2100 | 13.2 KB |
| StringBuilder | 685 ns | 4.2 ns | 3.5 ns | 0.24 | 0.2899 | 1.2 KB |
| StringJoin | 421 ns | 3.1 ns | 2.6 ns | 0.15 | 0.1297 | 0.5 KB |
Key columns:
- Mean — average execution time across all iterations.
- Error — half-width of the 99.9% confidence interval.
- StdDev — standard deviation of measurements.
- Ratio — performance relative to the baseline method.
- Gen0 — number of Gen 0 garbage collections per 1000 operations.
- Allocated — total memory allocated per operation (requires
[MemoryDiagnoser]).
Essential Attributes
[MemoryDiagnoser] — adds allocation tracking. Almost always worth enabling.
[Params] — parameterise benchmarks to test multiple input sizes:
[Params(10, 100, 1000)]
public int Size { get; set; }
[GlobalSetup]
public void Setup()
{
_data = new int[Size];
Random.Shared.NextBytes(MemoryMarshal.AsBytes(_data.AsSpan()));
}
[GlobalSetup] / [GlobalCleanup] — run once before/after all iterations. Use for expensive initialisation that should not be measured.
[IterationSetup] — run before each iteration. Use sparingly, as it adds overhead.
Comparing Multiple Approaches
Use [Benchmark(Baseline = true)] on one method to make all others relative. This makes it easy to see whether a change is 2x faster or 10x faster at a glance.
For comparing across different .NET versions, add a [SimpleJob] attribute:
[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.Net90)]
[MemoryDiagnoser]
public class CrossVersionBenchmarks
{
// ...
}
Common Mistakes
Benchmarking dead code. If your benchmark method returns void and the JIT determines the result is unused, it may eliminate the entire computation. Always return a value or assign to a field.
// Bad — JIT may eliminate the loop
[Benchmark]
public void Bad()
{
for (int i = 0; i < 1000; i++)
Math.Sqrt(i);
}
// Good — return forces computation
[Benchmark]
public double Good()
{
double sum = 0;
for (int i = 0; i < 1000; i++)
sum += Math.Sqrt(i);
return sum;
}
Running in Debug mode. Always use dotnet run -c Release. Debug builds disable critical JIT optimisations.
Too few iterations. BenchmarkDotNet automatically determines iteration counts based on statistical convergence. Do not override this unless you understand the statistics involved.
Exporting Results
BenchmarkDotNet supports multiple exporters. Add [MarkdownExporter], [HtmlExporter], or [JsonExporter] to your class. Results are written to the BenchmarkDotNet.Artifacts folder.
For tracking performance over time, export JSON results and compare them between commits using dotnet-benchmark or a custom script.
Summary
BenchmarkDotNet removes guesswork from performance measurement. It handles JIT warm-up, statistical analysis, memory tracking, and result formatting. Every performance claim in .NET should be backed by a BenchmarkDotNet run — anything less is speculation.