Fewer allocations with Span<T> in .NET

Span<T> lets you work on a slice of memory without a copy. A small parser shows the change, before and after.

On this page

The cost of a small string

Many programs read text and pull small pieces out of it, such as the fields of a log line. In C#, the usual tools for this are string.Split and Substring. They are easy to read and give correct results. Each call also creates new objects.

Creating an object is called an allocation. The object goes on the managed heap, the part of memory that .NET looks after for you. Later, the garbage collector (opens in a new tab) (GC) finds objects that nothing uses any more and frees their memory. The fewer objects you allocate, the less work the GC has to do.

The documentation for String.Split (opens in a new tab) says this in its notes on performance: the method allocates the array it returns, plus one new string for each element. When memory matters, it suggests IndexOf instead.

Below, I write a small parser twice: once with strings and once with ReadOnlySpan<char>. Then a short program counts the bytes that each version allocates.

What a span is

Span<T> is a view over a block of memory that sits in one piece. It holds only two things: a reference to the first element, and a length. A span does not own the memory, and creating one copies nothing. ReadOnlySpan<T> is the same idea, except that you can only read through it.

The memory behind a span can be an array, a buffer on the stack, or native memory from outside .NET. A ReadOnlySpan<char> can also point into a string. When you slice a span, you get a new view with a different start and length. The data itself stays where it is.

The samples in this post are written for .NET 10 file-based apps (opens in a new tab): one .cs file with no project file. Save a sample as a file and run it with dotnet run file.cs. The first sample below is a complete file. The others are the main parts of longer files, and the text says what they print.

C#
// A span over an array. A slice is a view, so a write shows in the array.
int[] numbers = [1, 2, 3, 4, 5, 6];
Span<int> middle = numbers.AsSpan(2, 3);   // the view holds 3, 4, 5
middle[0] = 30;
Console.WriteLine(string.Join(", ", numbers));

// A read-only span over a string. No new string is made.
string line = "status=200;bytes=5120;ms=12.5";
ReadOnlySpan<char> value = line.AsSpan(17, 4);
Console.WriteLine(int.Parse(value) + 1);

// A small buffer can live on the stack. Keep a limit, and use an array above it.
int length = 64;
Span<byte> buffer = length <= 1024 ? stackalloc byte[length] : new byte[length];
buffer.Clear();   // new stackalloc memory has no defined content, so clear it
Console.WriteLine(buffer.Length);

The program prints 1, 2, 30, 4, 5, 6, because the write through the slice changed the array. Next, it reads 5120 from the middle of the string with no new string, adds 1, and prints 5121. Last, it prints 64, the size of a buffer on the stack.

Memory on the stack

The stackalloc keyword creates a buffer on the stack. The stack is the small area of memory that holds the local variables of each method call. The buffer goes away when the method returns, so the GC never deals with it. Stack space is limited. The C# reference for stackalloc (opens in a new tab) suggests a size limit, with a normal array for larger sizes, as in the sample. It also says that new stack memory has no defined content. Clear it or fill it before you read it.

The parser with Split and Substring

The test input is one line of key=value pairs with a semicolon between them: status=200;bytes=5120;ms=12.5. The parser returns the three numbers in a readonly record struct Reading(int Status, int Bytes, double Ms). A struct is a value type: its data sits in the variable that holds it, so the result itself adds nothing to the heap.

The first version cuts the line into pairs with Split. In each pair, it finds the equals sign with IndexOf. Then Substring cuts the pair into a key and a value.

C#
// Before: string.Split and Substring. Every piece becomes a new string.
public static Reading ParseWithStrings(string line)
{
    ArgumentNullException.ThrowIfNull(line);
    int status = 0, bytes = 0;
    double ms = 0;

    foreach (string part in line.Split(';'))
    {
        int eq = part.IndexOf('=');
        if (eq < 0) throw new FormatException($"No '=' in \"{part}\".");

        string key = part.Substring(0, eq).Trim();
        string value = part.Substring(eq + 1).Trim();

        switch (key)
        {
            case "status": status = int.Parse(value, CultureInfo.InvariantCulture); break;
            case "bytes": bytes = int.Parse(value, CultureInfo.InvariantCulture); break;
            case "ms": ms = double.Parse(value, CultureInfo.InvariantCulture); break;
        }
    }

    return new Reading(status, bytes, ms);
}

Both versions pass CultureInfo.InvariantCulture to the parse methods. Without it, they use the current culture, which comes from the settings of the machine by default and can be changed for each thread. In a small test on my machine, double.Parse("12.5") returned 125 with the German culture and threw a FormatException with Canadian French.

This version is short and correct for the test input. On each call it creates one array and nine strings: three from Split and six from Substring.

The same parser with spans

