Skip to main content
C# advanced Lesson 22 of 25

Performance in C#

Span<T>, Memory<T>, ArrayPool, BenchmarkDotNet, and value types for high-performance C#.

Measuring Before Optimizing

The first rule of performance work is: measure before you change anything. Intuition about bottlenecks is frequently wrong, and optimizing the wrong place wastes time while making the code harder to read. BenchmarkDotNet is the standard tool for .NET microbenchmarks — it handles warmup, statistical analysis, and allocation reporting automatically. Always run benchmarks in Release mode; Debug builds are significantly slower.

dotnet add package BenchmarkDotNet
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]   // shows allocations per operation — critical for GC tuning
[SimpleJob]
public class StringBenchmarks
{
    private const int N = 10_000;

    [Benchmark(Baseline = true)]
    public string Concatenation()
    {
        // String concatenation in a loop is O(N²) — each += allocates a new string
        string result = "";
        for (int i = 0; i < N; i++)
            result += i.ToString();
        return result;
    }

    [Benchmark]
    public string WithStringBuilder()
    {
        // StringBuilder is O(N) — appends to an internal buffer, one allocation at the end
        var sb = new System.Text.StringBuilder(N * 5);
        for (int i = 0; i < N; i++)
            sb.Append(i);
        return sb.ToString();
    }

    [Benchmark]
    public string WithInterpolatedHandler()
    {
        var sb = new System.Text.StringBuilder(N * 5);
        for (int i = 0; i < N; i++)
            sb.Append($"{i}");
        return sb.ToString();
    }
}

// Run benchmarks — MUST be in Release mode
// dotnet run -c Release
BenchmarkRunner.Run<StringBenchmarks>();

Span<T> — Zero-Allocation Slicing

Every time you call Substring, Split, or similar string methods, .NET allocates a new string on the heap. In a tight parsing loop this creates enormous GC pressure. Span<T> solves this by representing a window into an existing block of memory — no new allocation, no copy. It is a stack-only ref struct, meaning it cannot be stored in fields or used across await points, but for synchronous parsing and processing it eliminates a whole class of allocations.

using System;

// Slice a string without allocating a new string — AsSpan returns a view
ReadOnlySpan<char> Slice(string s, int start, int length)
    => s.AsSpan(start, length);

// Parse an IP address without allocating substrings for each octet
bool TryParseIpAddress(ReadOnlySpan<char> input, out byte[] parts)
{
    parts = new byte[4];
    int partIndex = 0;

    while (input.Length > 0)
    {
        int dot = input.IndexOf('.');
        ReadOnlySpan<char> segment = dot < 0 ? input : input[..dot];
        if (!byte.TryParse(segment, out parts[partIndex++])) return false;
        if (dot < 0) break;
        input = input[(dot + 1)..];
    }

    return partIndex == 4;
}

// stackalloc — allocate a small buffer on the stack, zero heap allocation
Span<int> stackBuffer = stackalloc int[32];
for (int i = 0; i < stackBuffer.Length; i++)
    stackBuffer[i] = i * i;
int sum = 0;
foreach (int val in stackBuffer) sum += val;

// Span over an array — slice without copying
int[] data = { 1, 2, 3, 4, 5, 6, 7, 8 };
Span<int> middle = data.AsSpan(2, 4);  // { 3, 4, 5, 6 } — same memory
middle[0] = 99;                         // modifies data[2] directly

Memory<T> for Async Pipelines

Span<T> cannot cross await boundaries because it lives on the stack and the stack changes between suspension points. Memory<T> is the heap-allocated counterpart that can be stored in fields and passed across async calls. Use it when you need the zero-copy benefits of Span<T> in an async context, such as reading from a network stream into a reusable buffer.

public class DataPipeline
{
    // Memory<T> can live in a field — Span<T> cannot
    private readonly Memory<byte> _buffer;

    public DataPipeline(int capacity)
        => _buffer = new byte[capacity];

