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 BenchmarkDotNet2. 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 = truedesignates 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 ReleaseBenchmarkDotNet 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.