How to Benchmark and Profile C# with BenchmarkDotNet

BenchmarkDotNet is the standard library in the .NET ecosystem for measuring execution speed and tracking memory usage with microsecond precision. This article provides a direct, step-by-step walkthrough of creating a benchmarking project, writing benchmarks with the [Benchmark] attribute, profiling allocations using [MemoryDiagnoser], and correctly running the suite to collect statistically sound results.

1. Project Setup

BenchmarkDotNet requires a separate Console Application running in Release mode to prevent compiler optimizations from skewing the metrics.

Create a new console project and add the BenchmarkDotNet package:

dotnet new console -n PerformanceDemo
cd PerformanceDemo
dotnet add package BenchmarkDotNet

2. Writing a Benchmark Class

Create a dedicated class to contain your performance tests. Add the [MemoryDiagnoser] attribute to the class to enable memory allocation tracking alongside execution time. Use the [Benchmark] attribute on public methods you want to measure.

using System.Text;
using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class StringConcatenationBenchmark
{
    private const int Iterations = 1000;

    [Benchmark(Baseline = true)]
    public string UsingPlusOperator()
    {
        string result = string.Empty;
        for (int i = 0; i < Iterations; i++)
        {
            result += i.ToString();
        }
        return result;
    }

    [Benchmark]
    public string UsingStringBuilder()
    {
        var sb = new StringBuilder();
        for (int i = 0; i < Iterations; i++)
        {
            sb.Append(i);
        }
        return sb.ToString();
    }
}
  • Setting Baseline = true designates a reference benchmark against which other methods are compared as ratios.
  • Methods must be public, take no parameters, and return the result to prevent the JIT compiler from optimizing the entire operation away (dead code elimination).

3. Running the Benchmark from Program.cs

BenchmarkDotNet uses a runner engine to control execution iterations, warmups, and data aggregation. Update Program.cs to invoke BenchmarkRunner:

using BenchmarkDotNet.Running;

BenchmarkRunner.Run<StringConcatenationBenchmark>();

4. Executing in Release Mode

Never run benchmarks through an attached debugger or in Debug mode. Always execute the application from the CLI using the Release configuration:

dotnet run -c Release

BenchmarkDotNet will automatically create an isolated build, run warmup passes to let the JIT optimizer settle, execute pilot phases to determine optimal iteration counts, and collect runtime telemetry.

5. Advanced Benchmarking Techniques

Testing Multiple Input Sizes with [Params]

To test performance across varying dataset sizes without duplicating code, use the [Params] attribute on a field or property:

[MemoryDiagnoser]
public class CollectionBenchmark
{
    [Params(100, 10_000, 100_000)]
    public int Size;

    [Benchmark]
    public List<int> PopulateList()
    {
        var list = new List<int>();
        for (int i = 0; i < Size; i++)
        {
            list.Add(i);
        }
        return list;
    }

    [Benchmark]
    public List<int> PopulateListWithPreallocatedCapacity()
    {
        var list = new List<int>(Size);
        for (int i = 0; i < Size; i++)
        {
            list.Add(i);
        }
        return list;
    }
}

Profiling Multiple .NET Runtimes

You can compare your code across multiple runtimes (e.g., .NET 8 vs. .NET 9) by annotating the class with runtime jobs:

using BenchmarkDotNet.Jobs;

[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.Net90)]
[MemoryDiagnoser]
public class RuntimeComparison
{
    // Benchmarks here
}

6. Interpreting the Output

When execution finishes, BenchmarkDotNet prints a summary table in the terminal and exports raw data (Markdown, CSV, HTML) into the BenchmarkDotNet.Artifacts/results directory.

Key columns to evaluate:

  • Mean: The arithmetic average time taken per operation.
  • Error: Half of the 99.9% confidence interval. Lower numbers indicate more consistent results.
  • StdDev: The standard deviation of the measured iterations.
  • Ratio: The execution speed compared directly to the method marked Baseline = true.
  • Gen 0 / Gen 1 / Gen 2: The number of Garbage Collection cycles triggered per 1,000 operations.
  • Allocated: The total heap memory allocated per single operation. Minimizing this value often leads directly to faster and more predictable throughput.