Benchmarking C# Hot Paths to Eliminate Allocations
Optimizing hot paths in high-throughput C# libraries requires precise
measurement to eliminate performance bottlenecks and Garbage Collection
(GC) overhead. This article explains how to configure BenchmarkDotNet to
capture memory metrics, identify common allocation sources in critical
execution loops, and rewrite algorithms using modern .NET primitives
like Span<T>, ValueTask, and array
pooling.
Measuring Allocations with BenchmarkDotNet
Before attempting any optimization, you must establish an accurate baseline. BenchmarkDotNet is the standard framework for microbenchmarking in .NET. It isolates processes, warms up the runtime, and calculates statistical metrics while preventing Just-In-Time (JIT) compiler optimizations from skewing results.
To measure memory pressure alongside execution time, apply the
[MemoryDiagnoser] attribute to your benchmark class.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class StringParserBenchmark
{
private const string RawData = "User:10492;Status:Active;Role:Admin";
[Benchmark(Baseline = true)]
public string ParseSubstring()
{
int start = RawData.IndexOf("Status:") + 7;
int end = RawData.IndexOf(';', start);
return RawData.Substring(start, end - start);
}
}Run benchmarks exclusively in Release mode using the
command line:
dotnet run -c ReleaseThe resulting table reports execution time (Mean),
Garbage Collection counts (Gen0, Gen1), and
most importantly, Allocated bytes. Any value above
0 B in a critical loop represents heap allocation that will
eventually trigger GC pauses.
Common Hidden Allocations in Hot Paths
Hot paths often incur heap allocations through syntax that appears harmless:
- String Operations: Methods like
string.Substring,string.Split, and string concatenation create brand newSystem.Stringinstances on the managed heap. - Boxing: Casting value types to
objector interfaces (such as passing anintor anenumto an unconstrained generic or logging method) wraps the struct inside a heap-allocated object. - Closures and Delegates: Passing lambdas that capture outer local variables allocates a compiler-generated display class to hold the captured state.
- LINQ: Standard LINQ queries
(
.Select(),.Where(),.ToList()) instantiate enumerator classes and intermediate arrays or collections.
Techniques to Achieve Zero-Allocation Paths
1. Replace Substrings with Spans
ReadOnlySpan<T> provides a type-safe view of
contiguous memory without allocating heap objects. When working with
string data, use span-based slicing:
[Benchmark]
public ReadOnlySpan<char> ParseSpan()
{
ReadOnlySpan<char> span = RawData.AsSpan();
int start = span.IndexOf("Status:".AsSpan()) + 7;
ReadOnlySpan<char> slice = span.Slice(start);
int end = slice.IndexOf(';');
return slice.Slice(0, end);
}Because ReadOnlySpan<T> is a
ref struct, it lives entirely on the stack and produces
0 B in the Allocated column.
2. Rent Buffers Instead of Allocating Arrays
If your hot path requires a transient buffer to process data, avoid
new byte[size]. Instead, rent temporary memory from
ArrayPool<T>.Shared or use stackalloc
for small, known boundaries:
[Benchmark]
public void ProcessDataWithArrayPool()
{
byte[] buffer = System.Buffers.ArrayPool<byte>.Shared.Rent(256);
try
{
// Perform work on the rented buffer
buffer[0] = 1;
}
finally
{
System.Buffers.ArrayPool<byte>.Shared.Return(buffer);
}
}3. Eliminate Closure Allocations
When using callbacks or concurrent collections, pass contextual state
through methods that accept an explicit state parameter
rather than capturing variables:
// Allocates a closure:
int threshold = 10;
items.RemoveAll(x => x > threshold);
// Optimized (custom iteration without lambda capture):
for (int i = items.Count - 1; i >= 0; i--)
{
if (items[i] > threshold)
{
items.RemoveAt(i);
}
}4. Use ValueTask for Synchronous Completions
Asynchronous methods on hot paths that frequently complete
synchronously (such as reading from an in-memory cache) should return
ValueTask<T> instead of Task<T>.
While Task<T> always allocates an object reference,
ValueTask<T> is a discriminated union struct that
avoids heap allocation when the result is immediately available.
Verifying Optimization Iterations
After applying code changes, run the benchmark suite again to compare the baseline against optimized implementations. A successful hot path optimization should demonstrate:
- Allocated Bytes: Reduced to
0 B(or minimized significantly if an allocation is strictly unavoidable). - Gen0 Collections: Drop to
0.0000per 1,000 operations. - Execution Time: Lower
Meanlatency resulting from reduced GC pause frequency and improved CPU cache locality.