DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

800 MB of Arrays Nobody Asked For

I was profiling a request handler last week and the gen0 line on the allocation chart kept ticking up on a path that, as far as I could tell, allocated nothing. No LINQ, no string building, nothing getting new-ed up. Just a little helper I'd written to pick the largest of a few timeout values, something like PickTimeout(configTimeout, retryTimeout, defaultTimeout).

The helper took params int[]. That was the whole problem.

Here's the thing I'd managed to forget: every time you call a params method with loose arguments, the compiler builds an array to carry them. PickTimeout(a, b, c) quietly compiles to PickTimeout(new[] { a, b, c }). One array per call. On a path that runs a handful of times per request, that's nothing you'll ever notice. Mine sat inside a per-row loop, so it ran a lot.

So I did what I always do when a number smells wrong: boiled it down to the dumbest benchmark that still shows the effect and ran it. Pick the max of three ints, twenty million times, count the bytes.

static int MaxOfArray(params int[] values)
{
    int max = int.MinValue;
    foreach (int v in values)
        if (v > max) max = v;
    return max;
}
Enter fullscreen mode Exit fullscreen mode

The result wasn't subtle.

params int[]       :    800,000,040 bytes   gen0=   5     335 ms
Enter fullscreen mode Exit fullscreen mode

800 megabytes. Forty bytes a call (an array header plus three ints, rounded up), twenty million times. Five gen0 collections just to clean up arrays that died a few nanoseconds after they were born. Not one of them outlived the method call. They existed only to carry three integers across a single stack frame.

The fix is the parameter type

C# 13 added params collections, and the one I keep reaching for is params ReadOnlySpan<T>. You change the parameter type and leave every call site exactly as it was:

static int MaxOfSpan(params ReadOnlySpan<int> values)
{
    int max = int.MinValue;
    foreach (int v in values)
        if (v > max) max = v;
    return max;
}
Enter fullscreen mode Exit fullscreen mode

Now MaxOfSpan(a, b, c) doesn't build a heap array. The compiler drops the arguments into an inline buffer on the stack and hands the method a span over them. The caller reads identically. The body reads identically; you still foreach over values. The only thing that moved is where those three ints live for the length of the call.

Same benchmark, same machine, back to back:

params int[]       :    800,000,040 bytes   gen0=   5     335 ms
params ReadOnlySpan :              0 bytes   gen0=   0      76 ms
Enter fullscreen mode Exit fullscreen mode

Zero bytes. Zero gen0. And since it never touches the heap or bothers the GC, it came out roughly 4.5x faster on this run too, though the speed is the side dish here. The allocation line going flat is the main course.

A word on those numbers: this is a container, Release build, best of a few runs, and I care about the ratio, not the raw milliseconds. Your machine will time differently. The allocation figures won't. Forty bytes for an int[3] is forty bytes for an int[3] wherever you run it.

When I'd leave it as an array

I don't want to oversell this, because most params methods are perfectly happy as arrays. If your method is LogError(string message, params object[] args) and it's called a few hundred times over the life of the process, converting it buys you nothing you could measure with a straight face. The array is real, but it's a rounding error against everything else that call does.

The switch earns its keep in one specific situation: a params method on a hot path, called in a tight loop or per item, with small value-type arguments. That's where the per-call array stops being noise and starts showing up in your gen0 count. My timeout helper qualified because it lived in a per-row loop. The logging method three files over did not, and I left it alone.

There are a couple of honest catches. ReadOnlySpan<T> is a ref struct, so you can't stash the parameter in a field, capture it in a lambda, or await across it. If your method needs to do any of that, the span version simply won't compile, which is the compiler stopping you from keeping a stack pointer that's about to go invalid. And if a caller already has a real array in hand, passing it to the span overload is still free, because a span just wraps the array. You don't punish the people who had an actual collection to start with.

My rule of thumb now: if I'm writing a small utility that takes a handful of values and might get called often, I default to params ReadOnlySpan<T> and only fall back to the array when I genuinely need to hold onto the values past the call. It costs nothing where you call it, and it deletes a whole category of allocation I used to not even see.

Here's the whole benchmark if you want to run it yourself. A net10.0 console app (works on net9.0 too), dotnet run -c Release:

using System.Diagnostics;

static int MaxOfArray(params int[] values)
{
    int max = int.MinValue;
    foreach (int v in values)
        if (v > max) max = v;
    return max;
}

static int MaxOfSpan(params ReadOnlySpan<int> values)
{
    int max = int.MinValue;
    foreach (int v in values)
        if (v > max) max = v;
    return max;
}

const int Iterations = 20_000_000;

// Warm up the JIT so the numbers are steady-state.
for (int i = 0; i < 1000; i++)
{
    _ = MaxOfArray(i, i + 1, i + 2);
    _ = MaxOfSpan(i, i + 1, i + 2);
}

long beforeArr = GC.GetAllocatedBytesForCurrentThread();
int gen0Before = GC.CollectionCount(0);
var sw = Stopwatch.StartNew();
long sinkArr = 0;
for (int i = 0; i < Iterations; i++)
    sinkArr += MaxOfArray(i, i + 1, i + 2);
sw.Stop();
long arrBytes = GC.GetAllocatedBytesForCurrentThread() - beforeArr;
int arrGen0 = GC.CollectionCount(0) - gen0Before;
long arrMs = sw.ElapsedMilliseconds;

long beforeSpan = GC.GetAllocatedBytesForCurrentThread();
int gen0SpanBefore = GC.CollectionCount(0);
sw.Restart();
long sinkSpan = 0;
for (int i = 0; i < Iterations; i++)
    sinkSpan += MaxOfSpan(i, i + 1, i + 2);
sw.Stop();
long spanBytes = GC.GetAllocatedBytesForCurrentThread() - beforeSpan;
int spanGen0 = GC.CollectionCount(0) - gen0SpanBefore;
long spanMs = sw.ElapsedMilliseconds;

Console.WriteLine($"Results match      : {sinkArr == sinkSpan}");
Console.WriteLine($"params int[]       : {arrBytes,14:N0} bytes   gen0={arrGen0,4}   {arrMs,5} ms");
Console.WriteLine($"params ReadOnlySpan : {spanBytes,14:N0} bytes   gen0={spanGen0,4}   {spanMs,5} ms");
Enter fullscreen mode Exit fullscreen mode

Found one of these invisible params arrays lurking in a hot loop of yours? I'd like to hear where it turned up.

— still benchmarking things nobody asked me to

Top comments (0)