When your code outruns the clock
Learn how NBenchmark uses ops-per-sample calibration to accurately measure sub-microsecond operations that finish faster than the system clock can notice.
If you are benchmarking a tight loop, a simple mathematical function, or a high-performance data structure, you will eventually hit a wall where your code is simply too fast for the computer to time.
This is the resolution problem.
On most modern systems, reading the high-resolution timer itself has a cost - often in the tens of nanoseconds. If your method finishes in 50 nanoseconds, and the act of checking the clock takes 30 nanoseconds, a significant portion of your "measurement" is actually just the cost of the measurement tool. At this scale, the clock is too coarse; you aren't measuring your code, you're measuring the jitter of the system timer.
NBenchmark solves this by using ops-per-sample calibration.
The Batching Strategy: Ops-per-Sample (K)
Rather than trying to time a single execution of a nanosecond-scale operation, NBenchmark times a batch of back-to-back calls as a single "sample."
The number of calls in this batch is known as K (or Ops-per-sample).
The logic is simple: if one call is too fast to measure, 1,000 calls will be plenty fast to measure. By grouping them, the engine ensures that the total duration of the sample is large enough to dwarf the cost of the timer reads and the overhead of the loop.
Once the batch is timed, the engine divides the total duration by K to derive the per-operation time.
$$\text{Per-op Time} = \frac{\text{Sample Duration}}{\text{Ops-per-sample (K)}}$$
How Calibration Works
In most cases, you don't need to worry about K because NBenchmark handles it automatically.
During the calibration phase (which happens after warmup but before sampling), the engine probes the operation. It starts with a small K and incrementally increases it until the total sample duration is consistently above a safe threshold - one that is significantly larger than the system's timer resolution.
This auto-calibration ensures that whether your method takes 10 nanoseconds or 10 microseconds, the resulting measurement is honest and the overhead is minimized.
When Auto-Calibration is Skipped
There is one specific scenario where the engine declines to calibrate and forces $K = 1$: when you use SampleSetup or SampleTeardown hooks.
The ops-per-sample batching works by running the method body in a tight, repeated loop. If you have configured a setup or teardown method to run around every sample, the engine cannot batch the body without also batching the setup/teardown. Since setup and teardown logic often involves state changes that must happen exactly once per operation, batching them would produce incorrect results.
In these cases, NBenchmark defaults to $K = 1$. If your method is extremely fast and you are using setup hooks, you may see the "clock resolution" symptoms described below.
Pinning K for Reproducibility
While auto-calibration is usually correct, there are times when you want to pin the batch size. You can do this using --ops-per-sample <n> in the CLI or WithOpsPerSample(n) in the API.
Pinning K is useful when:
- Comparing across environments: You want to ensure two different machines are using the exact same batching strategy to eliminate any potential (though rare) variance in calibration logic.
- Stability testing: You suspect that a very large K is masking a periodic spike that would be visible at a smaller K.
Note that OpsPerSample is a host-wide setting. It applies to every benchmark in the suite or harness, as it defines the fundamental measurement resolution for the run.
The Warning Sign: MarginOfError ±0
If you see a result where the Error (the confidence interval) is exactly ±0ns (0.0%), it is rarely a sign of perfect stability. Instead, it is a sign of quantization.
A $\pm 0$ error means that every single sample recorded exactly the same value. When this happens with sub-microsecond operations, it almost always means the operation is faster than the clock's ability to distinguish between two different values. The clock is simply rounding every sample to the same "tick."
When you see this, the solution is to raise K. By increasing the ops-per-sample, you push the total sample duration into a range where the clock can actually see the variance in your code's performance.
Beyond Batching: Jitter and Quantization
Batching solves the resolution problem, but it doesn't solve everything. There are deeper issues - like the way the CPU's TSC (Time Stamp Counter) can drift or how the OS handles power states - that can introduce "jitter" into your numbers.
NBenchmark includes an advanced jitter auto-switch and quantization correction logic that operates beneath the batching layer to further refine the numbers. The deep dive into how the engine manages these hardware-level quirks is covered in the final post of the series.
Example: Fast Pure Function
Consider a simple function that calculates a hash.
[Benchmark]
public int CalculateHash(string input) => string.GetHashCode(input);
Run 1: Auto-Calibration (Default) The engine detects the operation is $\sim 15\text{ns}$. It calibrates $K$ to $1,000$. It times $1,000$ hashes $\approx 15,000\text{ns}$. Result: $15\text{ns} \pm 0.2\text{ns}$.
Run 2: Pinned K=1 (Forced) The engine times a single hash. The timer resolution is $30\text{ns}$. The result is either $30\text{ns}$ or $60\text{ns}$ based on where the clock tick falls. Result: $30\text{ns} \pm 0\text{ns}$ (Quantization Error).
By batching, NBenchmark turns a coin-flip measurement into a defensible number.
For a deeper dive into the cost of memory and the state of the heap during these measurements, see Counting what the GC sees.