Reducing Allocations
Measure allocation rate, then cut it on hot paths with Span<T>, ArrayPool, StringBuilder, object pooling and streaming instead of buffering.
var buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try { var n = Encoding.UTF8.GetBytes(line, buffer); Use(buffer.AsSpan(0, n)); }
finally { ArrayPool<byte>.Shared.Return(buffer); }Allocating a small object in .NET is a pointer bump, so a single allocation is cheap. The cost arrives later: every few megabytes of gen0 allocation triggers a collection, objects that survive are copied into older generations, and a gen2 collection walks the whole heap. A request path that allocates 200 KB, at 2,000 requests per second, produces 400 MB of garbage per second. That shows up as CPU spent in GC, tail-latency spikes and a larger container memory limit than the work needs.
Measure first
Optimise only what a measurement points at. dotnet-counters shows the allocation rate (dotnet.gc.heap.total_allocated, as bytes per second) and the time in GC; dotnet-trace with the gc-verbose profile, or a memory profiler, shows which methods allocate; BenchmarkDotNet's [MemoryDiagnoser] reports bytes per operation for a candidate change. In a quick experiment, GC.GetAllocatedBytesForCurrentThread() before and after a loop is exact enough, as the example shows.
The tools
| Technique | Replaces | Constraint |
|---|---|---|
ReadOnlySpan<char> slicing, span.Split(',') | Substring, string.Split | A ref struct: no fields in classes, cannot live across an await; use Memory<T> there |
ArrayPool<T>.Shared.Rent | new byte[n] per call | May return a bigger array; must be returned exactly once, never used after |
StringBuilder, string.Create, TryFormat | += in loops | += copies the whole string each time: quadratic |
ObjectPool<T> | Expensive objects built per request | Only for objects costly to create; pooling cheap objects is slower |
Streaming (IAsyncEnumerable<T>, writing to the response) | Building the whole payload in memory | Errors after the first byte cannot change the status code |
Large objects
Arrays of 85,000 bytes or more go to the large object heap, which is collected only with gen2 and is not compacted by default. A MemoryStream that buffers a 5 MB export, or a ToArray() over a large result, allocates there on every request; under load that means frequent gen2 collections and fragmentation. Pool big buffers, or stream.
Common hidden allocations
- LINQ in a hot loop: each operator allocates an enumerator, and lambdas that capture locals allocate a closure.
- Boxing: a struct passed as
objector a non-generic interface, or an enum in string interpolation handled the slow way. paramsarrays,ToList()just to callCount, and string keys built with interpolation for every cache lookup.- Logging with interpolated strings: the message is formatted even when the level is disabled. Use
[LoggerMessage]or message templates.
The production failure mode of over-optimising is real too: an ArrayPool buffer returned twice is handed to two requests at once and mixes their data. Keep pooled code small, local and covered by tests.
Example
When to use it
- Parse a 50,000-line order import CSV with span slicing instead of string.Split to cut gen0 collections during the nightly import
- Stream a large invoice export to the response instead of buffering it in a MemoryStream on the large object heap
- Rent a pooled buffer to compute an HMAC over each incoming payment webhook body without a new array per request
More examples
Streaming an invoice export with one reused StringBuilder
The export streams rows straight to the response through a 16 KB writer buffer instead of building a multi-megabyte string on the large object heap. Allocating per row is the cost to remove, so one <code>StringBuilder</code> is cleared and reused for every row. <code>ObjectPool<StringBuilder></code> (via <code>CreateStringBuilderPool</code>, registering the provider with <code>TryAddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider></code> because hosting already adds one) pays off only on short requests at high RPS that each need a builder.
using System.Globalization;
using System.Text;
public sealed record InvoiceLine(string InvoiceNumber, string Sku, int Quantity, decimal UnitPrice);
public sealed class InvoiceCsvWriter
{
public async Task WriteAsync(Stream output, IAsyncEnumerable<InvoiceLine> lines, CancellationToken ct)
{
await using var writer = new StreamWriter(output, new UTF8Encoding(false), bufferSize: 16 * 1024, leaveOpen: true);
// One builder per export, reused for every row. An export is a long request,
// so pooling this single cheap object would cost more than it saves.
var sb = new StringBuilder(256);
await foreach (var line in lines.WithCancellation(ct))
{
sb.Clear()
.Append(line.InvoiceNumber).Append(',')
.Append(line.Sku).Append(',')
.Append(line.Quantity).Append(',')
.Append(line.UnitPrice.ToString("F2", CultureInfo.InvariantCulture));
await writer.WriteLineAsync(sb, ct);
}
}
}
public static class InvoiceExportRegistration
{
public static IServiceCollection AddInvoiceExport(this IServiceCollection services) =>
services.AddSingleton<InvoiceCsvWriter>();
}
Pitfall and fix: ArrayPool misuse
A double return puts one array in the pool twice, so two concurrent requests can rent the same memory and corrupt each other. Reading the whole rented length hashes garbage. The fixed version slices with <code>AsSpan(0, length)</code>, uses <code>Memory<byte></code> because the read is awaited, and clears the buffer since it held payment data.
using System.Buffers;
using System.Security.Cryptography;
public static class PaymentSignature
{
// Bug: hashes buffer.Length bytes (Rent may give 4096 for a request of 3000),
// so stale bytes from a previous renter change the signature at random.
// Bug: returns the array on the success path and again in finally.
public static byte[] SignBroken(Stream body, byte[] key, int length)
{
var buffer = ArrayPool<byte>.Shared.Rent(length);
try
{
body.ReadExactly(buffer, 0, length);
var mac = HMACSHA256.HashData(key, buffer);
ArrayPool<byte>.Shared.Return(buffer);
return mac;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
// Fix: slice to what was read, return exactly once, clear because it held payment data.
public static async Task<byte[]> SignAsync(Stream body, byte[] key, int length, CancellationToken ct)
{
var buffer = ArrayPool<byte>.Shared.Rent(length);
try
{
await body.ReadExactlyAsync(buffer.AsMemory(0, length), ct);
return HMACSHA256.HashData(key, buffer.AsSpan(0, length));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}
}
}
Measuring allocation rate before and after
Record the allocation rate and GC pause time under a realistic load before touching code, then again after the change. The trace (open it in PerfView or Visual Studio) attributes allocations to types and call stacks, which is how you find the one serializer call that accounts for most of the garbage.
# Against a local or load-test instance with the .NET 10 SDK, dnx runs the tools without
# installing them. In a pod there is no SDK: use the single-file tool downloads in a
# debug container that shares the process namespace (kubectl debug --target=...).
dnx dotnet-counters ps
# Allocation rate and GC cost (bytes allocated is a cumulative counter;
# dotnet-counters shows its rate per refresh interval)
dnx dotnet-counters monitor --name Orders.Api \
--counters "System.Runtime[dotnet.gc.heap.total_allocated,dotnet.gc.collections,dotnet.gc.pause.time,dotnet.gc.last_collection.heap.size]"
# Which methods allocate: a 30-second trace with allocation sampling
dnx dotnet-trace collect --name Orders.Api --profile gc-verbose --duration 00:00:00:30 --output orders-api.nettrace
# Then compare one change in isolation
dotnet run -c Release --project benchmarks/Orders.Benchmarks -- --filter '*CsvParsing*' --memory
Discussion