← Back to all posts

Benchmarks in .NET Core

Benchmarks in .NET Core

Reading Time: 5 minutes

Why Benchmarking Matters

Performance discussions in .NET often start with good intentions and end with guesses.

Typical examples:

  • “This LINQ query feels slower”
  • “Span should be faster here”
  • “I replaced this code with a manual loop and it seems better”

The problem is that performance intuition is often wrong.

Modern .NET has:

  • JIT optimizations
  • tiered compilation
  • allocation effects
  • CPU cache behavior
  • runtime-specific optimizations

That means the only reliable way to compare two implementations is to measure them properly.

The Wrong Way to Benchmark

A lot of developers start with Stopwatch around ad hoc code.

var sw = Stopwatch.StartNew();
DoWork();
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);

This is usually not enough for trustworthy benchmarking because it ignores:

  • warmup effects
  • JIT compilation
  • multiple iterations
  • GC pressure
  • process/environment noise

For quick diagnostics, Stopwatch can be useful. For real comparison work, use a benchmarking framework.

The Right Tool: BenchmarkDotNet

In the .NET ecosystem, BenchmarkDotNet is the standard tool for microbenchmarks.

It handles many important details for you:

  • warmup
  • repeated iteration
  • statistical analysis
  • memory allocation reporting
  • runtime comparisons
  • disassembly and diagnostics support

Basic Setup

Install the package:

dotnet add package BenchmarkDotNet

Minimal example

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<StringBenchmarks>();

[MemoryDiagnoser]
public class StringBenchmarks
{
    private readonly string[] _values = Enumerable.Range(1, 1000)
        .Select(x => x.ToString())
        .ToArray();

    [Benchmark]
    public string JoinWithStringJoin()
    {
        return string.Join(',', _values);
    }

    [Benchmark]
    public string JoinWithBuilder()
    {
        var sb = new StringBuilder();

        for (int i = 0; i < _values.Length; i++)
        {
            if (i > 0)
                sb.Append(',');

            sb.Append(_values[i]);
        }

        return sb.ToString();
    }
}

Run the benchmark and BenchmarkDotNet will produce structured output showing timing and memory differences.

What BenchmarkDotNet Tells You

Typical metrics include:

  • Mean: average execution time
  • Error / StdDev: how stable the measurements are
  • Allocated: memory usage per operation
  • Ratio: relative speed compared to a baseline

This is much more useful than a single elapsed number from Stopwatch.

Add a Baseline

A useful technique is marking one implementation as the baseline.

[Benchmark(Baseline = true)]
public string OriginalImplementation()
{
    return string.Join(',', _values);
}

[Benchmark]
public string NewImplementation()
{
    return string.Concat(_values);
}

This makes it easier to compare the new code against the existing implementation.

Benchmarking Allocations Matters Too

Sometimes a method is only slightly faster, but allocates much more memory.

That matters in real applications because allocation pressure affects:

  • GC frequency
  • latency
  • throughput under load

That is why [MemoryDiagnoser] is so valuable.

Common Benchmark Scenarios in .NET

Typical things worth benchmarking:

  • string handling
  • collection iteration
  • LINQ vs manual loops
  • parsing and serialization
  • JSON libraries
  • caching strategies
  • object allocation patterns
  • Span<T> and buffer optimizations

The key is to benchmark something where a performance decision is actually being made.

Common Benchmarking Mistakes

1. Benchmarking unrealistic code

If the test input does not look like production input, results may be meaningless.

Bad example:

  • benchmarking a parser with a 5-character string when production inputs are 20 KB payloads

2. Optimizing before identifying a bottleneck

Not everything needs a benchmark.

Benchmarking is most useful when:

  • you already found a hotspot
  • you are choosing between concrete implementations
  • performance is a real requirement

3. Drawing conclusions from tiny differences

If one version is 1-2% faster but harder to read and maintain, that may not be a worthwhile tradeoff.

4. Ignoring allocations

A faster mean with much higher memory allocation can be worse in real systems.

5. Benchmarking too much inside one method

A benchmark should isolate the thing you want to compare.

If it includes setup, IO, logging, and unrelated work, it becomes harder to reason about the result.

Use Params for Realistic Comparisons

BenchmarkDotNet supports varying inputs with [Params].

[MemoryDiagnoser]
public class SearchBenchmarks
{
    private int[] _data = Array.Empty<int>();

    [Params(100, 1000, 100000)]
    public int Size;

    [GlobalSetup]
    public void Setup()
    {
        _data = Enumerable.Range(1, Size).ToArray();
    }

    [Benchmark]
    public bool LinqContains()
    {
        return _data.Contains(Size - 1);
    }

    [Benchmark]
    public bool ManualLoop()
    {
        foreach (var value in _data)
        {
            if (value == Size - 1)
                return true;
        }

        return false;
    }
}

This helps avoid conclusions based on only one input size.

Use GlobalSetup for Shared Preparation

If the data preparation is not part of what you want to measure, move it into [GlobalSetup].

This keeps the benchmark focused on the actual operation.

[GlobalSetup]
public void Setup()
{
    _items = CreateLargeDataset();
}

Comparing Runtimes

BenchmarkDotNet can also compare runtimes when needed.

This is useful when testing differences across:

  • .NET 8
  • .NET 9
  • .NET 10 previews or newer runtimes

This helps answer questions like:

  • Did the runtime itself get faster?
  • Is this optimization still necessary on newer .NET?

Benchmarks vs Profiling

Benchmarks and profilers solve different problems.

Benchmarking

Use when you want to compare isolated implementations.

Profiling

Use when you want to find where time is going in a real application.

A practical workflow is:

  1. Profile the application to find hotspots
  2. Extract candidate code into benchmarkable units
  3. Benchmark competing implementations
  4. Apply the winning change only if it is worth it

Interpreting Results Pragmatically

The fastest code is not always the best code.

Ask:

  • Is the improvement meaningful in the actual system?
  • Does it reduce allocations or just shift them?
  • Is the result stable across realistic inputs?
  • Is the extra complexity justified?

Performance work should improve the system, not just the benchmark table.

Summary

Benchmarking in .NET Core is about making performance decisions with evidence instead of intuition.

The practical default is:

  • use BenchmarkDotNet for microbenchmarks
  • measure both execution time and allocations
  • benchmark realistic workloads
  • isolate the code you actually want to compare
  • treat small wins skeptically if they add complexity

If performance matters, benchmark first, optimize second, and keep the result in context of the real application.

References