📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

A .NET service that processes large batches spends a lot of time in garbage collection, and memory profiling shows most allocations are short-lived arrays. How do you make array-heavy code fast? Explain ArrayPool, the Large Object Heap, cache locality, copying APIs, spans and SIMD.

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.

Senior RealWorldScenario Advanced arraypool arrays csharp large-object-heap performance simd

🎯 Key points

  • Measure first: dotnet-counters (allocation rate, GC counts, LOH size), dotnet-trace or an allocation profiler, BenchmarkDotNet MemoryDiagnoser
  • Arrays of 85,000 bytes or more go on the Large Object Heap, collected only with gen 2 and not compacted by default: churn there causes long GCs and fragmentation
  • ArrayPool<T>.Shared.Rent returns an array at least the requested size; always Return it, use only the requested length, and never use it after returning
  • Clear rented arrays that held sensitive data (clearArray: true); pools are not thread-affine but each rented array must have one owner
  • GC.AllocateUninitializedArray skips zeroing for large buffers you will overwrite; GC.AllocateArray(pinned: true) uses the pinned object heap for interop buffers
  • Contiguous arrays of structs give cache locality; arrays of class references cause pointer chasing
  • Use Array.Copy, span CopyTo, Fill and Clear (vectorised memmove) rather than manual loops; TensorPrimitives and Vector<T> for numeric SIMD
  • Avoid hidden allocations: ToArray/ToList in loops, LINQ in hot paths, range indexing that copies, params arrays

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Confirm the diagnosis

  • 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.
  • Look especially for large arrays (≥ 85,000 bytes): they go to the Large Object Heap, which is only collected during Gen2 collections — so allocating big temporary buffers per batch triggers expensive full GCs.
  • Check Application Insights / Prometheus for latency spikes that line up with Gen2 GCs.

2. Reuse buffers with 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.
  • Always Return in finally, exactly once, and never use the array after returning it.
  • The shared pool keeps arrays up to large sizes per thread/core; for very large or fixed-size buffers, a dedicated 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.

3. Stop creating intermediate arrays

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.

4. Cache locality

CPUs read memory in 64-byte cache lines; a cache miss costs ~100 ns versus ~1 ns for an L1 hit.

  • Prefer arrays of structs (contiguous data) over arrays of references to scattered objects for hot numeric data.
  • Iterate sequentially and in row-major order for grids.
  • Consider struct-of-arrays (separate double[] prices, int[] quantities) when loops touch only a few fields of each record.

5. Copying APIs

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.

6. SIMD

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.

7. GC configuration (after code fixes)

  • Server GC (default for ASP.NET Core) for throughput on multi-core machines; check container CPU limits.
  • 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.
  • Configuration helps at the margins; reducing allocations is the real fix.

