Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

I Threw Away 786 MB of Scratch Buffers

I had a message processor that unpacked each incoming record into a scratch buffer, read one value out, and moved on. The buffer was pure throwaway: fill it, peek at a byte, forget it. I wrote the obvious thing, new byte

I had a message processor that unpacked each incoming record into a scratch buffer, read one value out, and moved on. The buffer was pure throwaway: fill it, peek at a byte, forget it. I wrote the obvious thing, new byte[4096] at the top of the loop, and shipped it. It worked. It was also quietly handing the garbage collector a short-lived 4 KB array every single iteration, and I didn't notice until I was staring at an allocation profile for something else and saw this loop sitting near the top.

Here's the loop, more or less:

for (int i = 0; i < messages; i++)
{
    byte[] scratch = new byte[bufferSize];   // 4096 bytes, every iteration
    Fill(scratch, i);
    sink += scratch[i % bufferSize];
}

Each scratch lives for a few microseconds and then becomes garbage. Multiply a 4 KB array by 200,000 messages and you've asked the runtime to allocate, and then collect, about three quarters of a gigabyte to produce a handful of bytes you actually cared about.

I measured it with GC.GetAllocatedBytesForCurrentThread() around the loop. Not a timer. Allocation counters are deterministic, so the number doesn't wobble between runs:

new byte[]  :    785.8 MB allocated,   6 gen0 collections

Six gen0 collections for a loop whose entire output is one running total. That's the GC doing cleanup work I invented for no reason.

Renting instead of newing

ArrayPool<T> keeps a stash of reusable arrays. You Rent one when you need it and Return it when you're done, and the same physical arrays cycle through your loop instead of a fresh one each pass.

for (int i = 0; i < messages; i++)
{
    byte[] scratch = ArrayPool<byte>.Shared.Rent(bufferSize);
    try
    {
        Fill(scratch.AsSpan(0, bufferSize), i);
        sink += scratch[i % bufferSize];
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(scratch);
    }
}

Same loop, same result. The allocation line:

ArrayPool   :      0.0 MB allocated,   0 gen0 collections

Zero. Once the pool warms up, the loop stops allocating entirely and the GC has nothing to collect. Both versions produced the identical running total in my run, so I know I didn't change the math. I just stopped making garbage.

The two things that bite people

First, and I've walked into this one myself: Rent(4096) does not promise you a 4096-byte array. It promises an array at least that big, and often it's bigger, because the pool rounds up to its bucket sizes. So scratch.Length is useless to you as a length. You track the size you asked for yourself, which is why I slice with AsSpan(0, bufferSize) instead of trusting scratch.Length. Loop to scratch.Length and you'll read bytes that aren't part of your data.

Second, you have to actually return the buffer, and return it exactly once. That finally isn't decoration. If an exception skips the Return, the pool slowly bleeds buffers and falls back to allocating, which drops you right back where you started with no error to point at it. And returning the same buffer twice, or touching it after you returned it, is the kind of bug that corrupts a different part of your program and surfaces nowhere near the cause. The pool hands your returned array to the next renter. If you're still writing to it, so are they.

When I don't bother

I don't reach for this on small buffers. A 64-byte scratch is better off as a stackalloc Span<byte>: no pool, no return, no risk, and it never touches the heap at all. ArrayPool earns its keep when the buffers are big enough that allocating them hurts and you're doing it a lot, which usually means kilobytes in a hot loop or a per-request path on a busy server.

I'll be honest about the trade too. Pooling buys you fewer allocations in exchange for a try/finally and a rule you have to not break. If this loop runs a hundred times a day, 786 MB spread across all of that is nothing, and I'd leave the plain new byte[] alone. The simple version is easier to read and impossible to misuse. I only take on the complexity when the allocation actually shows up somewhere I care about: a server under load, a tight loop a profiler flagged, a GC pause I can measure. Rent buffers because a profiler pointed at them, not because an article told you allocations are bad.

The whole thing

Here's the complete program I measured, so you can drop it into a net10.0
console project and run it yourself.

using System.Buffers;

const int bufferSize = 4096;
const int messages = 200_000;

static long ProcessWithNew(int bufferSize, int messages)
{
    long sink = 0;
    for (int i = 0; i < messages; i++)
    {
        byte[] scratch = new byte[bufferSize];
        Fill(scratch, i);
        sink += scratch[i % bufferSize];
    }
    return sink;
}

static long ProcessWithPool(int bufferSize, int messages)
{
    long sink = 0;
    for (int i = 0; i < messages; i++)
    {
        byte[] scratch = ArrayPool<byte>.Shared.Rent(bufferSize);
        try
        {
            Fill(scratch.AsSpan(0, bufferSize), i);
            sink += scratch[i % bufferSize];
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(scratch);
        }
    }
    return sink;
}

static void Fill(Span<byte> buffer, int seed)
{
    for (int i = 0; i < buffer.Length; i++)
        buffer[i] = (byte)(seed + i);
}

static (double mb, int gen0, long sink) Measure(Func<long> run)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
    long before = GC.GetAllocatedBytesForCurrentThread();
    int g0 = GC.CollectionCount(0);
    long sink = run();
    long after = GC.GetAllocatedBytesForCurrentThread();
    int g0after = GC.CollectionCount(0);
    return ((after - before) / (1024.0 * 1024.0), g0after - g0, sink);
}

ProcessWithNew(bufferSize, 2000);   // warm up JIT
ProcessWithPool(bufferSize, 2000);

var a = Measure(() => ProcessWithNew(bufferSize, messages));
var b = Measure(() => ProcessWithPool(bufferSize, messages));

Console.WriteLine($"new byte[]  : {a.mb,8:F1} MB allocated, {a.gen0,3} gen0 collections  (sink {a.sink})");
Console.WriteLine($"ArrayPool   : {b.mb,8:F1} MB allocated, {b.gen0,3} gen0 collections  (sink {b.sink})");

Do you pool aggressively or wait for the profiler to make the case? I lean toward waiting, but I'm curious where you land.

— still counting allocations nobody asked me to count

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.