Skip to content

Dev Notes

—Short notes on software, systems, and things I learn while building—

Menu
Menu

C# Benchmarking & Profiling – I (BenchmarkDotNet)

Posted on July 22, 2026July 22, 2026 by isleyen

BenchmarkDotNet helps you to transform methods into benchmarks, track their performance, and share reproducible measurement experiments. Under the hood, it performs a lot of magic that guarantees reliable and precise results thanks to the perfolizer and pragmastat statistical engines. (source)

using System.Security.Cryptography;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.Net10_0, baseline: true)]
public class Md5VsSha256
{
    private SHA256 sha256 = SHA256.Create();
    private MD5 md5 = MD5.Create();
    private byte[] data;

    [Params(1000, 10000)]
    public int N;

    [GlobalSetup]
    public void Setup()
    {
        data = new byte[N];
        new Random(42).NextBytes(data);
    }

    [Benchmark]
    public byte[] Sha256() => sha256.ComputeHash(data);

    [Benchmark]
    public byte[] Md5() => md5.ComputeHash(data);
}

Program.cs:

BenchmarkRunner.Run<Md5VsSha256>();
// BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run();

R installation required for [RPlotExporter] (it generates the plot)

How to Read Benchmark Report

What it measuresHow to read it
MeanAverage time per single callwatch the unit. average execution time.
ErrorHalf of the 99.9% confidence intervalIf Error is large relative to Mean, the measurement is noisy — don’t over-trust it.
StdDevstandard deviation across runssmall: stable method, large: each call varies (IO, GC, threads)
RatioTime relative to the baselinekey column for comparisons
1.00=baseline
2.00=twice as slow
0.50=twice as fast
RatioSDStandard deviation of the ratioTells us how reliable the Ratio is.
Gen0 / Gen1 / Gen2GC collections (per 1000 ops)
MemoryDiagnoser attribute required
High Gen0 = many short-lived allocations. Non-zero Gen1/Gen2 = longer-lived, costlier objects.
AllocatedManaged heap allocation per single call
MemoryDiagnoser attribute required
- or 0 B = no allocation.
Two methods at equal speed but different allocation → prefer the leaner one.

Rule of thumb:

Read Ratio together with Error/StdDev.

Instead of “method X is faster,” think “method X is Y% faster than baseline, with low measurement noise.”

Job Run Strategies

Throughput (default)ColdStartMonitoring
PurposeMeasure steady-state performance of fast codeMeasure first-call / cold cost (JIT, first allocation, cold cache)Measure long-running, unstable operations
Pilot stage(*)

a preparation phase that automatically finds how many times to call the method per iteration (InvocationCount). If a single call is too fast to measure, it repeats enough times to reach a meaningful total duration.
Runs — auto-determines invocation countSkippedSkipped
Warmup(*)

unmeasured pre-runs executed before the real measurement so that “warming” effects (JIT compilation, CPU cache filling) settle and don’t pollute the results.
RunsSkippedRuns
Invocations per iterationMany (thousands–millions), then averaged1 per iterationFew, but fully measured
Typical use caseAlgorithms, hot paths, micro-optimizationsStartup cost, JIT impact, first-hit latencyIntegration tests, IO-heavy work, “different every time” scenarios
  • Throughput → fast, stable code (many repetitions + averaging)
  • ColdStart → first-call cost (single run, no warmup)
  • Monitoring → long, unstable work (few repetitions, no pilot)
[SimpleJob(RunStrategy.Throughput, baseline: true)]
[SimpleJob(RunStrategy.ColdStart, warmupCount: 0)]
[SimpleJob(RunStrategy.Monitoring)]
[MemoryDiagnoser]
public class RunStrategyDemo
{
    private readonly byte[] _data = new byte[1024];
    private readonly MD5 _md5 = MD5.Create();
 
    public RunStrategyDemo()
    {
        new Random(42).NextBytes(_data);
    }
 
    [Benchmark]
    public byte[] FastHash() => _md5.ComputeHash(_data);
 
    [Benchmark]
    public void SlowUnstableWork()
    {
        int ms = 100 + Random.Shared.Next(0, 40);
        Thread.Sleep(ms);
    }
}

JIT / Dead Code Elimination

When we need to benchmark the code, Project should be started in Release mode. Optimization will be done.
If the output isn’t used anywhere, the JIT eliminates the code, which skews the benchmark results.

PROBLEM:

[Benchmark]
public void Bad()
{
    Math.Sqrt(123.45); // constant folding & calculation result not used -> eliminated by JIT
}


SOLUTION:

[Benchmark]
public double Good() => Math.Sqrt(_input); // _input is not constant, calculation result returned

When the benchmark can’t return a value for the JIT to treat as used (e.g. it’s void), use the Consumer class from BenchmarkDotNet.Engines.

SOLUTION:
private Consumer _consumer = new();

[Benchmark]
public void Loop()
{
    foreach (var x in _items)
        _consumer.Consume(Work(x)); // .Consume(...) -> JIT assumes result is in use. there is no dead code
}

JIT / Lazy Evaluation

[SimpleJob]
public class ProcessorBenchmarks
{
    private Consumer consumer = new();

    // BAD: Compute() is called but the iterator is never walked.
    // yield return is never reached -> the actual work never runs.
    // Also the result is not consumed -> dead code elimination applies.
    // Benchmark only measures creating the iterator object, not the real work.
    [Benchmark]
    public void Bad()
    {
        Compute();
    }

    // TOARRAY: Runs correctly but pollutes the measurement.
    // ToArray() walks the iterator (lazy is triggered) BUT allocates a new array.
    // Now you measure Compute() + array allocation instead of just Compute().
    [Benchmark]
    public void ToArrayVersion()
    {
        Compute().ToArray().Consume(consumer);
    }

    // GOOD: Consume walks the iterator to the end.
    // Every yield return is triggered -> the work actually runs.
    // No extra ToArray()/ToList() allocation, so the measurement stays clean.
    [Benchmark]
    public void Good()
    {
        Compute().Consume(consumer); // lazy forced, no allocation
    }

    public IEnumerable<string> Compute()
    {
        yield return "OK";
    }
}

documentation: https://benchmarkdotnet.org/articles/guides/getting-started.html
source code: https://github.com/dotnet/BenchmarkDotNet
nuget: https://www.nuget.org/packages/benchmarkdotnet/

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Recent Posts

  • C# Benchmarking & Profiling – I (BenchmarkDotNet)
  • Reliability Notes: SLI, SLO, and SLA
  • Developing Inside Container – II (Notes)
  • Developing Inside a Container – (Notes)
  • Conda – Cheat Sheet

Categories

  • Azure
  • C#
  • claude
  • devcontainer
  • docker
  • python
  • SRE
© 2026 Dev Notes | Powered by Superbs Personal Blog theme