09 · Performance & Memory (Span, structs)¶
Most C# code never needs to think about allocations. This module covers the
tools for when it does: Span<T>, struct vs class trade-offs, and
BenchmarkDotNet for measuring instead of guessing.
Stack vs. heap, briefly¶
struct PointStruct { public int X, Y; }
class PointClass { public int X, Y; }
var structPoints = new PointStruct[1000]; // one contiguous heap block, no per-element headers
var classPoints = new PointClass[1000]; // an array of 1000 references + 1000 separate heap objects
for (int i = 0; i < 1000; i++)
classPoints[i] = new PointClass();
A struct array stores the values inline, contiguously — no separate
allocation per element, better cache locality, no GC pressure from tracking
1000 individual objects. A class array stores references; each element is
a separate heap allocation the GC must track individually. This is the
reason value types exist as a distinct concept from reference types in C#.
When to use struct¶
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency) throw new InvalidOperationException("Currency mismatch.");
return new Money(a.Amount + b.Amount, a.Currency);
}
public override string ToString() => $"{Amount:F2} {Currency}";
}
Good struct candidates: small (a few fields), immutable, value-equality
makes sense (two Money(10, "USD") are "the same" by value), and created in
large numbers or in hot loops. readonly struct additionally tells the
compiler nothing mutates after construction, which unlocks some
optimizations and prevents a common bug — accidentally mutating a copy.
The trap: passing a large struct by value copies the whole thing every time.
struct BigStruct { public long A, B, C, D, E, F, G, H; } // 64 bytes
void Process(BigStruct s) { } // copies all 64 bytes onto the stack
void ProcessByRef(in BigStruct s) {} // passes a reference instead — no copy
in passes a struct by reference as read-only, avoiding the copy for large
structs without allowing the callee to mutate the caller's copy.
Span<T> — a view without a copy¶
int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
Span<int> middle = numbers.AsSpan(2, 5); // view over numbers[2..7], no allocation
middle[0] = 99; // mutates numbers[2] directly
Console.WriteLine(numbers[2]); // 99
Console.WriteLine(string.Join(",", numbers));
AsSpan doesn't copy — middle is a lightweight (ref struct) window onto
the same backing memory. Slicing a Span<T> (middle[1..3]) is also
allocation-free, unlike array[1..3] which allocates a new array.
void PrintSum(ReadOnlySpan<int> values)
{
int sum = 0;
foreach (var v in values) sum += v;
Console.WriteLine(sum);
}
PrintSum(numbers); // implicit conversion from int[]
PrintSum(numbers.AsSpan(0, 3)); // just the first three
PrintSum(stackalloc int[] { 1, 2, 3 }); // stack-allocated, zero heap allocations at all
ReadOnlySpan<int> as a parameter type accepts arrays, slices, and
stackalloc buffers uniformly — this is why string.Split and
int.TryParse overloads increasingly take spans: one method body serves
many call shapes with zero extra allocations.
String parsing without substrings¶
string csvLine = "42,Widget,19.99";
ReadOnlySpan<char> span = csvLine;
int firstComma = span.IndexOf(',');
ReadOnlySpan<char> idPart = span[..firstComma];
int id = int.Parse(idPart); // parses directly from the span, no substring allocated
ReadOnlySpan<char> rest = span[(firstComma + 1)..];
int secondComma = rest.IndexOf(',');
ReadOnlySpan<char> namePart = rest[..secondComma];
string name = namePart.ToString(); // allocate only when you actually need a string
Console.WriteLine($"{id}: {name}");
Traditional csvLine.Split(',') allocates a new string[] plus one new
string per field. Span-based parsing walks the original string's memory
and allocates only where a real string is ultimately required (name
here) — for hot-path parsing (log processing, high-throughput APIs) this
measurably reduces GC pressure.
Measuring instead of guessing: BenchmarkDotNet¶
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class SplitVsSpanBenchmark
{
private const string Line = "42,Widget,19.99";
[Benchmark(Baseline = true)]
public int UsingSplit()
{
var parts = Line.Split(',');
return int.Parse(parts[0]);
}
[Benchmark]
public int UsingSpan()
{
ReadOnlySpan<char> span = Line;
int comma = span.IndexOf(',');
return int.Parse(span[..comma]);
}
}
// BenchmarkRunner.Run<SplitVsSpanBenchmark>();
[MemoryDiagnoser] reports allocations per operation alongside timing —
UsingSplit shows a nonzero Allocated column (the array plus per-field
strings), UsingSpan shows dramatically less. Never trust a performance
claim (including this module's) without a benchmark like this backing it up
on your actual workload.
How It Actually Works¶
- Every
classarray allocation the GC must "track individually" refers to a real, concrete cost: the generational garbage collector walks live object graphs to decide what survives a collection. A Gen 0 collection (Module 5's generational model) works by tracing reachability from "roots" — stack references, statics, CPU registers — through every live object graph; 1000 separatePointClassheap objects means 1000 nodes the tracer visits (plus following any references they hold), whereas aPointStruct[1000]array is a single object as far as the GC is concerned — its fields are inlined bytes with no independent identity to trace. This is the mechanism-level reason "GC pressure" is a real, measurable thing, not a vague concern: more live objects means more nodes a collection cycle has to walk. Span<T>is aref structspecifically so the compiler can guarantee it never outlives the memory it points into. Aref structcan only ever live on the stack — the compiler forbids it as a field of a heap-allocated class, as a generic type argument, or as the state captured by an async state machine (Module 4 of Level 2), because none of those could safely guarantee the underlying buffer (a stack frame, orstackallocmemory) still exists when theSpanis eventually used. This restriction is what letsSpan<T>internally hold a raw, unmanaged-style pointer plus a length with zero runtime safety overhead beyond ordinary array bounds checks — the type system itself, not a runtime check, prevents dangling references.stackalloc int[] { 1, 2, 3 }allocates directly on the current method's stack frame, memory the CLR never asks the GC to manage at all. This is qualitatively different fromnew int[3], which is always a heap allocation tracked by the collector;stackallocmemory is reclaimed the instant the method returns, the same automatic, zero-cost cleanup ordinary local variables get — which is exactly why it can only be wrapped in aSpan<T>/ReadOnlySpan<T>(never returned as a raw pointer from the method, and never boxed into anobject) — nothing else in the type system can safely represent "valid only until this stack frame pops."in BigStruct savoids the copy but the JIT sometimes still defensively copies internally — as covered forreadonly structin Module 5 of Level 1, if the struct isn't markedreadonlyand the callee calls any instance method ons, the JIT can't prove that method won't mutate the referenced data, so it silently copies the struct into a local before the call anyway — meaninginon a non-readonlystruct with method calls inside the callee can, counter-intuitively, still pay the full copy cost it was meant to avoid.- BenchmarkDotNet's
[MemoryDiagnoser]reads allocation counts from the CLR's own GC instrumentation (GC.GetAllocatedBytesForCurrentThread()), not from static code analysis — it runs each benchmark method many times in an isolated process, snapshotting the thread's allocated-byte counter before and after, which is precisely why it can report the exact difference betweenSplit's array-plus-strings allocation and the span-based version's near-zero allocation: it's measuring the real garbage collector's bookkeeping, not estimating from source code.
Exercise¶
Write a method CountWords(ReadOnlySpan<char> text) that counts
whitespace-separated words without calling Split (walk the span,
tracking whether you're inside a word). Benchmark it against a
text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length baseline
using BenchmarkDotNet with [MemoryDiagnoser] on a ~10,000-character
string, and record the allocation difference in a comment above your
results.