High-Performance C# - Span<T>

ref struct / ref field

struct

we usually use a struct type as a local variable, so it typically is a stack allocated value. But it can be allocalted on heap.

struct is a value type, it will be allocated on somewhere it was declared:

  1. declared by a method local variable: [stack]
  2. declared inside another struct: [where the containing struct locates]
  3. declared inside an array: [heap]
  4. declared as a field of a class: [heap]
  5. be boxed to as a object or interface type: [heap]

struct could contain reference type value like string or other object, a reference to their heap addresss will be hold by struct.

ref struct

ref struct is a strictly restricted stack allocation type, it has some strict restrictions:

  1. cannot has reference type field like string or other object
  2. cannot be a field of a class or a regular struct
  3. cannot be an element of an array
  4. cannot be boxed
  5. cannot be used in async(await/yield) methods or lambda expressions
  6. cannot be returned by a method
    All above restriction enfores the ref struct lifetime safety, to avoid dangling reference.
    It allows a zero allocating manipulate memory on stack, heap, native buffers.

ref field

ref struct can has ref field, A ref field is a managed pointer that can point to a location on either the stack or the heap. This is the key to a ref struct‘s power.
behaves like a managed pointer but:

  1. type safety: the pointing object has to be with same type of the ref field decalreation
  2. this pointer cannot be manipulated arithmetically or created like unmanaged pointer
  3. lifetime of ref field <= the lifetime of object it points to

byref (managed by-reference) is a runtime type in IL that represents a reference to a specific memory location (variable, arrayelement, field) inside managed memory or stack. In C#,

  • ref and out parameters
  • ref locals
  • ref returns
    are implemented in IL as a byref. In IL, a byref is represented as a type with a trailing &.
    1
    .method public void Example(int32& x){ ... } //int32& means a managed* reference to an int32
  • what does the “managed” mean here: this reference will be updated when GC move the targeting object. this reference is safe.

Span<T>

Span<T> is a readonly ref struct that represents a contiguous block of memory on stack, heap or unmanaged memory, to mutate the data like string or any type of array. It’s a zero-allocation, safe, high-performance alternative to pointers.
Core fields:

1
2
ref T _reference; // a IL byref to the first element
int _length; //number of elements.

they act like a safe, typed pointer + length.
src/libraries/System.Private.CoreLib/src/System/Span.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// System/Span.cs
[DebuggerTypeProxy(typeof(SpanDebugView<>))]
[DebuggerDisplay("{ToString(),raw}")]
[NonVersionable]
[NativeMarshalling(typeof(SpanMarshaller<,>))]
[Intrinsic]
public readonly ref struct Span<T>
{
// fields
internal readonly ref T _reference;
private readonly int _length;

// ctors
public Span(T[]? array);
public Span(T[]? array, int start, int length);
public unsafe Span(void* pointer, int length);
public Span(ref T reference);
internal Span(ref T reference, int length);

public ref T this[int index] { get; }
public int Length { get; }
public bool IsEmpty { get; }
public static implicit operator Span<T>(T[]? array);
public static implicit operator Span<T>(ArraySegment<T> segment);
public static implicit operator ReadOnlySpan<T>(Span<T> span);

// mutating helpers
public void Clear();
public void Fill(T value);
public void CopyTo(Span<T> destination);
public bool TryCopyTo(Span<T> destination);
public T[] ToArray();

public Span<T> Slice(int start);
public Span<T> Slice(int start, int length);
}

Span<T> enables zero-copy slicing, stackalloc buffers, interop with native memory, and efficient APIs. Providing pointer-like power with compiler/runtime safety.

  • defalut

the defalut value for a Span<T> is default(T) or default(7.1), which is all-zero bits for value type, null for reference types and NullRef() for a ref field (NullRef() is a special byref that’s invalid to dereference). Span<T>.Empty is a default struct.

Types that Span<T> can point to

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. managed arrays
int[] arr = {1,2,3};
Span<int> s = arr;

// 2. array slices
Span<int> sub = arr.AsSpan(1,2); //covers [2,3]

