3 min readUnder the hood

Why your numbers come from another process

An exploration of process isolation, runtime profiles, and why measuring in-process is a recipe for misleading results.

If you've ever wondered why NBenchmark doesn't just run your code in a simple loop inside your app, the answer is a single word: Isolation.

In .NET, the most important decisions that shape your performance aren't made while your code is running - they are made while the process is starting. JIT tiering, Profile-Guided Optimization (PGO), ReadyToRun (R2R) state, and the Garbage Collection flavor are all decided at the process boundary. If you measure your code in the same process that launched the benchmark, you aren't measuring your code; you're measuring the baggage of the harness.

The 21x Demonstration

To see why this matters, consider a simple, tight loop. When measured in a dedicated, isolated worker process, the results are stable and fast: 320ns, 322ns, 329ns.

Now, run the same code using the --in-process flag. The results shift dramatically: 7,009ns, 6,733ns... and then, on the third attempt, 329ns.

What happened? In the first two attempts, the JIT compiler was still in "Tier 0" (the fast-to-compile, slow-to-run version). By the third attempt, the JIT finally promoted the method to "Tier 1" (optimized). Because the confidence interval stayed tight during the slow runs, a naive benchmark would tell you that the code is 21 times slower than it actually is, and it would do so with absolute confidence.

By spawning a fresh worker process for every measurement, NBenchmark ensures that JIT state and heap contamination from previous runs cannot leak into your current sample.

Runtime Profiles: Tuning the Machine

Isolation isn't just about cleaning the slate; it's about choosing the right slate. NBenchmark uses RuntimeProfile to control exactly how the worker process is configured.

SteadyState (The Default)

The SteadyState profile is designed for pure throughput measurement. It turns off JIT tiering, PGO, and R2R, and disables background GC. This removes the non-deterministic "noise" of the runtime, giving you a number that represents the fully-optimized steady state of your algorithm.

Production

The Production profile reproduces a shipping configuration. It leaves tiering and background GC on. This is deliberately more imprecise, but it tells you how the code will actually behave in a live environment.

ServerGc

This profile forces the worker to start with Server GC enabled, regardless of the host's settings. This is critical for server-side libraries where the GC behavior differs fundamentally from a client-side console app.

Host

The Host profile inherits whatever settings the harness process is currently using.

Because these knobs cannot be applied to a process that is already running, an in-process run silently gets none of them. This is why results from different runtime profiles are never placed in the same comparison group; a "SteadyState" number and a "Production" number are fundamentally different measurements.

Crossing the Wire

Since the code runs in a separate process, NBenchmark has to move data across a process boundary.

When you use a prepare block in Benchmark.Run, that code executes inside the worker process. This is why the prepare/body split is so critical: you build your state in the worker, on the worker's heap, using the worker's JIT state.

This boundary is also why the NBenchmark analyzer NB0014 warns you when a lambda captures local state. That state would have to be serialized and moved across the process boundary, which can introduce hidden costs and change the measurement.

Advanced State Management

For complex scenarios, NBenchmark provides two escape hatches:

  • [BenchmarkState]: Used for state that is explicitly designed to cross the boundary.
  • [BenchmarkPlan]: Used for suites that must build their own fixtures (like custom containers or live databases) directly inside the worker process via RunPlanAsync.

When to Opt Out

Despite the benefits, there are times when isolation is too expensive. You can disable it using [Isolation(Isolation.Off)], WithIsolation(...), or the --in-process flag.

In-process runs are excellent for "smoke tests" - verifying that your benchmark compiles and runs without spending the time to spawn workers. But for any result you intend to publish or use as a regression gate, isolation is non-negotiable.

If you're still not convinced, try the --verify-isolation flag. The engine will run the same benchmark isolated and in-process, then show you the divergence table. Usually, the 21x gap is enough to convince anyone.


Go deeper: Now that we've seen how a fresh process protects your numbers, let's look at the one thing that can survive between benchmarks: state.

State that survives between benchmarks →