Your benchmark is lying to you
Why naive .NET benchmarking with Stopwatch is misleading and how NBenchmark provides honest numbers.
You have a piece of code that feels slow. To find out exactly how slow, you do what every .NET engineer has done at least once: you wrap a for loop in a Stopwatch, run it 10,000 times, divide by 10,000, and look at the result.
The number looks plausible. You feel confident. You might even commit a "performance optimization" based on that number.
The problem is that the number is almost certainly nonsense.
When you use a Stopwatch in a simple loop, you aren't just measuring your code. You are measuring a chaotic intersection of the JIT compiler, the Garbage Collector, the OS scheduler, and the hardware clock. Each of these is casting a vote on your final result, and most of them are lying to you.
The five ways a naive benchmark lies
If you are timing a loop in a standard console app, your results are being skewed by five primary forces.
1. JIT Warmup
The first few times your code runs, it isn't running at full speed. The Just-In-Time (JIT) compiler is transforming your IL into machine code. If your loop is small, the "warmup" cost of the first few iterations can dwarf the actual execution time of the remaining thousands. You end up measuring the compiler's efficiency, not your algorithm's.
2. Dead-Code Elimination
The .NET compiler is aggressively smart. If you call a method inside a loop but never use the return value, the compiler can prove that the work is useless. In many cases, it will simply remove the call entirely. Your "benchmark" suddenly reports that a complex calculation takes 0.0001 nanoseconds. You aren't measuring a miracle; you're measuring the absence of your code.
3. GC Noise
The Garbage Collector (GC) is a non-deterministic guest. If a Gen0 collection triggers halfway through your loop, one of your iterations will suddenly take 100x longer than the others. Because you are likely looking at a simple average, this single spike drags the mean upward, giving you a "noisy" number that doesn't represent the steady-state performance of your application.
4. Timer Resolution
Stopwatch is powerful, but it isn't infinite. Every system clock has a resolution limit. If your method executes in 10 nanoseconds, but the clock only ticks every 100 nanoseconds, you are effectively guessing. You'll see a sequence of zeros and then a sudden jump, creating a quantized result that bears no resemblance to reality.
5. Scheduling Outliers
Your benchmark is not the only thing running on your machine. At any moment, the OS can decide to preempt your thread to handle a network packet or update the system clock. These "stutters" create massive outliers. In a naive loop, these outliers pollute your average, making it impossible to tell if a performance dip is a flaw in your code or just a background task running Windows Update.
What is NBenchmark?
NBenchmark is a measurement engine designed to strip away this noise. It doesn't just wrap your code in a timer; it orchestrates the entire environment to ensure the number you see is honest.
It solves the "lying benchmark" problem through four core pillars:
- Isolation: Every measurement happens in a clean, dedicated worker process. This ensures that JIT state and heap contamination from previous runs cannot leak into your current sample (we'll explore this in post 18).
- Adaptive Sampling: Instead of guessing how many times to run a loop, NBenchmark samples your code adaptively. It continues measuring until the confidence interval is tight enough to be statistically defensible (see post 9).
- Evidence over Vibes: Instead of comparing two averages and hoping for the best, NBenchmark uses rigorous significance tests. It tells you if a difference is "real" or just random noise, and quantifies the magnitude of that difference (post 7).
- Built-in Guardrails: It ships with a suite of Roslyn analyzers that catch common benchmarking mistakes - like throwaway return values or capturing lambdas - at compile time, before you ever run the code (post 5).
Whether you need a quick answer in a unit test, a side-by-side comparison of two algorithms, or a full-scale performance suite for your CI pipeline, NBenchmark provides the infrastructure to get a real number.
Getting started
Adding NBenchmark to your project is a single command. The core package is zero-dependency and targets .net8.0, .net9.0, and .net10.0.
dotnet add package NBenchmark
Note: For rich terminal tables and Dependency Injection support, you can add NBenchmark.Reporters.Console and NBenchmark.DependencyInjection respectively (covered in post 12 and post 19).
The fastest way to get an honest number is Benchmark.Run. It handles the warmup, the isolation, and the sampling automatically.
using NBenchmark;
// Measure a simple loop
var result = Benchmark.Run(() =>
{
for (var i = 0; i < 1000; i++)
{
// Your code here
}
});
result.Print();
When you run this, you won't get a single, shaky number. You'll get a result object containing the median, the mean, the error margin, and the allocation cost per operation.
Example Output:
Benchmark: 1
Median: 142.5 ns
Mean: 143.1 ns
Error: ± 1.2 ns
Alloc: 0 B/op
This output tells you that your code typically takes 142.5 nanoseconds, and the engine is confident that the true mean is within 1.2 nanoseconds of the reported value. No guesswork, no JIT noise, no lies.
Go deeper: In the next post, we'll look at the different ways to use Benchmark.Run to handle async code and complex state setup.