// 3. Strings (via ReadOnly)
string text = "hello";
ReadOnlySpan<char> chars = text.AsSpan(); //"hello"

// 4. Stack-allocated buffers (stackalloc)
Span<byte> buf = stackalloc byte[128]; //raw memory on stack

// 5. Unmanaged memory (void*)
unsafe{
byte* ptr =(byte*)NativeAlloc(100);
Span<byte> s = new Span<byte>(ptr, 100);
}

// 6. References (ref T), used internally and with MemoryMarshal.CreateSpan
int x = 42;
Span<int> one = new Span<int>(ref x);

Constructors (Explicit Creation)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public readonly ref struct Span<T>
{
// Construct from a array, most common way
Span(T[]? array)
Span(T[]? array, int start, int length)

// Construct from an unmanaged pointer, this Span<T> has to be wrapped in a unsafe block
// We are responsible to manage its lifetime, compiler and runtime will not manage it
Span(void* ptr, int length)

// Construct from a ref
// Low-level way to create spans from a reference directly. Used heavily inside runtime and libraries.
Span(ref T reference)
Span(ref T Treference, int lenght)
}

Implicit Conversions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// From an array
T[] arr = new int[5];
Span<int> s = arr; // implicitly using arr.AsSpan();
// and also, we can do slicing
Span<int> s0 = arr.AsSpan(3);
Span<int> s1 = arr.AsSpan(3, 5);
Span<int> s2 = new Span<T>(arr, 3, 5);

//from ArraySegement<T> to Span<T>
ArraySegment<int> seg = new(arr, 1, 3);
Span<int> s2 = seg;

//from Span<T> to ReadOnlySpan<T>
ReadOnlySpan<int> ro = s;

Get a normal array from a Span

Span<T>.ToArray() is to allocate a new managed array:

1
2
Span<int> span = stackalloc int[3]{1,2,3}; //create a value type array on stack with stackalloc keyword. 
int[] arr = span.ToArray(); // allocates and copies

This operation is typically to compatible to some old API which only accepts T[], usually we don’t need to do this as Span changed the original reference.

ArraySegement<T>

ArraySegment in C# is a lightweight structure that represents a contiguous block of elements from a larger array without creating a new copy of the data.

  • it’s a struct version of Span<T>, while Span<T> is a readonly ref struct
  • it’s an old version of Span<T>
  • it’s an heap storable version of Span<T>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public readonly struct ArraySegment<T>
    {
    private readonly T[]? _array;
    private readonly int _offset;
    private readonly int _count;

    public ArraySegment(T[] array);
    public ArraySegment(T[] array, int offset, int count);

    public T[]? Array { get; }
    public int Offset { get; }
    public int Count { get; }
    }

ReadOnlySpan<T>

Even Span<T> is a readonly ref struct, this readonly only means its ref and length is immutable, but we still can manipulate the data the ref points to.
For a string object, it is immutable, so we cannot point it with a Span<T>, we can only reference it on stack with a ReadOnlySpan<T>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// System/ReadOnlySpan.cs
[DebuggerTypeProxy(typeof(ReadOnlySpanDebugView<>))]
[DebuggerDisplay("{ToString(),raw}")]
[NonVersionable]
[Intrinsic]
public readonly ref struct ReadOnlySpan<T>
{
internal readonly ref T _reference;
private readonly int _length;

public ReadOnlySpan(T[]? array);
public ReadOnlySpan(T[]? array, int start, int length);
public unsafe ReadOnlySpan(void* pointer, int length);
public ReadOnlySpan(ref T reference);
internal ReadOnlySpan(ref T reference, int length);

public ref readonly T this[int index] { get; } //indexing returns a readonly T, this is different from Span<T>
public int Length { get; }
public bool IsEmpty { get; }
public static implicit operator ReadOnlySpan<T>(T[]? array);
public static implicit operator ReadOnlySpan<T>(ArraySegment<T> segment);

// there is no mutating helpers, need to convert to Span<T> firstly to mutate reference data

public ReadOnlySpan<T> Slice(int start);
public ReadOnlySpan<T> Slice(int start, int length);
}