The second version gets a ReadOnlySpan<char> over the line with AsSpan(). From there on, every piece is a slice of that span, and no string is created.

C#
// After: ReadOnlySpan<char>. Every piece is a view into the same line.
public static Reading ParseWithSpans(string line)
{
    // AsSpan() turns null into an empty span, so check for null first.
    ArgumentNullException.ThrowIfNull(line);
    int status = 0, bytes = 0;
    double ms = 0;

    ReadOnlySpan<char> text = line.AsSpan();
    foreach (Range range in text.Split(';'))
    {
        ReadOnlySpan<char> part = text[range];
        int eq = part.IndexOf('=');
        if (eq < 0) throw new FormatException($"No '=' in \"{part}\".");

        ReadOnlySpan<char> key = part[..eq].Trim();
        ReadOnlySpan<char> value = part[(eq + 1)..].Trim();

        switch (key)
        {
            case "status": status = int.Parse(value, CultureInfo.InvariantCulture); break;
            case "bytes": bytes = int.Parse(value, CultureInfo.InvariantCulture); break;
            case "ms": ms = double.Parse(value, CultureInfo.InvariantCulture); break;
        }
    }

    return new Reading(status, bytes, ms);
}

Most lines match the first version. These are the parts that changed:

  • text.Split(';') is MemoryExtensions.Split (opens in a new tab), which needs .NET 9 or later. It gives back each piece as a Range, a start and an end position, with no new string. text[range] turns the range into a slice.
  • part[..eq] and part[(eq + 1)..] use the C# range operator to slice the span.
  • Trim() on a span returns a smaller view of the same characters.
  • switch (key) compares a ReadOnlySpan<char> with constant strings. C# has allowed this since C# 11.
  • int.Parse and double.Parse have overloads, which are other versions of the same method, that take a ReadOnlySpan<char>. So the value never has to become a string.

Figure 1 follows the pair bytes=5120 through both versions.

One string and the views into itThe line status=200;bytes=5120;ms=12.5 is one string of 29 characters, at index 0 to 28. Three ReadOnlySpan<char> views point into it and copy nothing: part starts at index 11 with length 10, key starts at 11 with length 5, and value starts at 17 with length 4. Below, the Split and Substring version holds the same characters in three new strings: bytes=5120 from Split, and bytes and 5120 from Substring.line: one string on the heapstatus=200;bytes=5120;ms=12.50111728ReadOnlySpan<char>views: start, lengthnothing is copiedpart11, 10key11, 5value17, 4Split and Substringnew strings, chars copiedbytes=5120from Splitbytesfrom Substring5120from Substring
Figure 1 The pair bytes=5120 in both parsers. The spans point into the line. Split and Substring copy its characters into new strings.

Both parsers return the same Reading. I checked this with the line above and with a messy line that has extra spaces and an unknown key. Both checks print True. These parsers are a demo. A missing key leaves its value at 0, and a repeated key keeps the last value. Real code should check for both.

If you need a piece as a real string, for example as a dictionary key, call ToString() on that span. It copies the characters into a new string, so you pay only for the pieces you keep.

Counting the bytes

To count allocations, I used GC.GetAllocatedBytesForCurrentThread() (opens in a new tab). It returns the total number of bytes that the current thread has allocated on the managed heap since the thread started. The total includes objects that the GC has already freed. It does not include native memory. Read it before and after a loop, and the difference is what the loop allocated.

C#
// Runs one piece of work many times and returns the managed heap bytes per call.
static double BytesPerCall<T>(Func<string, T> work, string line)
{
    const int Calls = 1_000_000;

    // Warm up first, so the JIT compiler is done before we count.
    // The JIT turns .NET code into machine code while the app runs.
    for (int i = 0; i < 10_000; i++) work(line);

    long before = GC.GetAllocatedBytesForCurrentThread();
    for (int i = 0; i < Calls; i++) work(line);
    long after = GC.GetAllocatedBytesForCurrentThread();

    return (after - before) / (double)Calls;
}

The warm-up loop runs first, so one-time work such as compiling the code is not counted. Then the helper makes one million calls and divides. The full file also measures Split alone and the six Substring calls alone. I ran it with dotnet run -c Release measure.cs and got this output:

Output
.NET 10.0.12, Microsoft Windows 10.0.26300, X64, Release build
Whole parse, Split and Substring: 384 bytes per call
Whole parse, ReadOnlySpan<char>:  0 bytes per call
Only line.Split(';'):             184 bytes per call
Only the six Substring calls:     200 bytes per call

The parts add up. Split allocated 184 bytes per call and the six Substring calls allocated 200 bytes, which makes the 384 bytes of the string version. The span version allocated 0 bytes. Two more Release runs and one Debug run printed the same numbers.

