State that survives between benchmarks
Managing instance lifetimes and shared state to prevent contamination and ensure statistically valid results.
The most dangerous bug in a benchmark is not a crash; it's a cache.
Imagine you have two benchmarks: one that populates a static cache and another that reads from it. If you run them in that order, the second benchmark will report a blazing fast execution time. If you run them in reverse, it will be significantly slower.
This is state contamination. When one benchmark alters the environment in a way that benefits the next, your results are no longer independent. Your significance tests, which rely on the assumption that every sample is an independent event, are now lying to you. They will report "statistically significant" differences that are actually just the side effects of a shared cache.
Understanding instance lifetimes
NBenchmark gives you precise control over how your benchmark classes are instantiated. This allows you to balance the cost of construction against the need for isolation.
Per-Method (The Default)
By default, NBenchmark creates a fresh instance of your benchmark class for every single method. If you have three [Benchmark] methods in a class, the engine instantiates the class three times. This is the safest approach, as it ensures that instance fields are reset between benchmarks.
Per-Class
When construction is expensive - for example, when you need to establish a database connection or load a 1GB dataset into memory - you can use [InstanceLifetime(InstanceLifetime.PerClass)].
This tells the engine to reuse the same instance for every benchmark method in the class. This amortizes the construction cost, but it opens the door to contamination. If Method A modifies a private field that Method B relies on, you have introduced a hidden dependency.
Remedies for shared state
If you must use PerClass for performance reasons, you need a way to scrub the state between runs.
IStateReset
The most robust solution is the IStateReset interface. By implementing ResetAsync, you can define exactly how to return your class to a clean state.
public class DatabaseBenchmark : IStateReset
{
private readonly MyDbContext _context = new();
[Benchmark]
public void QueryData() => _context.Users.ToList();
public async Task ResetAsync()
{
// Clear the cache or truncate tables between benchmarks
await _context.Database.EnsureDeletedAsync();
await _context.Database.EnsureCreatedAsync();
}
}
NBenchmark calls ResetAsync between benchmarks, immediately after the inter-benchmark Garbage Collection. This ensures that each method starts with a predictable state without the overhead of full class re-instantiation.
Explicit Declarations
If the carry-over is deliberate - for example, if you are specifically measuring how a cache performs over time - you can mark the state with [SharedState]. This tells the engine (and other developers) that the contamination is an intended part of the measurement.
Integrating Dependency Injection
In real-world applications, your code doesn't just use new(); it uses a dependency injection (DI) container. NBenchmark supports this via the NBenchmark.DependencyInjection package.
Instead of a parameterless constructor, you can use WithServices or WithScopedServices to provide a IServiceProvider to the worker process. This allows your benchmarks to resolve real services, configuration, and logging exactly as they would in production.
var services = new ServiceCollection()
.AddSingleton<ICache, RedisCache>()
.BuildServiceProvider();
var suite = new BenchmarkSuite()
.Add(new CacheBenchmark())
.WithServices(services);
The ASP.NET and EF Pitfall
A common mistake when combining DI and PerClass lifetimes is the "Scoped Service Trap."
If you resolve a scoped service (like an Entity Framework DbContext) in the constructor of a PerClass benchmark, that context lives for the entire duration of the suite. In a real application, a DbContext is short-lived. By keeping it alive across multiple benchmarks, you are measuring a bloated, long-lived context that behaves differently than a production request.
NBenchmark's analyzers catch this at compile time. NB0011 warns you when a scoped service is used under a PerClass lifetime, and NB0013 flags mutable instance fields that could lead to contamination. These warnings are your first line of defense against "confident nonsense."
By managing your lifetimes and scrubbing your state, you ensure that your results are a product of your code's efficiency, not the order in which you wrote your methods.
Go deeper: Now that we've secured the state within a single runtime, let's look at the bigger picture. What happens when you need to know if a new version of the .NET runtime itself provides a performance win?