Strings & chars manipulation with Span<T>

  1. reading operation for a string, with ReadOnlySpan<char>

    1
    2
    3
    string s = "hello";
    ReadOnlySpan<char> ro = s.AsSpan(); //"hello"
    ReadOnlySpan<char> sub = s.AsSpan(1,3);//"ell"
  2. writing operation for a string, with Span<char>

  • produce a string with Span
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    //create empty Span<char>
    Span<char> buf = stackalloc char[64];// writable buffer
    int written = 0;

    // the **TryFormat** method parses data into the stack buffer directly without creating a temporary string
    // append an int literal as chars
    if (!12345.TryFormat(buf[written..], out int w))
    return;
    written += w;

    // append a string literal
    " items - ".AsSpan().CopyTo(buf[written..]);
    written += " items - ".Length;

    // append a DateTime in ISO
    DateTime.UtcNow.TryFormat(buf[written..], out w, "yyyy-MM-ddTHH:mm:ssZ");
    written += w;

    // final string (single alloc)
    string result = new(buf[..written]);
  • transform a read-only data into a writable buffer
    1
    2
    3
    4
    5
    //read a Span<char> from string
    string s = "Hello, world!";
    ReadOnlySpan<char> src = s.AsSpan();
    Span<char> dst = stackalloc char[src.Length];
    for (int i = 0; i < src.Length; i++) dst[i] = src[i];
  • fast join of two spans
    1
    2
    3
    4
    5
    6
    7
    8
    9
    ReadOnlySpan<char> a = "foo".AsSpan(), b = "bar".AsSpan();
    Span<char> buf = stackalloc char[a.Length+1+b.Length];
    int w = 0;
    a.CopyTo(buf[w..]);
    w += a.Length;
    buf[w++] = '-';
    b.CopyTo(buf[e..]);
    w += b.Length;
    string joined = new(buf[..w]);
  1. ReadOnlySpan<char> vs ReadOnlySpan<byte>
    ReadOnlySpan<char> is to operate data originates as .NET string, while ReadOnlySpan<byte> is suit for data tranport.
  • parsing utf-8 string in spans received via network or read from file
    1
    2
    3
    4
    5
    6
    7
    ReadOnlySpan<byte> line = bufferSpan;      // e.g., a slice of a file
    int sep = line.IndexOf((byte)',');
    var left = line[..sep];
    var right = line[(sep + 1)..];

    // decode only when need
    string leftText = Encoding.UTF8.GetString(left);
  • formating date/time, numbers data into stack buffers directly withou temporary strings
    1
    2
    3
    4
    5
    Span<char> tmp = stackalloc char[64];
    if (DateTime.UtcNow.TryFormat(tmp, out int written, "yyyy-MM-dd"))
    {
    string s = new(tmp[..written]); // single alloc at the end
    }
  1. string.Create()
    string.Create is designed for zero-allocation performance-critical formatting, it is designed for hot-paths with known output length(fixed-date formats, IDs, hashes etc), to replace StringBuilder when know the size upfront.
    1
    2
    3
    4
    5
    public static string Create<TState>(
    int length,
    TState state,
    SpanAction<char, TState> action
    );
    1
    2
    3
    4
    5
    6
    7
    string message = string.Create(
    20, // 1) Exact length of the string to create
    DateTime.UtcNow, // 2) State object passed into the lambda
    static (dst, dt) => // 3) Static lambda: writes into span
    {
    dt.TryFormat(dst, out _, "yyyy-MM-dd");
    });
    example show power of string.Create(), producing “2025-09-01Z (id=12345)”
  • traditional code
    1
    2
    3
    4
    5
    6
    7
    8
    9
    string s1 = $"{DateTime.UtcNow:yyyy-MM-dd}Z (id={12345})"; //ofter temporary strings are created.
    // or
    var sb = new StringBuilder(); //but can resize, bring overhead.
    sb.Append(DateTime.UtcNow.ToString("yyyy-MM-dd"))
    .Append('Z')
    .Append(" (id=")
    .Append(12345)
    .Append(')');
    string s2 = sb.ToString();
  • zeor-copy finalization with string.Create
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    string s3 = string.Create(
    21,
    (DateTime.UtcNow, 123456),
    static(dst, st) =>
    {
    var (dt, id) = st;
    dt.TryFormat(dst, out int w, "yyyy-MM-dd");
    dst[w++] = 'Z';
    " (id=".AsSpan().CopyTo(dst[w..]);
    w += 5;
    id.TryFormat(dst[w..], out int n);
    w += n;
    dst[w] = ')';
    }
    );
    only use when the target string length is known to take its advantage of high performance.
  1. int.TryParse
