Performance: allocation, Span and benchmarking
Measure first, then reduce allocations on the hot path with Span and pooling rather than rewriting code on intuition.
Measure before changing anything
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser] // allocations per operation
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class Parsing
{
private string _line = "";
[GlobalSetup]
public void Setup() =>
_line = string.Join(",", Enumerable.Range(0, 64).Select(i => i.ToString()));
[Benchmark(Baseline = true)]
public int Split()
{
var parts = _line.Split(',');
var sum = 0;
foreach (var p in parts) sum += int.Parse(p);
return sum;
}
[Benchmark]
public int Span()
{
var sum = 0;
var span = _line.AsSpan();
while (!span.IsEmpty)
{
var comma = span.IndexOf(',');
var slice = comma < 0 ? span : span[..comma];
sum += int.Parse(slice);
span = comma < 0 ? default : span[(comma + 1)..];
}
return sum;
}
}
// Program.cs
// BenchmarkRunner.Run<Parsing>();| Result column | What it tells you | Why it matters |
|---|---|---|
| Mean | Average time per operation | The headline number |
| StdDev | Spread of measurements | A large value means the result is not trustworthy |
| Ratio | Time relative to the baseline | The comparison that matters |
| Gen0 | Gen 0 collections per 1000 operations | The allocation signal |
| Allocated | Bytes allocated per operation | The direct lever on GC pressure |
| Alloc Ratio | Allocation relative to the baseline | Shows whether a change actually helped |
- Always run benchmarks in Release, with a baseline, and never trust a single run or a stopwatch in a test.
- Optimise the code that the profiler says is hot. A faster string parser in a function called once per request changes nothing.
- An allocation reduction that does not move the end-to-end number is churn. Measure the call site, not just the microbenchmark.
Span, Memory and pooling
using System.Buffers;
using System.Runtime.InteropServices;
// Span<T> is a stack-only view: no allocation, no copy, but it cannot be stored
// on the heap, used in an async method, or captured by a lambda.
public static int SumCsv(ReadOnlySpan<char> line)
{
var sum = 0;
while (true)
{
var comma = line.IndexOf(',');
var token = comma < 0 ? line : line[..comma];
if (!token.IsEmpty) sum += int.Parse(token);
if (comma < 0) return sum;
line = line[(comma + 1)..];
}
}
// Memory<T> when you need to cross an await boundary or store the slice
public static async Task ProcessAsync(Memory<byte> buffer, Stream stream,
CancellationToken ct)
{
var read = await stream.ReadAsync(buffer, ct);
Consume(buffer.Span[..read]);
}
// ArrayPool for large buffers, with a finally that always returns the array
public static string Compress(byte[] input)
{
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(input.Length * 2);
try
{
var written = Deflate(input, buffer);
return Convert.ToBase64String(buffer, 0, written);
}
finally
{
// clearArray: false is faster but leaves data readable by the next renter
pool.Return(buffer, clearArray: false);
}
}
// CollectionsMarshal for in-place access to a List<T> without copying
public static void BumpFirst(List<int> values)
{
ref var first = ref CollectionsMarshal.AsSpan(values)[0];
first++;
}⚠️
Do not return a Span from a method, store it in a field, or use it inside an async method — the compiler rejects most of it, but the cases it allows are lifetime bugs. When you need a view to outlive the current stack frame, use Memory, or copy.
FAQ
Is string pooling worth it?
Rarely. Replacing Split with AsSpan and IndexOf removes an allocation with no shared state and no ceremony, while string interning adds a global lock and a permanent memory cost.
When is Span not the answer?
When the data must be stored, sent across an await, or passed to an API that takes a string. Converting a Span back to a string allocates, which cancels the benefit.
Related
JSON serialisation with System.Text.Json Diagnostics and observability in production
Last refreshed 2026-09-18.