Span and ReadOnlySpan — Viewing Memory Without Copying
- Value Types vs Reference Types — Stack, Heap, and the Hidden Cost of Boxing
- Span
and ReadOnlySpan — Viewing Memory Without Copying
- `Span<T>` is a **view** that points at an arbitrary region of memory. It treats array slices, substrings, and stack buffers with the same abstraction, letting you inspect a portion of data without copying it.
- The `ref struct` constraint is not a penalty — it is a contract. The single rule "lives on the stack only" blocks boxing, field storage, and async capture **at the compiler level**.
- `"hello".Substring(1, 3)` allocates a new 12-byte string; `"hello".AsSpan(1, 3)` allocates **0 bytes**. In parsing, logging, and validation code that frequently produces substrings, this drops GC pressure by an order of magnitude.
- On .NET 10 (Apple M4 Pro, Arm64 RyuJIT), replacing a `string.Substring` + `int.Parse` parser with a `Span<char>`-based one yields a **6× speedup** and zero allocations.
- The places `Span<T>` cannot go — fields, async methods, lambda captures — are handled by `Memory<T>` in the next episode. The two types are not rivals; they divide the work.
Introduction: The Copy Cost Left Behind by the Boxing Episode
Episode 1 (Value Types vs Reference Types and Boxing) closed with one debt unpaid.
“Boxing is avoided, but the copy cost of the
structitself remains.”
One core rule from the Boxing episode is worth restating:
Value types have their entire contents copied when assigned, passed, or compared.
Under normal circumstances this rule is intuitive and desirable. Passing a 6-byte (short, int) pair to a function incurs one copy — a cost that can safely be ignored. However, when only a portion of data needs to be examined, this copy rule creates a problem.
1
2
3
string line = "ID=42,SCORE=1280,TIME=00:01:32";
string idPart = line.Substring(3, 2); /* "42" — new string allocation */
int id = int.Parse(idPart); /* parsed once more */
These two lines cause two heap allocations. Substring creates a new string, and once the result is no longer needed, it leaves GC overhead. Simple code that parses a single CSV line generates dozens of bytes of garbage on every call. In a game loop invoked every frame, that cost accumulates.
The root of the problem is that a subset cannot be observed without copying. The type that addresses this directly is the subject of this episode: Span<T> and ReadOnlySpan<T>.
Three goals for this episode:
- Understand
Span<T>through a single definition — “a pointer + length pinned as a ref struct” - See why this type accepts the strong
ref structconstraint, and what problem that constraint solves - Verify with .NET 10 measurements how everyday substring/split/parse patterns can be rewritten with zero allocations
Part 1. What Span<T> Actually Is
1.1 One-Line Definition — “A View of Memory”
Span<T> is summarized in one sentence:
“A pointer + length that points at an arbitrary memory region, pinned into a type so it can be handled safely.”
The internal representation is straightforward.
1
2
3
4
5
6
public readonly ref struct Span<T>
{
internal readonly ref T _reference; /* managed reference to the starting position */
internal readonly int _length; /* length */
/* ... */
}
ref T _reference is a form that could not be expressed directly before C# 11. It is not a plain reference to an object — it is a reference to an arbitrary position inside an object. The middle of an array, the fifth character of a string, the start of a stack buffer — it can point anywhere. Adding _length on top of that capability is enough to represent “a specific memory region.”
This is precisely the shape of a tool for inspecting a subset without copying.
The key differences are summarized in the table below.
| Axis | string.Substring(1, 3) | string.AsSpan(1, 3) |
|---|---|---|
| New object | 1 string (12B + 6B) | None |
| Data copied | 3 chars | 0 |
| GC pressure | Yes | None |
| Pass cost | 8B reference | ref T + int = 16B |
| Lifetime | Determined by GC | Tied to source memory |
The only cost of Span<T> is that its lifetime is bound to the source memory. Accepting that single-line constraint makes the allocation disappear.
1.2 Span<T> vs ReadOnlySpan<T>
The only difference, as the name implies, is whether writes are permitted.
Span<T>— the indexer returnsref T. Elements inside the slice can be modified directly.ReadOnlySpan<T>— the indexer returnsref readonly T. A read-only view.
AsSpan() obtained from a string always returns ReadOnlySpan<char>. Since string is immutable in .NET, a mutable view cannot be provided. Conversely, AsSpan() from a char[] returns Span<char>.
From an API design perspective, the standard pattern is to accept input parameters as ReadOnlySpan<T> and output buffers as Span<T>.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* input is read-only — accepts string, char[], or stackalloc buffers without conversion */
static int CountVowels(ReadOnlySpan<char> input)
{
int count = 0;
foreach (var c in input)
if ("aeiou".Contains(c)) count++;
return count;
}
/* call sites — any memory source passes without conversion cost */
CountVowels("hello world"); /* string as-is */
CountVowels("hello world".AsSpan(0, 5)); /* part of a string */
CountVowels(new char[]{'h','i'}); /* char[] */
Span<char> tmp = stackalloc char[8]; /* stack buffer */
CountVowels(tmp); /* passed without conversion */
There is no need to write separate APIs for string, char[], and stack buffers. A single ReadOnlySpan<char> accepts all memory sources through one unified interface.
1.3 Why ref struct
Span<T> is declared not as a plain struct but as a ref struct. That single word imposes strong constraints on the compiler.
| Prohibition | Reason |
|---|---|
| Storing as a class or struct field | Cannot guarantee safety of ref T if it escapes to the heap |
Boxing (casting to object) | Boxing is a heap allocation — same reason |
Implementing ordinary interfaces such as IDisposable | Interface casting involves boxing |
Using as a local variable in an async method | The async state machine is a heap object — same reason |
| Lambda capture | Captured variables are converted to a closure (class) and go to the heap |
Embedding inside ValueTuple | Ordinary structs also have boxing paths — blocked |
All of these prohibitions share a common thread: paths that leak onto the heap. The memory a Span<T> points to (especially a stackalloc stack buffer) disappears the moment the method returns. A Span that points at vanished memory and survives inside a heap object becomes a dangling reference — the same lifetime bug that causes late-night debugging sessions in C++.
ref struct blocks that possibility at the compiler level. It is statically prevented without any runtime check. This is a further elevation of the “value type safety” emphasized in the Boxing episode.
“The constraints on Span are not a cost — they are a guarantee. Every piece of code the compiler accepts is memory-safe.”
In exchange for that guarantee, Span<T> cannot be stored in fields, used in async, or captured in lambdas. Memory<T>, covered in the next episode, fills those gaps.
Part 2. Three Sources of Span — Arrays, Strings, and stackalloc
The power of Span<T> lies in treating three memory sources with the same abstraction. Regardless of where the data came from, the view inside looks identical.
2.1 Source ① — Arrays
The most common source. AsSpan() on a T[] creates a view over the entire array or a portion of it.
1
2
3
4
5
6
7
8
9
10
int[] scores = { 92, 88, 75, 60, 100 };
Span<int> all = scores.AsSpan(); /* entire array */
Span<int> top3 = scores.AsSpan(0, 3); /* first 3 elements */
Span<int> tail = scores.AsSpan(2); /* from index 2 to end */
/* slices of slices are free — no new object created */
Span<int> middle = top3.Slice(1, 1); /* { 88 } */
middle[0] = 99; /* scores[1] also becomes 99 */
AsSpan() does not copy data. It simply opens a different window into the same array. That is why middle[0] = 99 affects the original array.
ArraySegment<T> did something similar, but Span<T> returns ref T from its indexer, enabling zero-copy transformation beyond simple reads and writes.
2.2 Source ② — Strings and ReadOnlySpan<char>
Strings are where Span<T> does the most work. string.AsSpan() returns ReadOnlySpan<char>.
1
2
3
4
5
6
7
string log = "[2026-04-30 09:00:00] INFO Player joined: id=42";
ReadOnlySpan<char> bracket = log.AsSpan(1, 19); /* "2026-04-30 09:00:00" */
ReadOnlySpan<char> level = log.AsSpan(22, 4); /* "INFO" */
ReadOnlySpan<char> id = log.AsSpan(45, 2); /* "42" */
int playerId = int.Parse(id); /* .NET Core 2.1+: ReadOnlySpan<char> overload exists */
Three Substring calls would produce three new strings plus an equivalent GC burden. Three AsSpan calls produce zero allocations. The two snippets have the same meaning, but their GC cost is in a different category entirely.
The immutability of string provides an additional benefit. Since the source never changes, the memory that ReadOnlySpan<char> points at never changes either — race conditions are not a concern.
2.3 Source ③ — stackalloc
The most attractive source. A temporary buffer can be created without touching the heap at all.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static long Sum(ReadOnlySpan<int> xs)
{
long s = 0;
foreach (var x in xs) s += x;
return s;
}
void DoWork()
{
Span<int> buffer = stackalloc int[64]; /* 256 bytes reserved on the stack */
for (int i = 0; i < 64; i++) buffer[i] = i * i;
long total = Sum(buffer); /* 0 alloc */
}
stackalloc does the same thing as C’s alloca. It claims an on-the-spot buffer inside the method’s stack frame, and that buffer disappears when the method returns. In earlier C#, stackalloc was a dangerous tool available only in unsafe contexts, but since C# 7.2, combining it with Span<T> made it a safe, first-class feature.
Two things are worth remembering.
① Stack size limit — The OS thread stack limit is typically around 1 MB. The main thread in a game client may be larger, but stackalloc above a few KB is risky. The recommendation is 1 KB or less; 256–512 bytes is the safe range.
1
2
3
4
const int StackThreshold = 256;
Span<byte> buffer = size <= StackThreshold
? stackalloc byte[size]
: new byte[size];
② Zero-init cost — Before .NET 6, memory claimed by stackalloc was entirely zeroed. For small buffers this can be ignored, but above a few hundred bytes the cost is measurable.
[SkipLocalsInit] can disable this zero-init in .NET 6+.
1
2
3
4
5
6
7
8
9
10
using System.Runtime.CompilerServices;
[SkipLocalsInit]
static int FastParse(ReadOnlySpan<char> s)
{
Span<char> tmp = stackalloc char[64]; /* zero-init skipped */
/* tmp's initial content is garbage — every location must be written before use */
s.CopyTo(tmp);
/* ... */
}
[SkipLocalsInit] can only be used when there is a guarantee that every location is written before it is read. Otherwise, the contents of the previous stack frame are exposed — a security vulnerability.
2.4 A Single Function Accepts All Three Sources
Having a single function accept all three sources is the essence of the Span<T> design.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* single API that does not care about the source */
static double Average(ReadOnlySpan<double> values)
{
double sum = 0;
foreach (var v in values) sum += v;
return values.Length == 0 ? 0 : sum / values.Length;
}
/* call sites — all three sources treated identically */
double[] heap = { 1.0, 2.0, 3.0 };
Average(heap); /* array */
Span<double> stack = stackalloc double[3] { 1.0, 2.0, 3.0 };
Average(stack); /* stack */
ReadOnlySpan<double> slice = heap.AsSpan(1, 2);
Average(slice); /* slice of array */
IEnumerable<T> previously handled this unification, but at the cost of interface dispatch plus an enumerator object. Span<T> achieves the same unification with zero allocations and direct indexing.
Part 3. The Deep Reason Behind ref struct Constraints
The following compile errors are what everyone encounters when first using Span<T>. Understanding why they exist once eliminates any confusion forever.
3.1 Why It Cannot Be a Class Field
1
2
3
4
class Cache
{
Span<byte> _buffer; /* CS8345: ref struct fields are only allowed in ref structs */
}
What would happen if this were allowed?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void Setup(byte[] data)
{
var cache = new Cache();
cache._buffer = data.AsSpan();
/* looks fine up to here */
}
void Setup2()
{
var cache = new Cache();
Span<byte> tmp = stackalloc byte[256];
cache._buffer = tmp; /* tmp disappears when this method returns */
/* if cache is still alive, _buffer is a dangling reference */
}
stackalloc memory disappears when the method exits. A Span pointing at that memory and surviving inside a class (on the heap) leads directly to use-after-free. C# blocks this possibility at compile time.
Fields of a ref struct can only be stored in another ref struct. Doing so means the container inherits the same constraints, and ultimately every path leads only to the stack.
3.2 Why It Cannot Enter async Methods or Lambdas
1
2
3
4
5
6
async Task BadAsync(byte[] data)
{
Span<byte> view = data.AsSpan(); /* CS4012: ref struct cannot be used in async methods */
await Task.Yield();
Console.WriteLine(view.Length);
}
An async method is transformed by the compiler into a state machine class (or struct). Every local variable that must survive across an await becomes a field of that state machine. Since Span<T> cannot be a class field, it cannot survive past an await.
Lambdas fail for the same reason. Captured variables become fields of a display class generated by the compiler, and that class lives on the heap.
1
2
3
4
5
void BadLambda()
{
Span<int> nums = stackalloc int[4] { 1, 2, 3, 4 };
Func<int> first = () => nums[0]; /* CS8175: cannot capture ref struct in a lambda */
}
Two solutions exist.
(a) Extract into a synchronous helper — process the data before the await.
1
2
3
4
5
6
7
async Task GoodAsync(byte[] data)
{
int sum = SyncSum(data.AsSpan()); /* Span lives only here */
await SaveAsync(sum);
}
static int SyncSum(ReadOnlySpan<byte> view) { /* ... */ }
(b) Use Memory<T> — when crossing an asynchronous boundary is necessary, switch to Memory<T> from the next episode. Memory<T> is a plain struct and can freely appear in async methods, lambdas, and fields.
3.3 Interface Casting and Boxing Are Prohibited
1
2
3
ReadOnlySpan<int> view = ...;
IEnumerable<int> seq = view; /* CS0030: ref struct cannot be converted to an interface */
object o = view; /* CS0029: boxing is prohibited */
This is the same situation seen in the Boxing episode. Interface casting and object casting involve boxing, and boxing is a heap allocation. Every path that would take Span<T> to the heap is blocked.
This is also why LINQ cannot be used with Span<T>. LINQ is based on IEnumerable<T>, and Span<T> cannot implement interfaces. The alternatives are Span-specific methods — Sum, Contains, IndexOf, and other extension methods accumulated in MemoryExtensions — or manual for/foreach loops.
3.4 The Workaround — scoped and Ref Safety Rules
The scoped keyword added in C# 11 allows lifetime rules for ref struct parameters to be expressed more precisely.
1
2
3
4
5
6
7
/* guarantees that parameter view does not escape outside the method */
static int Sum(scoped ReadOnlySpan<int> view)
{
int s = 0;
foreach (var v in view) s += v;
return s; /* only int is returned — the Span itself does not escape */
}
A ref struct parameter marked scoped is strictly prevented from intruding on the caller’s lifetime. When writing a library, this device lets callers pass a Span from a wider variety of sources (including stackalloc) with more freedom.
These rules do not need to be memorized in full. When a compile error occurs, asking “where is this Span leaking to?” is all it takes.
Part 4. Span in Everyday Code
Theory comes first, then practice. Here is where and how to rewrite common daily code patterns.
4.1 Substring → AsSpan().Slice()
The most commonly encountered transformation.
1
2
3
4
5
6
7
8
9
10
11
12
13
/* allocation on every call */
string GetExtension(string path)
{
int dot = path.LastIndexOf('.');
return dot < 0 ? "" : path.Substring(dot);
}
/* zero alloc — when the caller can accept ReadOnlySpan<char> */
ReadOnlySpan<char> GetExtensionSpan(string path)
{
int dot = path.LastIndexOf('.');
return dot < 0 ? ReadOnlySpan<char>.Empty : path.AsSpan(dot);
}
If the caller needs to store the result long-term, returning a Span is not appropriate. In that case, return a plain string (the original string is a GC candidate anyway) or switch to Memory<T>. Span is the right choice only for substrings that are used immediately and discarded.
4.2 int.Parse Evolution — string Argument → ReadOnlySpan<char> Argument
Since .NET Core 2.1, numeric parsing APIs accept ReadOnlySpan<char> overloads.
1
2
3
4
5
6
7
8
9
10
11
string raw = "X=42,Y=88,Z=12";
/* Substring → Parse — three string allocations */
int x = int.Parse(raw.Substring(2, 2));
int y = int.Parse(raw.Substring(7, 2));
int z = int.Parse(raw.Substring(12, 2));
/* AsSpan → Parse(ReadOnlySpan<char>) — 0 alloc */
int x2 = int.Parse(raw.AsSpan(2, 2));
int y2 = int.Parse(raw.AsSpan(7, 2));
int z2 = int.Parse(raw.AsSpan(12, 2));
The same pattern applies consistently to double.Parse, DateTime.Parse, and Guid.TryParse. Every major parsing API in the standard BCL already has Span overloads.
4.3 string.Split → MemoryExtensions.Split (or SpanSplitEnumerator)
string.Split returns string[], so it allocates as many substrings as there are tokens plus the array itself. Splitting a CSV line is one of the most expensive operations.
1
2
3
4
5
6
7
8
9
10
11
/* 8 tokens → 9 objects (1 array + 8 strings) */
string line = "id,name,score,time,region,mode,season,build";
string[] tokens = line.Split(',');
/* .NET 8+ — 0 alloc parser */
ReadOnlySpan<char> view = line.AsSpan();
foreach (Range r in view.Split(','))
{
ReadOnlySpan<char> token = view[r]; /* no new string */
/* process token */
}
MemoryExtensions.Split(ReadOnlySpan<T>, T) added in .NET 8 returns a sequence of Range values. The caller retrieves each token by indexing the original Span. The result is a 0-alloc split.
For .NET 7 and earlier, a short helper that manually iterates with IndexOf is sufficient.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static IEnumerable<Range> Split(ReadOnlySpan<char> s, char sep)
{
int start = 0;
for (int i = 0; i < s.Length; i++)
{
if (s[i] == sep)
{
yield return new Range(start, i);
start = i + 1;
}
}
yield return new Range(start, s.Length);
}
/* WARNING: the code above does not work — ReadOnlySpan<char> as a parameter clashes with yield return */
/* Span cannot be a parameter in an iterator method — see the note below */
Here the ref struct constraint appears again. yield return is a place where the compiler generates a state machine — Span<T> cannot be inside one. In practice, either write a ref struct enumerator directly (e.g., SpanSplitEnumerator) or fill an index array in advance and let the caller iterate.
4.4 Encoding, Hashing, and Serialization
Low-level conversion APIs in the standard library have been almost entirely updated to accept Span.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* UTF-8 encoding */
ReadOnlySpan<char> text = "안녕하세요".AsSpan();
Span<byte> buffer = stackalloc byte[64];
int written = Encoding.UTF8.GetBytes(text, buffer);
/* buffer.Slice(0, written) holds the encoded UTF-8 bytes */
/* SHA256 */
ReadOnlySpan<byte> data = ...;
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(data, hash);
/* JSON Reader */
ReadOnlySpan<byte> json = ...;
Utf8JsonReader reader = new(json);
The stackalloc + Span I/O combination is the standard form of a zero-alloc serialization/hashing pipeline.
4.5 ArrayPool<T> Preview
The practical limit for stackalloc is around 1 KB. When a larger temporary buffer is needed — without triggering GC with new byte[] every time — ArrayPool<T> enters the picture.
1
2
3
4
5
6
7
8
9
10
byte[] rented = ArrayPool<byte>.Shared.Rent(8192);
try
{
Span<byte> view = rented.AsSpan(0, 8192);
/* use view */
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}
An array rented from ArrayPool is used through a Span<T> view and returned to the pool when done. This pattern is the standard buffering approach in ASP.NET Core. It is covered in depth in the next episode (Memory<T> + ArrayPool<T>).
Part 5. Benchmarks — Substring-Based vs Span-Based
From here the data is empirical. .NET 10.0.100 + BenchmarkDotNet 0.14.0, the same environment as the Boxing episode — Apple M4 Pro, macOS 26.1, Arm64 RyuJIT AdvSIMD. The measurement code uses game-domain examples (log parsing, partial extraction, temporary buffer comparison).
5.1 Log Parsing — Substring + int.Parse vs Span-Based
Scenario: extract PlayerId, Score, and Region from 1,000 log lines of the form "[2026-04-30 09:00:00] PlayerId=42,Score=1280,Region=3".
| Method | Mean | Ratio | Allocated |
|---|---|---|---|
| Substring + int.Parse(string) | 142.6 μs | 1.00 | 144,000 B |
| AsSpan + int.Parse(ReadOnlySpan<char>) | 22.3 μs | 0.16 | 0 B |
Code with identical meaning runs 6.4× faster and GC allocation disappears entirely. Parsing 1,000 lines drops from roughly 144 KB of allocation to 0 B — in code called every frame, that eliminates 4 MB of garbage in 30 frames.
5.2 Substring + Immediate Comparison — Equals vs SequenceEqual
Scenario: check whether the extension is ".png" for 10,000 file paths.
| Method | Mean | Ratio | Allocated |
|---|---|---|---|
| Substring + string.Equals | 187.4 μs | 1.00 | 320,000 B |
| EndsWith(string) | 39.6 μs | 0.21 | 0 B |
| AsSpan().EndsWith(span) | 28.8 μs | 0.15 | 0 B |
EndsWith alone avoids creating a substring, but when the call site already holds a ReadOnlySpan, the Span version is additionally faster. The gap looks small, but it accumulates with call frequency.
5.3 Temporary Buffers — new vs ArrayPool vs stackalloc
Scenario: create a 256-byte temporary buffer inside a function, fill it, and sum it. 10,000 iterations.
| Method | Mean | Ratio | Allocated |
|---|---|---|---|
new byte[256] | 6.42 ms | 1.00 | 2,640,000 B |
ArrayPool.Rent(256) | 4.18 ms | 0.65 | 0 B |
stackalloc byte[256] | 1.97 ms | 0.31 | 0 B |
stackalloc + [SkipLocalsInit] | 1.42 ms | 0.22 | 0 B |
stackalloc not only avoids the GC — its memory access pattern is cache-friendly, making it faster. Disabling zero-init with [SkipLocalsInit] adds further acceleration.
Above 256 bytes, however, the risk rises. When 8 KB is needed temporarily, ArrayPool is the answer.
5.4 What These Numbers Say
Common patterns across all three benchmarks:
- One substring = one new string allocation. If that substring is discarded immediately, the allocation is 100% waste. Receiving it as a Span eliminates that waste entirely.
- Span-based code is faster not only because it skips allocation, but because it skips data copying. 1,000 substrings of 256 bytes each is 256 KB of additional memory writes — measurable as cache pressure as well.
stackallocis the answer for small, short-lived buffers. Under 256 B, used entirely within the method — when both conditions hold, it is always the fastest option.
Part 6. Unity / IL2CPP Perspective
Span<T> works in the Unity runtime, but it faces different pressures and limitations than CoreCLR.
6.1 Supported Versions and Backends
Span<T> entered the BCL with .NET Core 2.1 / .NET Standard 2.1. In Unity terms:
- Before Unity 2021.2 — usable by adding the
System.MemoryNuGet package separately. Some Mono backend optimizations are absent. - Unity 2021.2 – 2022.2 — uses the standard BCL directly when the
.NET Standard 2.1compatibility profile is enabled. - Unity 2022.3 LTS and later — enabled by default.
AsSpan,MemoryExtensions, andstackalloc+ Span all work as expected.
Span<T> works correctly in IL2CPP builds as well. IL2CPP preserves the same semantics in C++-translated code by honoring the ref struct safety rules.
6.2 The Relationship Between NativeArray<T> and Span<T>
Unity’s native collection NativeArray<T> manages memory outside the GC. It belongs to a different world from the managed C# memory series, but it has a bridge: AsSpan().
1
2
3
4
5
6
7
8
NativeArray<float> velocities = new(1024, Allocator.TempJob);
/* borrow a view over NativeArray as a Span */
Span<float> view = velocities.AsSpan();
/* standard Span API works as-is */
view.Fill(0f);
view.Slice(0, 256).CopyTo(view.Slice(256));
NativeArray<T>.AsSpan() is available from Unity 2021.2+. No allocation occurs — it simply creates a Span over the unmanaged memory that NativeArray points to.
This means the same function can accept T[], NativeArray<T>, and stackalloc buffers.
1
2
3
4
5
6
7
8
9
10
11
static float Average(ReadOnlySpan<float> values) { /* ... */ }
/* all three called identically */
float[] heap = new float[1024];
Average(heap);
NativeArray<float> native = new(1024, Allocator.Temp);
Average(native.AsSpan());
Span<float> stack = stackalloc float[256];
Average(stack);
6.3 Burst and Span — Compatibility and Limits
The Burst compiler recognizes both NativeArray<T> and Span<T> and treats them as targets for SIMD optimization. A few things to keep in mind:
- Spans of managed arrays cannot be used inside Burst Jobs. Burst does not handle GC objects.
NativeArray<T>.AsSpan()is fine.stackallocworks inside Burst as well — stack memory is unmanaged internally.
The most common form that gets 0-alloc + Burst SIMD acceleration simultaneously in Job code is the combination of NativeArray + AsSpan + stackalloc temporary buffer.
6.4 ref struct Tracking in IL2CPP
IL2CPP translates IL to C++ while preserving ref struct lifetime rules. Code that the C# compiler accepts will also be accepted by IL2CPP — no additional validation is needed.
One thing to be aware of is how the _reference field of Span is represented in IL2CPP. A Span pointing at a managed array is represented in IL2CPP as a GC handle + offset, adding slight overhead on every indexing operation. In general, however, this is faster than the Mono backend.
From a benchmarking perspective, the same measurement must be run in Editor, Mono, and IL2CPP to understand the true cost. As with the Boxing episode — always measure on the target deployment backend.
6.5 Common Usage Patterns in Unity
① String processing every frame
1
2
3
4
5
6
7
8
9
10
11
12
13
/* new string on every frame for TextMeshPro labels */
void Update()
{
label.text = "HP: " + currentHp + " / " + maxHp;
/* string.Concat → new string + potentially two boxed ints */
}
/* Span-based formatting (.NET 6+ string interpolation uses Span internally) */
void Update()
{
label.text = $"HP: {currentHp} / {maxHp}";
/* C# 10+ DefaultInterpolatedStringHandler uses a Span pool */
}
C# 10+ interpolated strings use DefaultInterpolatedStringHandler internally, which uses a Span-based temporary buffer. Boxing disappears and allocations reduce to one — the same as seen in section 4.4 of the Boxing episode.
② Network packet decoding
1
2
3
4
5
/* packet received — 4-byte length header + payload */
ReadOnlySpan<byte> packet = recvBuffer.AsSpan(0, recvLen);
int payloadLen = BinaryPrimitives.ReadInt32LittleEndian(packet[..4]);
ReadOnlySpan<byte> payload = packet.Slice(4, payloadLen);
/* process payload — 0 alloc */
Renting recvBuffer from a pool (ArrayPool<byte>.Shared.Rent(...)) and slicing over it with Span is the standard approach in game networking.
③ Casting a large struct via Span (MemoryMarshal)
1
2
3
4
/* low-level memory reinterpretation — viewing the same memory as a different type */
Span<Vector3> verts = ...;
Span<float> floats = MemoryMarshal.Cast<Vector3, float>(verts);
/* 1024 Vector3s → 3072 floats — same data, only the view changes */
MemoryMarshal is the class that collects reinterpretation APIs for Span. It is very useful when passing data to a shader, or when viewing bytes as another type during serialization.
Summary
Four key takeaways from this episode:
Span<T>is a view that inspects an arbitrary memory region without copying data. Arrays, strings, andstackallocare all treated through the same abstraction, and the BCL APIs that operate on top of it —int.Parse,Encoding.UTF8.GetBytes,MemoryExtensions.Split,MemoryMarshal.Cast— become the building blocks of zero-alloc code.- The
ref structconstraint is a safety guarantee, not a cost. The prohibitions on fields, async, and lambda captures all exist to block every path by which a Span could outlive the memory it points to. Being caught at the compiler level is always better than a runtime bug. - The substring + parse pattern is the most common zero-alloc refactoring candidate in game code. On .NET 10 Arm64, the same-meaning parser ran 6× faster and GC allocation disappeared entirely. Code called every frame is the first place to inspect.
stackallocis the answer for small, short-lived temporary buffers. Under 256 B, finished within the method — when both conditions hold, it outperforms evenArrayPool. Above that threshold belongs to theArrayPoolterritory covered in the next episode.
Series Connection: Preview of the Next Episode
Two problems left open in this episode carry forward.
Span<T>cannot enterasyncmethods, fields, or lambdas: episode 3 onMemory<T>+ArrayPool<T>fills those gaps. Pooled buffers can be carried safely across asynchronous boundaries.- Large temporary buffers cannot be claimed with
stackalloc: the same episode 3 covers theArrayPool<T>.Shared.Rent/Returnpattern. - The copy cost of the
structitself —in,readonly struct,ref struct: episode 4 addresses that.
This is the end of episode 2 of the C# Memory series.
References
Primary Sources — Official Documentation and Standards
- Microsoft Learn —
Span<T>Struct — official reference - Microsoft Learn —
ReadOnlySpan<T>Struct — official reference - Microsoft Learn —
MemoryExtensionsClass — Span-specific extension methods - Microsoft Learn —
MemoryMarshalClass — Span reinterpretation API - Microsoft Learn —
stackalloc(C# reference) — officialstackallocdocumentation - Microsoft Learn —
[SkipLocalsInit]Attribute — disabling zero-init - Microsoft Learn —
scopedmodifier — ref safety rules
Blog Posts and In-Depth Analysis
- .NET Blog — “All About Span: Exploring a New .NET Mainstay” — Stephen Toub, background on Span’s introduction
- .NET Blog — “Performance Improvements in .NET 10” — Stephen Toub, Span-related optimization items
- Adam Sitnik — “Span” — analysis of Span internals and lifetime rules
Measurement Tools
- BenchmarkDotNet official documentation —
[MemoryDiagnoser],[SimpleJob]usage - sharplab.io — real-time C# → IL / JIT code conversion
Game Runtime Perspective
- Unity Manual —
NativeArray<T>— official NativeArray reference - Unity Blog — “On DOTS: C# & the Burst Compiler” — Burst and Span compatibility
- Unity Manual — IL2CPP overview — how IL2CPP works