1
2
3
4
5
public static bool TryParse(ReadOnlySpan<char> text, out int value) 
=> int.TryParse(text, out value);

public static bool TryParse(string text, out int value)
=> TryParse(text.AsSpan(), out value);

Operation and API

Indexing

1
2
3
4
5
6
7
8
9
public ref T this[int index]
{
get
{
if ((uint)index >= (uint)_length) // using uint casting to reduce the comparation to only once.
ThrowHelper.ThrowIndexOutOfRangeException(); //
return ref Unsafe.Add(ref _reference, (nint)(uint)index); //
}
}
1
2
span[0] = 42;      // modifies in-place
ref int first = ref span[0];

Slicing

1
2
3
4
5
6
7
8
9
public Span<T> Slice(int start, int length)
{
if ((uint)start > (uint)_length || (uint)length > (uint)(_length - start))
ThrowHelper.ThrowArgumentOutOfRangeException();

return new Span<T>(
ref Unsafe.Add(ref _reference, (nint)(uint)start),
length);
}
1
2
3
Span<int> s = new int[] {1,2,3,4,5};
Span<int> mid = s.Slice(1, 3); // points to [2,3,4]
mid[0] = 99; // modifies original array

Bulk Operations

  • Clear() set every element to default(0 for numbers, null for refs).

    1
    2
    Span<int> s = stackalloc int[3]{1,2,3};
    s.Clear(); //[0,0,0]
  • Fill(value) set every element to the same value

    1
    s.Fill(42)
  • CopyTo(destination) copy elements. Throws if destination too short.

  • TryCopyTo(destination) same to CopyTo(destination), but returns false instead of throwing.

    1
    2
    3
    Span<int> src = stackalloc int[3]{1,2,3};
    Span<int> dst = stackalloc int[3];
    src.CopyTo(dst);

Pinning

Normally, the GC can move objects around in memory (to compact the heap), that means their addresses (pointers) aren’t stable. If we pass a pointer to native code (like a C API), the GC might move the object while the native code still uses the pointer -> crash.
Pinning mean telling the GC: Don’t move this object while someone is working with it.

  • GetPinnableReference()
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Span<byte> buf = stackalloc byte[256];
    unsafe
    {
    fixed (byte* p = buf) // fixed is a C# keyword that uses GetPinnableReference() under the hood.
    {
    // p is a stable pointer to buf[0]
    // safe to pass to native API
    }
    // GC can move stuff again after the fixed block
    }

Memory<T>

While Span<T> is writable view of memory, and ReadOnlySpan<T> is readonly view, they are fastest and zero-alloc, but they cannot be stored on the heap or used across async/await.

Memory<T> is writable can live on heap, and ReadOnlyMemory<T> is read-only and can live on the heap.
Memory<T> is slower than Span<T>, but we can store them in fields or keep them alive acorss await.

1
2
3
4
5
6
7
8
class Holder
{
private readonly Memory<byte> _data;
public Holder(Memory<byte> data) => _data = data;
public void PrintFirst() => Console.WriteLine(_data.Span[0]);
}
var h = new Holder(new byte[] { 42 });
h.PrintFirst(); // prints 42

When we need, Memory<T> can give a Span<T>

1
2
Memory\<byte> mem = new byte[10];
Span<byte> span = mem.Span;