Steven's Knowledge

Performance & Memory

What makes modern .NET fast -- Span<T> and allocation-free code, the garbage collector, Native AOT, and source generators

Performance & Memory

Modern .NET is genuinely fast. The runtime that ships today is not the CLR of a decade ago: the JIT has grown tiered compilation and aggressive inlining, the BCL has been rewritten to allocate less, and whole categories of overhead have been engineered away. In practice the gap between code that "works" and code that is fast in C# is rarely about clever algorithms -- it is almost always about allocations and the garbage collector. Every object you put on the heap is a future debt the GC has to pay back, and on a hot path that debt compounds. This page is about the tools senior engineers reach for when that hot path matters: stack-friendly memory views, allocation discipline, GC tuning, ahead-of-time compilation, and compile-time code generation. None of them are everyday tools -- but when you need them, nothing else will do.

Span<T> and Memory<T>

Span<T> is the single most important performance primitive added to the language in years. It is a stack-only view over a contiguous block of memory -- it does not care whether that memory lives in a managed array, on the stack, or in unmanaged native memory; it just points at it with a length. The consequence is that you can slice, parse, and walk over data with no allocation and no copying. Taking a sub-slice is free: it is a pointer adjustment and a new length, not a new array. That makes Span<T> the natural tool for parsing and buffer manipulation, where the naive approach allocates a string or array for every intermediate step.

The classic example is pulling a value out of a string without allocating any substrings. string.Substring allocates a new string every time; slicing a ReadOnlySpan<char> allocates nothing at all.

ReadOnlySpan<char> line = "NZD:100.50";
int colon = line.IndexOf(':');
ReadOnlySpan<char> currency = line[..colon];
decimal amount = decimal.Parse(line[(colon + 1)..]);

Neither currency nor the slice passed to decimal.Parse touches the heap -- both are windows onto the original string's characters. Repeat that across a few million lines of a payment file and the difference in Gen 0 pressure is dramatic.

For scratch buffers, stackalloc lets you allocate a small array directly on the stack, which the GC never sees. It is perfect for short-lived, bounded buffers -- formatting a number, building a small key, hashing a few bytes -- but you must keep it genuinely small (a few hundred bytes at most) or you risk a stack overflow.

Span<byte> buffer = stackalloc byte[32];
int written = Encoding.UTF8.GetBytes("NZD", buffer);
ReadOnlySpan<byte> code = buffer[..written];

When the buffer is large or its size is unbounded, reach for ArrayPool<T>.Shared instead of allocating a fresh array. You rent a buffer, use it, and return it -- the pool reuses the backing storage across calls, so a high-throughput loop that would otherwise churn through thousands of large arrays runs against a handful of recycled ones. Just be disciplined about returning what you rent (a try/finally is the idiomatic shape), because a leaked rental defeats the purpose.

The tradeoff that makes all of this safe is that Span<T> is a ref struct. It is forbidden to live on the heap: it cannot be a field of a normal class, cannot be boxed, and -- the rule that bites most often -- cannot survive across an await or yield. The compiler enforces this because a span pointing at stack memory would be a dangling reference the moment the stack frame unwound. When you need span-like semantics in async code, that is exactly what Memory<T> is for: it is the heap-storable equivalent that can be a field and can cross an await, and you call .Span on it to get a Span<T> for the synchronous inner work. The mental rule is simple: Memory<T> to carry the reference around, Span<T> to actually touch the bytes.

Why Allocations Matter

It is worth being precise about why allocations are the thing to watch, because individually they are almost free. Allocating an object in .NET is little more than bumping a pointer -- it is the cleanup that costs. Every object you allocate becomes future work for the garbage collector, and the more Gen 0 garbage you produce, the more frequently the GC has to run a collection to reclaim it. Reducing allocations does not make any single operation faster; it reduces how often the GC pauses your application. On a server handling thousands of requests a second, that pause frequency is often the difference between smooth tail latency and periodic stalls.

The first lever is struct versus class. A struct is a value type: it lives inline -- on the stack or embedded in its container -- and avoids a heap allocation entirely. The catch is that it copies by value, so a large struct passed around repeatedly can cost more than the heap allocation you were trying to avoid. Small, short-lived value types (a coordinate, a small key, a result tuple) are ideal candidates; anything large or long-lived usually belongs on the heap. Marking a struct readonly struct tells the compiler the value never mutates, which lets it skip defensive copies and is simply good hygiene for value semantics.

The second lever is the hidden allocations the language sprinkles in for convenience. A lambda that captures a local variable allocates a closure object; LINQ chains allocate enumerators and delegates at every link. In a method called occasionally, none of this matters and the readability is worth it. In a tight loop on a hot path, a foreach over a List<T> with a plain if can be the difference between zero allocations and one per iteration. The rule is not "never use LINQ" -- it is "know which loops are hot, and keep those ones boring." String building is the same story: concatenating in a loop allocates a new string each time, so reach for StringBuilder, and lean on the interpolated-string handlers that modern .NET uses to format $"..." expressions without intermediate allocations.

The overriding rule, though, is measure first. C# makes it dangerously easy to guess wrong about where the cost is, and premature micro-optimization is as much a trap here as anywhere. Use BenchmarkDotNet to get real allocation and timing numbers before you rewrite anything -- it accounts for JIT warmup, runs enough iterations to be statistically meaningful, and reports bytes allocated per operation. More often than not the hot path is not where you assumed, and the readable code was fine.

The Garbage Collector