8. A production example (how I'd tell it)

[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.

🔍 What the interviewer is checking

A practical performance scenario that shows whether the candidate can diagnose and fix allocation-heavy code.

Green flags

  • Starts with measurement and names concrete tools.
  • Explains the LOH threshold and why it hurts.
  • Uses ArrayPool correctly, including the larger-array and clearing rules.
  • Talks about cache locality and vectorised APIs.

Red flags

  • Suggests calling GC.Collect().
  • Pools everything without ownership discipline.
  • Cannot say why arrays of structs differ from arrays of objects.

📚 Learn in depth

1. Why short-lived arrays hurt

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:

  • A high allocation rate (hundreds of MB/s) triggers Gen0 GCs many times per second; each pauses threads (briefly) and touches caches.
  • Objects alive at a Gen0 GC (e.g. buffers in flight across an await) get promoted to Gen1/Gen2, making later collections more expensive ("mid-life crisis").
  • Large objects (≥ 85,000 bytes) skip Gen0 and go straight to the LOH, which is logically part of Gen2. Allocating many large temporary arrays forces Gen2 collections, which scan the whole heap, and the LOH isn't compacted by default, so it fragments.
  • Every new array must be zeroed by the runtime, which costs memory bandwidth for large buffers.

2. How ArrayPool<T>.Shared works

The shared pool (TlsOverPerCoreLockedStacksArrayPool<T>):

  • Buckets arrays by power-of-two sizes (16, 32, 64, ... up to ~1 GB in .NET 8+; earlier versions capped at 1 MB for pooling).
  • Keeps a small thread-local cache per bucket for the fastest path, then per-core stacks with light locking.
  • Trims pooled arrays under memory pressure (registered with the GC's memory pressure callbacks).
  • 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.

3. Ownership bugs with pooling

Pooling reintroduces manual memory-management bugs:

  • Use after return: another caller rents the same array and you overwrite each other's data.
  • Double return: the array ends up in the pool twice and is handed to two callers.
  • Leak: forgetting to return just loses the pooling benefit (the GC still collects it) — the least harmful bug.

Mitigations: try/finally, clear ownership (IMemoryOwner<T> + using), keep rent/return in the same method where possible, and code review.

4. stackalloc rules

const 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.

5. The Pinned Object Heap and uninitialised arrays

  • 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).

6. Cache hierarchy numbers (typical server CPU)

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.

7. SIMD in .NET

  • 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.
  • The BCL vectorises 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.

8. Measuring improvement

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.

Try it yourself

  1. Write a loop that allocates a 100 KB array per iteration and watch loh-size and gen-2-gc-count in dotnet-counters; switch to ArrayPool and compare.
  2. Benchmark summing a float[] with a loop, Vector<float>, and TensorPrimitives.Sum.
  3. Benchmark iterating 1 million class points vs struct points.
  4. Deliberately use a pooled array after returning it in a multi-threaded test and observe corruption — then fix the ownership.

💻 Code

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()));
    }
}

⚠ Common mistakes

1. Optimising before measuring

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.

2. Using a rented array's full length

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).

3. Using pooled arrays after returning them

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.

4. Not clearing sensitive pooled buffers

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.

5. Allocating large temporary arrays per request

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.

6. Large or unbounded stackalloc

What 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.

7. Slicing arrays with ranges or LINQ

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.

8. Arrays of objects for hot numeric data

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.

9. Tuning GC settings instead of fixing allocations

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.

10. Writing manual SIMD before using TensorPrimitives

What 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.

🏛 Senior insight: trade-offs

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
  • Pooling adds ownership rules; wrap rent/return in small types (IMemoryOwner<T>, ArrayPoolBufferWriter) so misuse is hard.
  • Consider GCSettings.LargeObjectHeapCompactionMode or GCConserveMemory only after reducing LOH churn at the source.
  • In containers, also tune GC settings deliberately (Server GC, DOTNET_GCHeapHardLimit or the default percentage of the container limit) and validate with load tests.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions the 85,000-byte LOH threshold Why: Checks managed heap and GC generation knowledge
    How to answer

    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.

  2. When: Candidate mentions SIMD or tight loops Why: Checks bounds-check elimination awareness
    How to answer

    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.

🛠 Real-world problems: how you handle them

Document worker OOMKilled by Large Object Heap churn
Situation
A document-processing worker on AKS processed about 200,000 PDFs and images a day, allocating several large byte[] buffers per document for reading, compression and hashing.
Signals
dotnet-counters showed 25-40% time in GC and frequent gen-2 collections; LOH size grew steadily; pods were OOMKilled during peak batches.
Investigation
Captured a gc-verbose trace and analysed it in PerfView, finding byte[] allocations above 85,000 bytes from new byte[], MemoryStream.ToArray and range slicing as the top sources; took dotnet-gcdump snapshots to confirm LOH fragmentation.
Resolution
Introduced ArrayPool buffers with try/finally returns and clearing, switched to RecyclableMemoryStream, replaced range copies with spans, and hashed incrementally while streaming.
Prevention
BenchmarkDotNet allocation budgets for the pipeline steps in CI, dashboards and alerts on GC time and LOH size, and a coding guideline for buffer ownership.

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.

↩ Usually asked after

More in Arrays

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me