C# Span Tutorial: Zero-Allocation Performance Guide

The traditional fix was unsafe pointers — fast, but dangerous and unverifiable. Span<T> gives you the same performance profile with full type and bounds safety. It is a ref struct: a tiny value type holding a reference to the start of a memory block plus a length. Creating a span copies nothing; slicing a span copies nothing. It is a window over memory you already have.

Crucially, a Span<T> can point at three different kinds of memory with one unified API:

  • Managed heap — arrays, strings, List<T> backing stores
  • Stack memory — via stackalloc, with zero GC involvement
  • Unmanaged memory — native buffers from interop or NativeMemory.Alloc

That unification is the real win. You can write one parsing method that works on all three sources without overloads, without copying, and without unsafe.

C# Span<T> Tutorial: Your First Zero-Allocation Code

Start with the classic allocation trap — parsing a CSV line with Split:

// SLOW: allocates a string[] plus one string per field, every call
public static decimal ParseTotalSlow(string line)
{
    string[] parts = line.Split(',');
    return decimal.Parse(parts[2]) * int.Parse(parts[3]);
}

For a 4-field line, that is five allocations per call. Now the span version:

using System;
using System.Globalization;

public static decimal ParseTotalFast(ReadOnlySpan<char> line)
{
    // Slice off field 0 and field 1 without allocating
    for (int i = 0; i < 2; i++)
    {
        int comma = line.IndexOf(',');
        line = line[(comma + 1)..];
    }

    int next = line.IndexOf(',');
    ReadOnlySpan<char> priceSpan = line[..next];
    ReadOnlySpan<char> qtySpan   = line[(next + 1)..];

    decimal price = decimal.Parse(priceSpan, CultureInfo.InvariantCulture);
    int qty       = int.Parse(qtySpan, CultureInfo.InvariantCulture);

    return price * qty;
}

// Usage - implicit conversion from string to ReadOnlySpan<char>
decimal total = ParseTotalFast("SKU-88,Widget,19.99,3");

Total allocations: zero. The Parse overloads that accept ReadOnlySpan<char> have existed since .NET Core 2.1 precisely for this pattern. Note the range syntax line[..next] — C# ranges work natively on spans and are compiled to a Slice call with no copy.

Using stackalloc for Small Buffers

When you need a scratch buffer, stackalloc paired with a span keeps it entirely off the heap:

public static string ToHex(ReadOnlySpan<byte> bytes)
{
    // Safe, verifiable stackalloc - no 'unsafe' keyword needed
    Span<char> buffer = stackalloc char[bytes.Length * 2];

    for (int i = 0; i < bytes.Length; i++)
    {
        int b = bytes[i];
        buffer[i * 2]     = GetHexChar(b >> 4);
        buffer[i * 2 + 1] = GetHexChar(b & 0xF);
    }

    return new string(buffer); // one allocation, only for the result
}

private static char GetHexChar(int value) =>
    (char)(value < 10 ? '0' + value : 'a' + value - 10);

Critical rule: never stackalloc inside a loop, and never stackalloc a size derived from untrusted input. The stack is typically 1 MB per thread; overflowing it kills the process with no catchable exception. The standard guardrail is a size threshold with an ArrayPool<T> fallback:

using System.Buffers;

const int StackLimit = 256;

char[]? rented = null;
Span<char> buffer = length <= StackLimit
    ? stackalloc char[StackLimit]
    : (rented = ArrayPool<char>.Shared.Rent(length));

try
{
    buffer = buffer[..length];
    // ... work with buffer ...
}
finally
{
    if (rented is not null)
        ArrayPool<char>.Shared.Return(rented);
}

This hybrid pattern appears throughout the .NET runtime itself and is the single most valuable idiom in high-performance C#.

Memory<T> vs Span<T>: Choosing the Right Type

Span<T> is a ref struct, which the compiler enforces can only live on the stack. That restriction is what makes it safe — a span can never outlive the memory it points to. But it also means a span cannot:

  • Be a field in a class or a regular struct
  • Be captured by a lambda or local function closure
  • Be used across an await or yield return
  • Be boxed or stored in a collection

This is where Memory<T> comes in. It is a normal struct that describes the same memory but can live on the heap. You convert it to a span only when you need to touch the data:

public sealed class ChunkReader
{
    private readonly Memory<byte> _buffer; // legal: Memory can be a field

    public ChunkReader(Memory<byte> buffer) => _buffer = buffer;

    public async Task<int> ReadAsync(Stream stream, CancellationToken ct)
    {
        // Memory<T> works across await; Span<T> would not compile here
        int read = await stream.ReadAsync(_buffer, ct);

        // Get a span only for synchronous work
        Span<byte> window = _buffer.Span[..read];
        Normalise(window);

        return read;
    }