The .NET GC is generational, which is the single fact that explains most of its behaviour. Objects are born in Gen 0; the ones that survive a collection are promoted to Gen 1, and survivors of that to Gen 2. The bet is that most objects die young -- a request's temporary objects, a parse's intermediate buffers -- so Gen 0 collections sweep a small, busy region and are frequent and cheap. Gen 2 collections are full collections: they walk the entire managed heap, are comparatively rare, and are expensive. The practical implication is that the objects you most want to avoid creating are the ones that survive long enough to be promoted, because every object that reaches Gen 2 makes the eventual full collection more costly.

There is a fourth region that sits outside the generational story: the Large Object Heap. Any allocation of 85,000 bytes or more goes straight to the LOH, and the LOH is not compacted by default. That means large-array churn -- repeatedly allocating and freeing big buffers -- fragments it, leaving gaps that cannot be reused for differently sized objects, and your process can hold a lot of memory it cannot hand back. This is precisely why ArrayPool<T> matters for big buffers: renting and returning keeps large arrays out of the LOH allocation path entirely.

The GC runs in one of two modes, and choosing the right one matters. Workstation GC uses a single heap tuned for low latency and responsiveness -- the right default for desktop and client apps. Server GC creates a separate managed heap and a dedicated GC thread per logical core, collecting in parallel for much higher throughput. For any server application -- an ASP.NET Core API, a background worker, a high-volume message processor -- Server GC is the correct choice, and it is the default in the ASP.NET Core templates. You can set it explicitly in the project file:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

Beyond the mode, background GC lets Gen 2 collections run concurrently with your application threads so a full collection does not freeze everything at once. And for short, latency-critical sections -- the inside of a low-latency trade path, say -- GCSettings.LatencyMode lets you ask the runtime to avoid disruptive collections temporarily (for example GCLatencyMode.SustainedLowLatency), trading some memory headroom for predictability. This kind of tuning is exactly what matters for high-throughput New Zealand enterprise back-ends, where the cost of a badly timed Gen 2 pause shows up as a tail-latency spike in a dashboard someone is watching.

Native AOT

Native AOT compiles your application ahead of time into a self-contained native binary. There is no JIT at runtime and no separate .NET runtime to install -- the machine code and a trimmed runtime are baked into a single executable. The payoff is concrete: near-instant startup because there is no JIT warmup, a smaller memory footprint, and a binary that runs anywhere without a framework dependency. That profile is ideal for CLI tools that must start fast every invocation, for serverless functions and containers where cold-start latency is a billed cost, and for microservices where density and startup time are operational concerns.

The cost is that everything happens at compile time, so anything depending on runtime code generation is off the table. Reflection that discovers types and members dynamically, runtime emit, and libraries that generate code on the fly may not work -- the AOT compiler trims away code it cannot prove is used, and it cannot reason about reflection it cannot see. The ecosystem's answer is to push that work to compile time with source generators (next section), which is why AOT and source generation have grown up together. You opt in via the project file:

<PropertyGroup>
  <PublishAot>true</PublishAot>
</PropertyGroup>

If full AOT is too aggressive for your dependencies, ReadyToRun (R2R) is the lighter middle ground. R2R precompiles your assemblies to native code ahead of time but keeps the full JIT and runtime available, so startup is faster than a cold JIT while reflection and dynamic codegen still work. You give up the self-contained, no-runtime-dependency benefit and the smallest footprint, but you keep compatibility. The usual progression is: start with R2R if startup is your only concern, and reach for Native AOT only when you also need the minimal footprint and self-contained deployment -- and have verified your dependency tree survives without runtime reflection.

Source Generators

Source generators are Roslyn components that run during the build and emit additional C# source into your compilation. Because the generated code is real C# that the compiler then compiles normally, there is no runtime reflection, no startup discovery cost, and nothing for the AOT trimmer to choke on -- the work that a reflection-based library would do on first use has already been done by the time your program starts.

The flagship example is System.Text.Json source generation. Out of the box, the serializer uses reflection at runtime to discover a type's properties; with a source-generated JsonSerializerContext, those serializers are written out at compile time, which is both faster and fully AOT-compatible. You declare the types you serialize and let the generator do the rest:

[JsonSerializable(typeof(Order))]
internal partial class AppJsonContext : JsonSerializerContext { }

// Uses generated, reflection-free serializer
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);

The same pattern shows up across the platform. The regex source generator turns a [GeneratedRegex("...")] partial method into a fully compiled matcher at build time, skipping the runtime regex engine's interpretation step. The logging source generator behind [LoggerMessage] emits strongly typed, allocation-free logging methods instead of formatting through reflection on every call. More broadly, source generators are how the ecosystem is adapting to AOT: any library that used to lean on runtime reflection is being rewritten to do that work at compile time, and a senior C# engineer should recognise the partial class plus [*Generated*] attribute shape as the modern, AOT-friendly idiom.

Sharp Edges

A few hazards are worth keeping in mind. Do not micro-optimize without profiling -- BenchmarkDotNet exists precisely because intuition about C# allocation costs is so often wrong, and the readable version is usually fine. Remember that Span<T> is a ref struct and cannot cross an await or yield or live in a field; when you need its semantics in async code, carry a Memory<T> and take its .Span for the synchronous part. Server GC buys throughput by spending memory -- multiple heaps means a higher baseline footprint, which is the right trade for a server but wrong for a small client. Native AOT will silently break reflection-based and runtime-codegen libraries, so verify your whole dependency tree before committing to it. And do not assume value types are always cheaper: a large struct copied around hot paths can cost more than the single heap allocation you were trying to avoid, so keep your value types small or pass them by in/ref.

On this page