    public async Task ProcessAsync(Stream source, CancellationToken ct)
    {
        int bytesRead;
        int offset = 0;

        // Memory<T> survives across the await — Span<T> would be a compile error here
        while ((bytesRead = await source.ReadAsync(_buffer[offset..], ct)) > 0)
        {
            offset += bytesRead;
            if (offset >= _buffer.Length)
                await FlushAsync(ct);
        }

        if (offset > 0)
            await FlushAsync(ct);
    }

    private async Task FlushAsync(CancellationToken ct)
    {
        // Process _buffer contents, then reset offset
        await Task.CompletedTask;
    }
}

ArrayPool<T>

Temporary arrays — buffers for encoding, compression, serialization — are a common source of GC pressure because they are allocated, used briefly, and discarded. ArrayPool<T>.Shared maintains a pool of reusable arrays. Renting from the pool avoids a heap allocation; returning it makes it available for the next caller. The rented array may be larger than requested, so always track how many elements you actually used.

using System.Buffers;

public byte[] CompressData(byte[] input)
{
    // Rent from the pool — avoids allocating a new byte[] on the heap
    byte[] rented = ArrayPool<byte>.Shared.Rent(input.Length * 2);
    try
    {
        int written = DoCompress(input, rented);
        // Copy only the used bytes — rented array is often larger than requested
        byte[] result = new byte[written];
        rented.AsSpan(0, written).CopyTo(result);
        return result;
    }
    finally
    {
        // Always return — use a finally block so it happens even on exception
        ArrayPool<byte>.Shared.Return(rented, clearArray: true);
    }
}

// IMemoryOwner — cleaner ownership pattern that uses Dispose to return the buffer
public async Task ProcessStreamAsync(Stream input)
{
    using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
    Memory<byte> buffer = owner.Memory;

    int bytesRead = await input.ReadAsync(buffer);
    ProcessData(buffer.Span[..bytesRead]);
    // owner.Dispose() returns buffer to pool automatically
}

Struct vs Class for Hot Paths

Classes are heap-allocated and tracked by the garbage collector. For small, short-lived objects created at high frequency — game entities, math primitives, event records — this GC overhead is measurable. Making them structs instead keeps them on the stack or inline in arrays, producing zero GC pressure. Mark them readonly to prevent defensive copies when passed by value.

// Vector2 as a class — heap allocated, one GC-tracked object per instance
public class Vector2Class { public float X, Y; }

// Vector2 as a readonly struct — stack allocated, zero GC pressure
public readonly struct Vector2
{
    public float X { get; }
    public float Y { get; }

    public Vector2(float x, float y) => (X, Y) = (x, y);

    // Operators return new structs — no allocation, just stack values
    public static Vector2 operator +(Vector2 a, Vector2 b)
        => new(a.X + b.X, a.Y + b.Y);

    public static Vector2 operator *(Vector2 v, float scalar)
        => new(v.X * scalar, v.Y * scalar);

    public float Length => MathF.Sqrt(X * X + Y * Y);

    public Vector2 Normalized()
    {
        float len = Length;
        return len > 0 ? new(X / len, Y / len) : new(0, 0);
    }
}

// Benchmark result for 10M vector additions:
// class:  ~400ms, 480MB allocated (GC runs constantly)
// struct: ~15ms,  0B allocated   (no GC involvement)

Avoiding Allocations with ref and in

Passing large structs by value copies all their bytes on every method call. The in modifier passes a struct by read-only reference — one pointer, no copy — giving you the performance of a reference type without heap allocation. For return values, ref return lets callers access an element in an array directly without copying it out.

// 'in' parameter — passes by read-only reference, no struct copy
public float DistanceBetween(in Vector3 a, in Vector3 b)
{
    float dx = a.X - b.X;
    float dy = a.Y - b.Y;
    float dz = a.Z - b.Z;
    return MathF.Sqrt(dx * dx + dy * dy + dz * dz);
}

// ref return — caller gets a reference to an element, can modify it in place
private int[] _data = new int[1000];
public ref int GetRef(int index) => ref _data[index];

// Modifies _data[5] directly — no copy in or out
ref int slot = ref GetRef(5);
slot = 42;

String Performance