    private static void Normalise(Span<byte> data)
    {
        for (int i = 0; i < data.Length; i++)
            if (data[i] == (byte)'\r') data[i] = (byte)'\n';
    }
}

The practical rule for C# performance optimization: use Span<T> for synchronous method parameters and locals — it is the faster type and should be your default. Reach for Memory<T> only when you must store the reference in a field or cross an async boundary. Accessing .Span has a small cost, so hoist it out of loops rather than calling it repeatedly.

ReadOnlySpan<T> and the String Advantage

Always prefer ReadOnlySpan<T> for parameters you do not mutate. It documents intent, prevents accidental writes, and accepts more source types — including string, which converts implicitly. There is also a hidden compiler optimisation: a ReadOnlySpan<byte> initialised from a constant array is embedded directly in the assembly’s data section with no runtime allocation at all:

// The JIT emits this as a direct pointer into the PE file - zero allocation
private static ReadOnlySpan<byte> JsonPrefix => "{\"data\":"u8;

public static bool StartsWithPrefix(ReadOnlySpan<byte> payload) =>
    payload.StartsWith(JsonPrefix);

The u8 suffix (C# 11+) produces a UTF-8 ReadOnlySpan<byte> literal — ideal for network protocol and JSON parsing where you want to avoid transcoding to UTF-16 entirely.

Measuring the Difference with BenchmarkDotNet

Never optimise without measuring. Here is a benchmark you can run today:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
public class ParsingBenchmarks
{
    private const string Line = "SKU-88,Widget,19.99,3";

    [Benchmark(Baseline = true)]
    public decimal Split() => ParseTotalSlow(Line);

    [Benchmark]
    public decimal Span() => ParseTotalFast(Line);
}

BenchmarkRunner.Run<ParsingBenchmarks>();

On typical .NET 8/9 hardware the span version runs roughly 2–3x faster and, more importantly, reports 0 B allocated versus around 200 B for the Split version. The [MemoryDiagnoser] attribute is what makes allocation regressions visible — add it to every performance benchmark you write.

Best Practices and Common Pitfalls

  • Do not return a span over stackalloc memory. The compiler catches most cases, but returning a span that points at a caller’s expired stack frame is undefined behaviour. Return the data, not the window.
  • Watch for aliasing. Two spans can overlap the same array. CopyTo handles overlap correctly, but hand-written loops may not — use MemoryExtensions.Overlaps if you are unsure.
  • Spans do not keep arrays alive in the pool sense. If you rent from ArrayPool<T>, return it exactly once and stop using every span derived from it immediately.
  • Do not micro-optimise cold paths. Span code is harder to read. Apply it to measured hot paths: parsers, serialisers, request pipelines, image and buffer processing. A controller action that runs 10 times a day gains nothing.
  • Use the built-in helpers. MemoryExtensions gives you IndexOf, Split, Trim, SequenceEqual, BinarySearch, and Sort on spans — most are SIMD-accelerated and will beat any loop you write by hand.
  • Prefer SequenceEqual over element loops for comparison; it vectorises automatically.
  • Beware string interning assumptions. A ReadOnlySpan<char> over a string is not a string — dictionary lookups need .GetAlternateLookup<ReadOnlySpan<char>>() (.NET 9+) to stay allocation-free.

Advanced: Pipelines and SIMD

Once spans are natural to you, the next tier of C# performance optimization is System.IO.Pipelines for streaming I/O and System.Numerics.Vector<T> / System.Runtime.Intrinsics for explicit SIMD. Both build directly on Span<T> and Memory<T>, so the knowledge compounds. Vector.LoadUnsafe and Vector.StoreUnsafe operate on spans, letting you process 16 or 32 bytes per instruction.

Key Takeaways

C# Span<T> is the single highest-leverage tool for eliminating allocations in .NET, and it costs you nothing in safety. To summarise:

  • Span<T> is a stack-only window over contiguous memory — slicing is free, bounds are checked.
  • Memory<T> is the heap-capable equivalent for fields and async methods; call .Span for the actual work.
  • Default to ReadOnlySpan<T> parameters; use u8 literals for UTF-8 constants.
  • Combine stackalloc with an ArrayPool<T> fallback above a ~256-element threshold.
  • Always verify gains with BenchmarkDotNet and [MemoryDiagnoser] — guessing is how optimisation efforts fail.
  • Target hot paths only; readability is a real cost and cold code does not repay it.

Rewrite one hot parsing method in your codebase using these patterns, benchmark before and after, and you will have a concrete number to justify the next one.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

This article is part of the C# tutorials guide (language features, patterns, performance and testing).

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *