Array performance in .NET 10 as a real-world scenario: allocation and LOH costs, ArrayPool rent/return rules, uninitialised and pinned arrays, cache-friendly layouts, vectorised copy and fill APIs, TensorPrimitives and SIMD, and how to measure.
First I measure: dotnet-counters for allocation rate and LOH size, then an allocation profiler to see which arrays dominate. Short-lived large arrays are the worst case, because anything of 85,000 bytes or more lands on the Large Object Heap, which is only collected in gen 2. The fixes are to reuse buffers with ArrayPool<T>.Shared (always return them, use only the requested length, clear sensitive data), use spans instead of copying slices, and stream data instead of building giant arrays. For speed I keep data in contiguous arrays of structs for cache locality and use the vectorised CopyTo/Fill/TensorPrimitives APIs instead of hand-written loops. Then I prove it with BenchmarkDotNet and compare production counters.
The symptom — lots of GC time, mostly short-lived arrays — tells me the problem is allocation rate, not CPU work. I'd confirm with measurements, then attack it in order of impact: stop allocating (reuse and pool buffers, especially large ones), avoid copies (spans instead of sub-arrays), access memory in cache-friendly order, and finally vectorise the hot loops.
dotnet-counters monitor -p <pid> System.Runtime: watch % Time in GC, GC Heap Allocation Rate, Gen 0/1/2 GC Count, LOH Size. Healthy services are typically under a few % GC time; tens of % is a problem.dotnet-trace collect --profile gc-verbose (or Visual Studio's allocation tool / PerfView / dotMemory) to see which call stacks allocate which types and sizes.ArrayPool<T>byte[] buffer = ArrayPool<byte>.Shared.Rent(minimumLength: 1 << 20);
try
{
int read = await stream.ReadAtLeastAsync(buffer.AsMemory(0, needed), needed, cancellationToken: ct);
Process(buffer.AsSpan(0, read)); // use only the part you asked for
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // clearArray: true if it held sensitive data
}
Rules:
Rent returns an array at least the requested size (often larger) — track the length you actually use.Return in finally, exactly once, and never use the array after returning it.ArrayPool<T>.Create(maxArrayLength, maxArraysPerBucket) or a long-lived buffer is better.MemoryPool<T> / IMemoryOwner<T> gives a disposable ownership model when buffers flow through async pipelines.RecyclableMemoryStream (Microsoft.IO) replaces MemoryStream for pooled streams.Common hidden allocators and replacements:
| Allocating pattern | Replacement |
|---|---|
array[start..end], Skip().Take().ToArray() |
array.AsSpan(start, length) |
items.ToArray() just to iterate |
iterate the source / CollectionsMarshal.AsSpan(list) |
Split(',') returning string[] |
MemoryExtensions.Split into Span<Range> |
new byte[n] for small temporary buffers |
stackalloc byte[n] (small, bounded n) |
BitConverter.GetBytes(x) |
BinaryPrimitives.WriteInt32LittleEndian(span, x) |
Encoding.UTF8.GetBytes(string) |
Encoding.UTF8.GetBytes(string, span) into a pooled buffer |
stream.ToArray() / ReadAllBytes for large data |
stream processing with System.IO.Pipelines |
| LINQ chains in hot loops | plain loops over spans |
Also pre-size collections (new List<T>(count)) to avoid internal array regrowth.
CPUs read memory in 64-byte cache lines; a cache miss costs ~100 ns versus ~1 ns for an L1 hit.
double[] prices, int[] quantities) when loops touch only a few fields of each record.span.CopyTo(dest), Array.Copy, Buffer.BlockCopy and Buffer.MemoryCopy all use vectorised memmove. Avoid element-by-element copy loops, and avoid copying at all when a span view will do.
After allocation is fixed, the CPU work itself can be vectorised:
float total = TensorPrimitives.Sum(values); // SIMD sum
TensorPrimitives.Multiply(prices, quantities, lineTotals); // element-wise
int idx = span.IndexOfAny(SearchValues.Create(",;\n")); // vectorised search
System.Numerics.Tensors.TensorPrimitives (.NET 8+), Vector<T>, and the hardware-specific Vector128/256/512<T> APIs process 4–16 elements per instruction on modern x64 (AVX2/AVX-512) and Arm64 (Azure Cobalt) CPUs.
GCHeapAffinitizeMask, GCHeapCount, or DATAS (Dynamic Adaptation To Application Sizes, default with Server GC since .NET 9) to balance memory and throughput.GCSettings.LargeObjectHeapCompactionMode = CompactOnce for occasional LOH defragmentation.[In my case] a batch reconciliation service read payment files in 1–4 MB chunks and allocated a new byte[] per chunk, plus ToArray() copies for each record slice. dotnet-counters showed ~30% time in GC and frequent Gen2 collections due to LOH allocations. We rented chunk buffers from a dedicated ArrayPool, replaced slices with spans, parsed with BinaryPrimitives and Utf8Parser, and pre-sized result lists. GC time dropped to low single digits, throughput roughly doubled, and p99 latency for the API sharing the instance stabilised. Then TensorPrimitives on the amount arrays shaved more CPU off the totals step.
A practical performance scenario that shows whether the candidate can diagnose and fix allocation-heavy code.
Green flags
ArrayPool correctly, including the larger-array and clearing rules.Red flags
GC.Collect().The .NET GC is generational: new objects go into Gen0, which is collected frequently and cheaply if most objects are dead. Short-lived small arrays should be cheap — but:
await) get promoted to Gen1/Gen2, making later collections more expensive ("mid-life crisis").ArrayPool<T>.Shared worksThe shared pool (TlsOverPerCoreLockedStacksArrayPool<T>):
Rent(n) rounds up to the bucket size; requests larger than the largest bucket allocate a fresh array that isn't pooled on return.Pooled arrays are not cleared on Rent — they contain old data. Never assume zeros, and pass clearArray: true on Return for secrets.
Pooling reintroduces manual memory-management bugs:
Mitigations: try/finally, clear ownership (IMemoryOwner<T> + using), keep rent/return in the same method where possible, and code review.
stackalloc rulesconst int MaxStack = 256;
byte[]? rented = null;
Span<byte> buf = length <= MaxStack
? stackalloc byte[MaxStack]
: (rented = ArrayPool<byte>.Shared.Rent(length));
try { /* use buf[..length] */ }
finally { if (rented is not null) ArrayPool<byte>.Shared.Return(rented); }
Stack space is ~1 MB per thread by default (less on some platforms); large or input-dependent stackalloc risks StackOverflowException, which cannot be caught. Use a constant size and fall back to the pool. [SkipLocalsInit] avoids zeroing stack buffers in hot methods.
GC.AllocateArray<T>(n, pinned: true) (.NET 5+) allocates on the Pinned Object Heap, for long-lived buffers passed to native I/O, avoiding fragmentation from fixed/GCHandle pinning in the normal heap.GC.AllocateUninitializedArray<T>(n) skips zeroing for large buffers you will fully overwrite immediately (only for unmanaged T).| Level | Size | Latency |
|---|---|---|
| L1 | 32–48 KB per core | ~1 ns (4 cycles) |
| L2 | 1–2 MB per core | ~3–4 ns |
| L3 | 32–256 MB shared | ~10–40 ns |
| DRAM | GBs | ~80–120 ns |
Sequential access lets hardware prefetchers load lines before you need them; pointer-chasing through arrays of object references defeats them. That is why struct[] and spans are often 2–10× faster than equivalent class[] traversals on large datasets.
Vector<T>: hardware-sized vectors (16 bytes on SSE, 32 on AVX2, 64 with AVX-512 when enabled).Vector128/256/512<T>: fixed sizes with cross-platform operations; Vector512.IsHardwareAccelerated checks.TensorPrimitives: ready-made vectorised operations (Sum, Dot, Add, Multiply, Max, CosineSimilarity...) over spans — the recommended starting point.IndexOf, SequenceEqual, Fill, CopyTo, Base64, UTF-8 transcoding and more automatically.Vectorisation needs contiguous data (arrays/spans of primitives or blittable structs) — another reason to fix data layout first.
Use BenchmarkDotNet with [MemoryDiagnoser] for micro-level changes (bytes allocated per operation should drop to ~0 on hot paths), then re-run dotnet-counters under realistic load to confirm GC time and allocation rate improved end to end. Watch for regressions in memory retention (pools hold memory) using dotnet-gcdump.
loh-size and gen-2-gc-count in dotnet-counters; switch to ArrayPool and compare.float[] with a loop, Vector<float>, and TensorPrimitives.Sum.class points vs struct points.using System.Buffers;
using System.Numerics.Tensors;
using System.Security.Cryptography;
namespace Arrays;
public readonly record struct Sample(float Value, int SensorId); // struct: contiguous in arrays
public static class ArrayPerformance
{
// Hash a stream with a pooled buffer: no per-call large allocation
public static async Task<byte[]> HashAsync(Stream input, CancellationToken ct)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(128 * 1024); // may be larger than requested
try
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
int read;
while ((read = await input.ReadAsync(buffer.AsMemory(0, 128 * 1024), ct)) > 0)
{
hash.AppendData(buffer, 0, read); // use only the bytes read
}
return hash.GetHashAndReset();
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true); // documents may hold personal data
}
}
// Vectorised maths over contiguous data
public static float Average(ReadOnlySpan<float> values)
=> values.IsEmpty ? 0 : TensorPrimitives.Sum(values) / values.Length;
// Large buffer that will be fully overwritten: skip zeroing
public static byte[] CreateScratch(int size) => GC.AllocateUninitializedArray<byte>(size);
public static void Main()
{
float[] readings = new float[1_000];
readings.AsSpan().Fill(21.5f); // vectorised fill
Console.WriteLine(Average(readings)); // 21.5
Sample[] samples = [new(1.5f, 1), new(2.5f, 2)];
float total = 0;
foreach (ref readonly var s in samples.AsSpan()) total += s.Value; // no copies, no checks
Console.WriteLine(total);
using var ms = new MemoryStream("hello"u8.ToArray());
Console.WriteLine(Convert.ToHexString(HashAsync(ms, default).GetAwaiter().GetResult()));
}
}
What happens: pooling and spans are added everywhere based on intuition.
Why it is wrong: complexity without proven benefit; the real allocator may be elsewhere (LINQ, logging, serialisation).
Do instead: use dotnet-counters and allocation profiles to find the top allocating stacks first.
What happens: Rent(1000) returns 1024 elements and code processes buffer.Length.
Why it is wrong: processes stale data from previous users — corrupt output or data leaks.
Do instead: track the requested length and slice: buffer.AsSpan(0, length).
What happens: a buffer is returned in finally but a Memory<T> or task still references it.
Why it is wrong: another caller can rent and overwrite it — intermittent corruption.
Do instead: keep clear ownership; return only after all consumers are done; prefer IMemoryOwner<T> with using.
What happens: buffers holding personal data, tokens or keys are returned without clearing.
Why it is wrong: the next renter can read them; memory dumps contain them.
Do instead: Return(buffer, clearArray: true) or CryptographicOperations.ZeroMemory.
What happens: new byte[1_000_000] for each file chunk or response.
Why it is wrong: LOH allocations trigger Gen2 GCs and fragmentation.
Do instead: pool, reuse, or stream through smaller buffers.
stackallocWhat happens: stackalloc byte[input.Length].
Why it is wrong: can overflow the stack and crash the process.
Do instead: use a small constant limit and fall back to ArrayPool.
What happens: data[offset..(offset + size)] or Skip/Take/ToArray in loops.
Why it is wrong: each slice copies into a new array.
Do instead: use AsSpan/AsMemory views.
What happens: millions of small class instances in arrays processed in tight loops.
Why it is wrong: pointer chasing causes cache misses; each object adds header overhead and GC tracing.
Do instead: arrays of structs or struct-of-arrays layouts.
What happens: switching GC modes or increasing memory limits to hide allocation problems. Why it is wrong: treats the symptom; costs grow with load. Do instead: reduce allocations first, then tune GC for the remaining workload.
TensorPrimitivesWhat happens: custom intrinsic code with hardware checks and remainder loops.
Why it is wrong: error-prone and often slower than the BCL's tuned implementations.
Do instead: start with TensorPrimitives and span helpers; hand-write SIMD only when profiling justifies it.
Seniors approach array performance as allocation budget plus memory layout, backed by measurements.
| Symptom | Likely cause | Tool |
|---|---|---|
High % Time in GC, gen-2 spikes |
large temporary arrays (LOH) | dotnet-counters, gc-verbose trace |
| Memory grows, OOMKilled | LOH fragmentation, unbounded buffers | dotnet-gcdump, dotnet-dump |
| CPU-bound loops | poor locality, no vectorisation | profiler, BenchmarkDotNet, disassembly |
| Spiky latency | allocation bursts | Application Insights + counters |
IMemoryOwner<T>, ArrayPoolBufferWriter) so misuse is hard.GCSettings.LargeObjectHeapCompactionMode or GCConserveMemory only after reducing LOH churn at the source.DOTNET_GCHeapHardLimit or the default percentage of the container limit) and validate with load tests.Each thread allocates from its own allocation context, a small chunk of gen 0 where new is just a pointer bump plus zeroing; when it runs out, the thread asks the GC for another chunk, and when the gen 0 budget is used up, a gen 0 GC runs. The GC is generational because most objects die young: gen 0 collections are cheap, survivors are promoted to gen 1 and then gen 2, which is collected rarely (and mostly in the background). Objects of 85,000 bytes or more go to the large object heap (LOH), which is collected only with gen 2 and isn't compacted by default, so it can fragment. Pinned objects can't be moved, which blocks compaction; the pinned object heap (.NET 5+) keeps long-lived pinned buffers separate. In hot paths I reduce the allocation rate rather than tuning the GC.
Every array or span access is guarded by a bounds check that compares the index against Length and throws IndexOutOfRangeException. That's what makes .NET memory-safe: no buffer overruns. The JIT removes checks it can prove are redundant, for example for (int i = 0; i < arr.Length; i++) or foreach over arrays and spans. They stay when you loop to some other variable, index with computed values, or read the array from a field that might change. I write loops against the array's or span's own Length, copy fields to locals, slice to spans, and check the generated assembly with DOTNET_JitDisasm or BenchmarkDotNet. Unsafe/MemoryMarshal tricks are a last resort for proven hot paths.
How to tell it (STAR)
**S:** Our document worker [at company] was being OOMKilled at peak and spending a third of its time in GC. **T:** I owned the performance investigation. **A:** I measured with dotnet-counters and a gc-verbose trace, found short-lived large arrays on the LOH, and replaced them with pooled buffers, recyclable streams, spans and streaming hashing, with tests for buffer ownership. **R:** Allocations dropped by about [80%], gen-2 GCs became rare and the OOM kills stopped.
Last updated 30 Oct 2026.