sskiles devblog

dotnet benchmarking

There are a few different opinions on code performance and/or optimization. Whatever your opinion is, you are going to need to have measurements to compare. The de facto standard for .NET benchmarking is BenchmarkDotNet. Available via NuGet, BenchmarkDotNet is a reliable, high precision library for benchmarking .NET code. If you ever need to have measurements of code perfomance, BenchmarkDotNet is the way to go.

I will not even attempt to cover all the features of BenchmarkDotNet, but I will point you in the right direction(s). I will say that BenchmarkDotNet has some of the best documentation I have ever seen. A quick overview from their site shows you how to get started. The example code has a class with methods to benchmark, marking the test methods with the [Benchmark] attribute. Then running the benchmark with BenchmarkRunner.Run<MyClass>() will output the results to the console.

You can only run benchmarks in release mode, ensuring optimized production ready code is being tested. The base output will display the elapsed mean time for each benchmark (in milliseconds/microseconds/appropriate units), the standard error of the mean, and the standard deviation. Through configuration, you can easily add in memory usage, garbage collection, and other metrics. There are over 60 different samples in the documentation labeled as “intro” to show you how to use BenchmarkDotNet. I told you the documentation was outstanding.

It is also possible to export the results to numerous formats ranging from AsciiDoc to XmlFullCompressed. Side note: if you have experience with graphing in R, RPlotExporter is a beautiful way to visualize the results. BenchmarkDotNet uses the perfolizer performance analysis toolkit / statistical engine, so any data you want to crunch or pull out should be within reach.

Often times, I will actively tweak code in benchmarks to see how different changes can affect performance or memory usage. This is a great tool for that. One thing that always throws me is switching back and forth from debug mode while tweaking code to release for benchmarking. Sometimes other code changes are needed to switch back and forth. Here is a quick and dirty way to simplify this process.

using System;
using System.Collections.Generic;
using System.Reflection;
using BenchmarkConsole.Benchmarks;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

/// <summary>
/// Test Harness for Benchmarks.
/// </summary>
internal static class Program
{
    private static async Task Main()
    {
        await MainType<TestBenchmark>();
    }

    private static async Task MainType<T>()
        where T : class
    {
#if !DEBUG
        BenchmarkRunner.Run<T>();
#else
        await ValidateBenchMarks<T>();
#endif
        Console.WriteLine("\n...Complete\n");
        await Task.CompletedTask;
    }

    private static async Task ValidateBenchMarks<T>()
        where T : class
    {
        var type = typeof(T);
        var methods = type.GetMethods(
                BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public)
            .Where(m => m.GetCustomAttribute(typeof(BenchmarkAttribute)) != null);

        object? instance = Activator.CreateInstance(type);
        var maxNameLength = methods.Max(m => m.Name.Length);
        foreach (var method in methods)
        {
            object? result = method.Invoke(instance, null);

            // Replace this logic to fit your expected test results.
            object? readableResult = null;
            if (result != null)
            {
                readableResult = ((List<int>)result)[^1];
            }

            Console.WriteLine(
                "Method "
                    + $"{new string(' ', maxNameLength - method.Name.Length)}"
                    + $"{method.Name}: {readableResult ?? result}");
        }
    }
}

This code will run the benchmarks in release mode, but will run each method individually in debug mode to change, evaluate and compare. As an example, running the code against TestBenchmark class will output the following in release mode:

| Method         | N   | Mean       | Error     | StdDev    | Median     | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|--------------- |---- |-----------:|----------:|----------:|-----------:|------:|--------:|-------:|----------:|------------:|
| GenPrimesNaive | 100 |   258.0 ns |  31.45 ns |   4.87 ns |   259.8 ns |  1.00 |    0.02 | 0.0440 |     432 B |        1.00 |
| GenPrimes2N    | 100 |   276.0 ns | 101.33 ns |  15.68 ns |   271.8 ns |  1.07 |    0.06 | 0.0360 |     368 B |        0.85 |
| PossiblyClever | 100 |   280.7 ns | 480.00 ns | 124.66 ns |   222.3 ns |  1.09 |    0.44 | 0.0520 |     520 B |        1.20 |
|                |     |            |           |           |            |       |         |        |           |             |
| GenPrimesNaive | 500 | 1,627.6 ns | 338.90 ns |  52.45 ns | 1,610.9 ns |  1.00 |    0.04 | 0.1320 |    1248 B |        1.00 |
| GenPrimes2N    | 500 | 1,566.8 ns | 372.46 ns |  57.64 ns | 1,559.7 ns |  0.96 |    0.04 | 0.1240 |    1184 B |        0.95 |
| PossiblyClever | 500 | 1,056.6 ns | 219.57 ns |  33.98 ns | 1,052.9 ns |  0.65 |    0.03 | 0.1680 |    1608 B |        1.29 |
|                |     |            |           |           |            |       |         |        |           |             |
| GenPrimesNaive | 750 | 2,564.6 ns | 201.41 ns |  31.17 ns | 2,573.5 ns |  1.00 |    0.02 | 0.2440 |    2296 B |        1.00 |
| GenPrimes2N    | 750 | 2,594.0 ns | 628.63 ns |  97.28 ns | 2,584.1 ns |  1.01 |    0.04 | 0.1720 |    1640 B |        0.71 |
| PossiblyClever | 750 | 1,699.9 ns | 450.43 ns |  69.70 ns | 1,683.0 ns |  0.66 |    0.03 | 0.2320 |    2216 B |        0.97 |

And in debug mode:

Method GenPrimesNaive: 99991
Method    GenPrimes2N: 99991
Method PossiblyClever: 99991

This allows you to make changes while debugging and verify the results match the expected results. Then run the benchmarks in release mode to get the metrics. Switching back and forth is as easy as dotnet run -c release and dotnet run.

In the results above, the benchmark shows the result of methods when varying the paramenter N from 100 to 750. GenPrimesNaive is set as the baseline which the other methods’ timing and memory usage are compared against. The debug results show the result of the last member of each of the lists the test methods return.

That’s all I have to add to the world of BenchmarkDotNet. A quick and dirty way to switch between debug and release while testing and benchmarking. Everything else is in the documentation. I hope you find this useful.

Remember, “premature optimization is the root of all evil”.

© 2026 Shane Skiles