Benchmarking with BenchmarkDotNet and Allocation Profiling
Objective
"This version is faster" and "this version allocates less" are claims about
numbers, and a number taken with a stopwatch around one call is rarely the
number you think it is. BenchmarkDotNet is the standard tool for measuring .NET
code: it handles warm-up, JIT tiers, process isolation and statistics, and with
[MemoryDiagnoser] it reports allocations per operation, which is the number
that usually explains a slow service. This concept shows why a stopwatch
misleads, how to write a benchmark with a baseline and parameters, how to read
the report, which traps make a benchmark measure nothing, how pooling looks in
the numbers, and how to watch allocation in a running process. Every number
below was measured on one machine (an Apple M4 Max, macOS, arm64, 14 cores,
BenchmarkDotNet 0.15.8). Treat them as an illustration of the method, not as
data about your hardware.
Use Cases
- Proving, before a pull request, that a rewrite allocates less and is not slower.
- Choosing between two implementations (
+=versusStringBuilder,newversusArrayPool) with evidence instead of folklore. - Reading
Gen0andAllocatedto find out why a hot path triggers many collections. - Checking that a runtime upgrade did not regress a critical method.
- Watching the allocation rate of a running service without attaching a debugger.
Deep Dive
Why a stopwatch misleads
A loop of Stopwatch.StartNew() around a call looks fine until you call it
more than once. Building a semicolon-separated string from 100 integers with
+=, timed on five consecutive calls in a Release build:
csharpfor (int i = 0; i < 5; i++)
{
long before = GC.GetAllocatedBytesForCurrentThread();
var sw = Stopwatch.StartNew();
var s = Csv.Plus(values);
sw.Stop();
Console.WriteLine($"{sw.Elapsed.TotalMicroseconds:F1} us, {GC.GetAllocatedBytesForCurrentThread() - before} B");
}| Call | Time (two runs) | Allocated |
|---|---|---|
| 0 | 149 us, 283 us | 41,640 B, 39,736 B |
| 1 | 11 us, 9 us | 30,944 B |
| 2 to 4 | about 4 to 6 us | 30,944 B |
The first call was 25 to 60 times slower than the steady state, because it includes JIT
compilation and first-use costs. The steady state needs several calls to
appear, tiered compilation can still change the code afterwards, and a single
number has no spread. BenchmarkDotNet exists to remove exactly these problems:
it runs the method in a separate process, calibrates how many operations to run
per measurement, discards warm-up, repeats, and reports the mean with an error
interval. Note that GC.GetAllocatedBytesForCurrentThread already gave the
stable number here (30,944 bytes) after the first call. Bytes are far more
repeatable than time, which is why the lab for this concept asserts on them.
A first benchmark
csharp[MemoryDiagnoser]
public class ConcatBench
{
[Params(10, 1000)] public int N;
private int[] _values = [];
[GlobalSetup] public void Setup() => _values = Enumerable.Range(0, N).ToArray();
[Benchmark(Baseline = true)] public string Plus() => Csv.Plus(_values);
[Benchmark] public string Builder() => Csv.Builder(_values);
}Run it from a console project that references the BenchmarkDotNet package,
in Release, outside the debugger:
csharpBenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);plaintextdotnet run -c Release -- --filter "*ConcatBench*" --job short
--job short runs fewer iterations (three here), which is why the error
column below is wide. Use the default job for a conclusion you will defend.
| Method | N | Mean | Ratio | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|
| Plus | 10 | 72.9 ns | 1.00 | 0.0573 | 480 B | 1.00 |
| Builder | 10 | 49.1 ns | 0.67 | 0.0325 | 272 B | 0.57 |
| Plus | 1000 | 145,528 ns | 1.00 | 459.2 | 3,844,104 B | 1.000 |
| Builder | 1000 | 3,005 ns | 0.02 | 1.99 | 16,680 B | 0.004 |
Reading the report
- Baseline and Ratio.
[Benchmark(Baseline = true)]marks the reference method;Ratiois each method's mean divided by the baseline's mean (the report legend says "[Current]/[Baseline]"), so 0.02 means about fifty times faster. A ratio is only meaningful within one report. [Params]runs every benchmark once per value and prints one block per value, separated by an empty row. The table above shows why: at N = 10 the builder is 33 percent faster, at N = 1000 it is almost fifty times faster and allocates 230 times fewer bytes, because+=copies the whole string at each step and the cost grows with the square of the input. One input size would have hidden the story.Gen0,Gen1,Gen2are collections per 1000 operations: 459 gen0 collections per 1000 calls ofPlus(1000). This is the cost the mean does not show, because it falls on the whole process.Allocatedis managed memory per operation, inclusive of what the method calls; native memory is not counted.ErrorandStdDevtell you how much to trustMean. If the error is a large fraction of the mean (the N = 10 row above, from a short run), do not conclude anything from that row.
Traps that make a benchmark measure nothing
Four methods that look like benchmarks, in one class with [MemoryDiagnoser]:
csharp[Benchmark] public void Dead() { var a = new byte[1000]; }
[Benchmark] public int Kept() { var a = new byte[1000]; return a.Length + _x; }
[Benchmark] public byte[] Returned() { var a = new byte[1000]; return a; }
[Benchmark] public double Constant() => Math.Sqrt(2.0);| Method | Mean | Allocated |
|---|---|---|
| Dead | 0.04 ns | none |
| Kept | 0.04 ns | none |
| Returned | 28.5 ns | 1,024 B |
| Constant | about 0 ns | none |
BenchmarkDotNet printed a ZeroMeasurement warning for the first, second and
fourth: "the method duration is indistinguishable from the empty method
duration". The JIT removed the array that nothing used, even in Kept, where
only its length was read (the escape analysis described in Value Types,
Reference Types and Boxing), and folded Math.Sqrt(2.0) into a constant. Only
the version that returns the array measured an allocation. The rules that
follow:
- Return the result (or hand it to a
Consumer/Blackhole) so the work cannot be removed. This is dead code elimination. - Read inputs from fields set in
[GlobalSetup], never constants, so the JIT cannot fold the computation. - Keep setup out of the method. Building the input inside the benchmark
measures the setup too;
[GlobalSetup]runs once and[IterationSetup]is a last resort, because it distorts short benchmarks. - Release only. Running the same project in Debug, BenchmarkDotNet stops before measuring: "Assembly Demo which defines benchmarks is non-optimized". Also run outside the debugger and, ideally, with other heavy programs closed.
- Watch for tiering and warm-up. The default job runs warm-up iterations
until the method is stable; if you lower it, check that
Meanis not still falling.
Comparing .NET 10 and .NET 11
BenchmarkDotNet can run the same benchmark on several runtimes through a job
per runtime. On 0.15.8, the stable version when this was written, that
is a problem for .NET 11: its CoreRuntime has Core10_0 and no Core11_0,
the command line --runtimes net11.0 is rejected ("The provided runtime
"net11.0" is invalid", the list ends at net10_0), and a project built for
net11.0 fails with NotImplementedException: GetRuntimeVersion not implemented for NotRecognized in the default toolchain. What worked here was
building the project for both target frameworks and running each build in the
process with --inProcess, which measures on the host runtime without the SDK
check:
plaintextdotnet run -c Release -f net10.0 -- --filter "*VersionBench*" --inProcess dotnet run -c Release -f net11.0 -- --filter "*VersionBench*" --inProcess
| Method | .NET 10.0.0 | .NET 11.0.0 | Allocated (both) |
|---|---|---|---|
LinqSum over 1000 ints |
1.234 us | 1.218 us | 104 B |
StringBuilder loop, 1000 ints |
2.883 us | 2.770 us | 16,680 B |
Differences of 1 to 4 percent with errors up to 2 percent: no conclusion
beyond "no regression here". --inProcess gives up the process isolation that
protects a benchmark from the host's own state, so it is a workaround, not a
default; check the release notes of BenchmarkDotNet for first-class .NET 11
support before relying on this.
Pooling as a measured fix
ArrayPool<T>.Shared and ObjectPool<T> (package
Microsoft.Extensions.ObjectPool) replace allocation with reuse. Whether they
pay off is a question for the report:
| Method (ShortRun) | Mean | Ratio | Gen0/Gen1/Gen2 | Allocated |
|---|---|---|---|---|
new byte[100_000] |
3,401 ns | 1.000 | 31 / 31 / 31 | 100,045 B |
ArrayPool<byte>.Shared.Rent / Return |
3.96 ns | 0.001 | none | none |
new StringBuilder() plus ToString() |
13.2 ns | 0.004 | 0.017 gen0 | 144 B |
StringBuilder pool Get / Return |
10.2 ns | 0.003 | 0.005 gen0 | 40 B |
The array above 85,000 bytes lives on the large object heap, so every call
feeds gen2 collections (the 31 in the Gen2 column); the pool removes the
allocation altogether. The small builder case is a modest gain, and the
pooled version still allocates 40 bytes for the result string. Pooling has a
configuration cost you can measure as well. The default policy of the
StringBuilder pool discards builders that grew past 4,096 characters, so
with a result of 3,890 characters it pays for the pool plus a new builder
every call:
| Bytes per call (1000 calls, one thread) | 100 values | 1000 values |
|---|---|---|
pooled StringBuilder |
609 B | 21,176 B |
new StringBuilder() |
2,064 B | 16,680 B |
new StringBuilder(capacity) |
1,880 B | 19,880 B |
At 100 values the pool allocates only the result (602 B for a 290 character
string). At 1000 values the pooled version allocated more than a plain builder,
because the builder never survived the pool's size limit (the policy's InitialCapacity is 100 and MaximumRetainedCapacity is 4,096, read from StringBuilderPooledObjectPolicy). A pool is a
trade, not a free win: measure with your real sizes. The same holds for
ArrayPool (clear sensitive data on Return with clearArray: true, and never
touch a buffer after returning it).
Allocation in a running process
When you cannot attach a benchmark, dotnet-counters reads the runtime's
counters through EventPipe. Installed with dotnet tool install dotnet-counters --tool-path <dir> (version 10.0.750501 here) and pointed at a process that
called the += method in a loop for 25 seconds:
plaintextdotnet-counters collect -p <pid> --counters System.Runtime \ --format csv -o counters --refresh-interval 1 --duration 00:00:08
The CSV had one row per counter per second, using the OpenTelemetry style names of recent runtimes. The ones that answer an allocation question:
| Counter | Value over 8 seconds |
|---|---|
dotnet.gc.collections for gen0 |
2,459 to 3,053 per second |
dotnet.gc.collections for gen1 / gen2 |
0 to 2 per second / 0 |
dotnet.gc.heap.total_allocated |
20.6 to 25.5 GB per second |
dotnet.gc.last_collection.heap.size for the LOH |
24 B |
That is a gen0-only storm: everything dies young, nothing reaches the LOH, and
the process spends its time allocating and collecting. It is the shape of the
+= loop with 1000 values from the benchmark above. Counters tell you that a
process allocates too much and in which generation; to find which methods and
types are responsible, use an allocation profile or a dump with the tools in
Diagnosing Production with dotnet-trace, dump and gcdump, then confirm the fix
with a benchmark.
Trade-offs
- Stopwatch versus BenchmarkDotNet. A stopwatch is enough to see an order of magnitude (a 50x difference is not noise); BenchmarkDotNet is for decisions, at the price of minutes per run and a separate project.
- Short job versus default job.
--job shortmakes a fast feedback loop but produces wide error bars, as the N = 10 row shows; use the default job for numbers that go into a pull request. - Time versus allocations. Bytes per operation are stable across machines
and runs, time is not. For regression tests, assert on allocation in ranges
(as the lab does with
GC.GetAllocatedBytesForCurrentThread) and keep time in benchmarks you read.csharpvar before = GC.GetAllocatedBytesForCurrentThread(); Work(); Assert.InRange(GC.GetAllocatedBytesForCurrentThread() - before, 0, 1_000); - Microbenchmark versus production. A benchmark on one machine, one input size and a warm cache says little about contention, a loaded GC heap or a different CPU. Use it to compare two versions, then confirm in production with counters.
- Pooling versus plain allocation. A pool removes GC pressure for big or hot buffers, and adds lifetime rules (return it, do not touch it after) and size policies. For small short-lived objects, the generational GC is often cheaper than the discipline.