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:

Program.cs
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:

Essential Attributes

[MemoryDiagnoser] — adds allocation tracking. Almost always worth enabling.

[Params] — parameterise benchmarks to test multiple input sizes:

Example.cs
[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:

CrossVersionBenchmarks.cs
[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.

Example.cs
// 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.