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 Release

The 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:

  1. String Operations: Methods like string.Substring, string.Split, and string concatenation create brand new System.String instances on the managed heap.
  2. Boxing: Casting value types to object or interfaces (such as passing an int or an enum to an unconstrained generic or logging method) wraps the struct inside a heap-allocated object.
  3. Closures and Delegates: Passing lambdas that capture outer local variables allocates a compiler-generated display class to hold the captured state.
  4. 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.0000 per 1,000 operations.
  • Execution Time: Lower Mean latency resulting from reduced GC pause frequency and improved CPU cache locality.