Read these numbers as the result of this one test. They depend on the input and on the runtime, and they say nothing about speed. To compare time, use a benchmark tool that runs many rounds and shows how much the results vary.

Where a span cannot go

A span can point to memory on the stack, and that memory goes away when its method returns. So a span over stack memory must not outlive the method that owns that memory, and no span may move to the heap. For this reason, Span<T> and ReadOnlySpan<T> are ref struct types (opens in a new tab). A ref struct is a struct that the compiler keeps on the stack. The compiler checks both rules, and they mean that you cannot:

  • box a span, which means converting it to object or to an interface type
  • declare a field of a span type in a class or in a normal struct
  • create an array of spans
  • capture a span in a lambda or a local function, which are small functions written inside a method

I tried each of these with the .NET 10 SDK, and each one stopped the build with a compiler error.

Spans in async methods

An async method can stop at an await and continue later. A span cannot be kept across that pause. Before C# 13, the compiler did not allow span variables in async methods at all. What's new in C# 13 (opens in a new tab) allows these variables in async methods, with one limit:

However, those variables can't be accessed across an await boundary.

What's new in C# 13, Microsoft Learn

So you can create and use a span between two awaits. If you create it before an await and use it after, the build fails with error CS4007. A .NET 10 app uses C# 14 by default, so the relaxed rule applies.

C#
// Two lines with 2 and 3 pairs, and a blank line between them.
var reader = new StringReader("status=200;bytes=5120\n\nstatus=404;bytes=0;ms=0.5\n");
Console.WriteLine(await Lines.CountPairsAsync(reader));

var saved = new SavedLine("status=200;bytes=5120;ms=12.5".AsMemory());
Console.WriteLine(await saved.CountPairsLaterAsync());

static class Lines
{
    // Span work in a normal method. This needs no C# 13 feature.
    // It counts the pieces between semicolons and skips empty ones.
    public static int CountPairs(ReadOnlySpan<char> line)
    {
        int pairs = 0;
        while (true)
        {
            int end = line.IndexOf(';');
            ReadOnlySpan<char> piece = end < 0 ? line : line[..end];
            if (!piece.Trim().IsEmpty) pairs++;
            if (end < 0) return pairs;
            line = line[(end + 1)..];   // go on after the semicolon
        }
    }

    // C# 13 and later: a span local is allowed in an async method,
    // as long as no await comes between where you create it and where you use it.
    public static async Task<int> CountPairsAsync(TextReader reader)
    {
        int pairs = 0;
        while (await reader.ReadLineAsync() is string line)
        {
            ReadOnlySpan<char> text = line.AsSpan();
            pairs += CountPairs(text);
        }
        return pairs;
    }
}

// Memory<T> can be a field of a class, and it can wait across an await.
sealed class SavedLine(ReadOnlyMemory<char> line)
{
    private readonly ReadOnlyMemory<char> _line = line;

    public async Task<int> CountPairsLaterAsync()
    {
        await Task.Delay(10);
        return Lines.CountPairs(_line.Span);   // the span lives only inside this call
    }
}

In CountPairsAsync, each span is created and used inside one pass of the loop, after the await, so the code compiles. CountPairs shows the other fix, which also works before C# 13: do the span work in a normal method, and call it from the async method. I built that version with C# 12 to check it. The sample prints 5 for the two lines in the reader and 3 for the saved line.

When Memory<T> is the right type

Memory<T> and ReadOnlyMemory<T> also describe a block of memory in one piece. That memory is usually an array or a string, and never stack memory. They are normal structs, so they can live on the heap. You can keep one in a field of a class, and you can keep it across an await. For a string, AsMemory() gives you a ReadOnlyMemory<char>. When you need to read the data, take the Span property and do the work in synchronous code. SavedLine above works this way.

FeatureSpan<T>, ReadOnlySpan<T>Memory<T>, ReadOnlyMemory<T>
Kind of typereadonly ref structreadonly struct
Field in a classNoYes
Kept across an awaitNoYes
Over stackalloc memoryYesNo
Over a stringReadOnlySpan<char>ReadOnlyMemory<char>
Table 1 The two families side by side, from the Microsoft Learn pages for each type.

The usage guidelines for Memory<T> and Span<T> (opens in a new tab) help you choose. For a synchronous method, which is a method with no await, take a Span<T> parameter if you can, and the read-only type if the method only reads. Take Memory<T> when the method must keep the data across an await, or when an object keeps it in a field, as SavedLine does.

The guidelines also set a time limit: a method that receives a Memory<T> may use it only until it returns, or until its task completes. After that, the owner of the buffer may reuse the memory for something else. An object that gets a Memory<T> in its constructor may use it in its own methods. The owner must not reuse the buffer while that object still uses it.

Read more

More posts