String formatting and concatenation are surprisingly common sources of unnecessary allocations. string.Create lets you write directly into a newly allocated string’s buffer via a Span<char>, skipping all intermediate strings. For repeated parsing of many small identical strings (CSV columns, log levels), a string pool reduces allocations by returning the same interned string instance each time.

// string.Create — build a custom formatted string with one allocation and no intermediates
static string FormatUserId(int id, string name)
    => string.Create(name.Length + 10, (id, name), static (span, state) =>
    {
        var (id, name) = state;
        span[0] = 'U';
        id.TryFormat(span[1..], out int written);
        span[written + 1] = ':';
        name.AsSpan().CopyTo(span[(written + 2)..]);
    });

// Use StringPool (from CommunityToolkit.HighPerformance) for interning
// when you parse many repeated short strings (CSV columns, enum-like values, etc.)

Unsafe Code and Pointers

When every nanosecond counts and all other options are exhausted, unsafe code gives you direct pointer manipulation with no bounds checking. SIMD intrinsics let a single CPU instruction operate on multiple values simultaneously, achieving throughputs that are simply impossible with scalar code. Profile first — these techniques add significant complexity and should only be used in verified hot paths.

// Enable in .csproj: <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
public static unsafe void FillWithZero(Span<byte> data)
{
    fixed (byte* ptr = data)
    {
        // Direct memory operation — no bounds checking, maximum throughput
        System.Runtime.CompilerServices.Unsafe.InitBlockUnaligned(ptr, 0, (uint)data.Length);
    }
}

// SIMD vectorization — process 8 floats per instruction with AVX
using System.Runtime.Intrinsics;
using System.Runtime.Intrinsics.X86;

public static float SumFloatsSimd(float[] data)
{
    // Fall back to scalar if AVX is not supported or array is too small
    if (!Avx.IsSupported || data.Length < 8)
        return data.Sum();

    var vsum = Vector256<float>.Zero;
    int i = 0;
    int limit = data.Length - (data.Length % 8);

    fixed (float* ptr = data)
    {
        for (; i < limit; i += 8)
            vsum = Avx.Add(vsum, Avx.LoadVector256(ptr + i));  // 8 additions at once
    }

    // Reduce the 8-element vector to a scalar sum
    float sum = 0;
    for (int j = 0; j < 8; j++) sum += vsum.GetElement(j);
    for (; i < data.Length; i++) sum += data[i];  // handle remainder
    return sum;
}

GC Tuning Tips

The garbage collector is the biggest source of latency spikes in .NET applications. Most tuning comes down to one principle: allocate less, especially in hot paths. Object pools extend this principle to objects that are expensive to construct — instead of creating and discarding them, you reset and reuse them.

// Object pool — reuse expensive objects instead of allocating new ones each time
using Microsoft.Extensions.ObjectPool;

public class EmailMessage { public string To { get; set; } = ""; /* ... */ }

var pool = ObjectPool.Create<EmailMessage>();
var msg = pool.Get();   // retrieve a recycled instance
try
{
    msg.To = "alice@example.com";
    await SendAsync(msg);
}
finally
{
    pool.Return(msg);  // reset and return to pool for the next caller
}

// Force a full GC at a known idle point (rarely needed — let the GC manage itself)
// Useful before a latency-sensitive operation if you want a clean slate
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
GC.WaitForPendingFinalizers();

Frequently Asked Questions

When should I actually optimize for performance?
Measure first with a profiler or BenchmarkDotNet. Premature optimization wastes time and makes code harder to maintain. Optimize when you have a measured bottleneck in a hot path that matters for your application's goals.
What is the difference between Span<T> and Memory<T>?
Span<T> is a stack-only ref struct — it cannot be stored in a field, boxed, or used across await points. Memory<T> is a heap-allocated wrapper around a contiguous buffer that can cross async boundaries. Use Span<T> for synchronous processing; Memory<T> for async pipelines.
What does ArrayPool do?
ArrayPool<T>.Shared rents a pre-allocated array from a pool instead of allocating a new one. This avoids GC pressure in code that frequently needs temporary arrays. Always return rented arrays to the pool.