DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

40 Bytes a Call, Just to Pass Four Ints

I was staring at an allocation profile for a hot code path. The helper does almost nothing: it stamps a few counters onto a log line a few thousand times a second. And yet there was a column of int[] allocations in the profile I couldn't place. Nothing in my code newed up an array. I grepped for new int[. Nothing. The arrays were real though, and they were adding up.

Turned out I'd been allocating them for years without typing the word new once. They were hiding behind params.

Where the array comes from

Here's the shape of the thing. You write a method that takes a variable number of values:

static long Sum(params int[] xs)
{
    long acc = 0;
    foreach (int x in xs) acc += x;
    return acc;
}
Enter fullscreen mode Exit fullscreen mode

And you call it the obvious way:

Sum(a, b, c, d);
Enter fullscreen mode Exit fullscreen mode

That call site is a lie, or at least it hides the truth. The compiler can't pass four loose ints into a method that wants an int[], so it quietly builds one: Sum(new int[] { a, b, c, d }). Every call. A fresh four-element array on the heap, used for a few nanoseconds, then thrown to the GC. Nobody wrote new, but it's there in the IL all the same.

For most code that's fine. You call it once, who cares. On a path that runs thousands of times a second, those little arrays become a steady drip into gen 0, and gen 0 collections aren't free.

The fix is one word

Since C# 13 you can declare the params parameter as a span instead:

static long Sum(params ReadOnlySpan<int> xs)
{
    long acc = 0;
    foreach (int x in xs) acc += x;
    return acc;
}
Enter fullscreen mode Exit fullscreen mode

The body is identical. The call site Sum(a, b, c, d) is identical too. What changes is what the compiler emits to carry the arguments: instead of a heap array, it stack-allocates the buffer (an inline array under the hood) and hands the method a span over it. No heap, no array, nothing for the GC to clean up.

The nice part is you don't have to touch any callers. If both overloads existed, C# 13's overload resolution prefers the span one. So the move is: add the span version, delete the array version, recompile. Call sites don't notice.

I counted it

I didn't want to take the compiler's word for it, so I wrote the smallest thing that would show the gap. Two methods, same arithmetic, called five million times each with four int arguments. I read GC.GetAllocatedBytesForCurrentThread() around each loop and took the best of seven rounds.

long before = GC.GetAllocatedBytesForCurrentThread();
var sw = Stopwatch.StartNew();
for (int i = 0; i < iterations; i++)
    acc += body(i);           // calls Sum(i, i+1, i+2, i+3)
sw.Stop();
long after = GC.GetAllocatedBytesForCurrentThread();
Enter fullscreen mode Exit fullscreen mode

Here's what came back:

iterations per round : 5,000,000  (best of 7)
args per call        : 4 ints

params int[]          time     62.0 ms   allocated    200,000,000 bytes
params ReadOnlySpan    time     25.1 ms   allocated              0 bytes

bytes per call (array) : 40.0
bytes per call (span)  : 0.0
time ratio array/span  : 2.47x
Enter fullscreen mode Exit fullscreen mode

Forty bytes a call. That's exactly a four-element int[] on 64-bit: object header plus the length plus sixteen bytes of actual ints. The span version: zero. Not "less." Zero bytes, and it ran about two and a half times faster in the same loop, mostly because it isn't touching the allocator at all.

This isn't a lab. It's a container with workstation GC, and I care about the ratio and the allocation count, not the absolute milliseconds, which move around depending on where it runs. But 40 bytes versus 0 doesn't move. That's a structural difference, not a timing fluke.

The catch, because there's always one

A span can't escape. That's the whole deal with ref struct, and it's exactly the constraint that lets the compiler keep the buffer on the stack. So the moment your method wants to do any of these, the span version won't compile, and that's correct. It's stopping you from keeping a pointer to a stack frame that's about to disappear:

  • store the arguments in a field for later
  • return them, or hand them to something that outlives the call
  • await across the point where you're still using them

If you need the values to stick around, you need a real array, and params T[] is the honest way to get one. Don't fight the compiler here. Reach for the span version on the leaf methods that consume their arguments right now and return, which, if you look, is most of them. Formatters, aggregators, little Combine/Max/Sum helpers, logging shims. That's where the drip was coming from in my case, and that's where it stopped.

My honest opinion: for any new internal helper that takes params of a value type and uses it immediately, the span version should be the default and the array version the thing you reach for when you actually need an array. It's the same code to write, the call sites are untouched, and you stop paying a tax you probably forgot you were paying.

I'd been paying it for years. Go look at your own hot paths for params T[] on methods that just read their arguments and return — I'd bet there's a quiet little column of arrays in there too.

The whole thing

Here's the complete program if you want to run it yourself. New console project on the .NET 10 SDK (dotnet new console), drop this in Program.cs, then dotnet run -c Release:

using System.Diagnostics;

static long SumArray(params int[] xs)
{
    long acc = 0;
    foreach (int x in xs) acc += x;
    return acc;
}

static long SumSpan(params ReadOnlySpan<int> xs)
{
    long acc = 0;
    foreach (int x in xs) acc += x;
    return acc;
}

const int Iterations = 5_000_000;
const int Rounds = 7;
long sink = 0;

static (double ms, long bytes) Measure(int iterations, Func<int, long> body)
{
    for (int i = 0; i < 100_000; i++) body(i);   // warm up the JIT
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    long before = GC.GetAllocatedBytesForCurrentThread();
    var sw = Stopwatch.StartNew();
    long acc = 0;
    for (int i = 0; i < iterations; i++) acc += body(i);
    sw.Stop();
    long after = GC.GetAllocatedBytesForCurrentThread();
    return (sw.Elapsed.TotalMilliseconds, after - before);
}

double bestArray = double.MaxValue, bestSpan = double.MaxValue;
long bytesArray = 0, bytesSpan = 0;

for (int r = 0; r < Rounds; r++)
{
    var a = Measure(Iterations, i => { long v = SumArray(i, i + 1, i + 2, i + 3); sink += v; return v; });
    var s = Measure(Iterations, i => { long v = SumSpan(i, i + 1, i + 2, i + 3); sink += v; return v; });
    if (a.ms < bestArray) { bestArray = a.ms; bytesArray = a.bytes; }
    if (s.ms < bestSpan) { bestSpan = s.ms; bytesSpan = s.bytes; }
}

Console.WriteLine($"params int[]          time {bestArray,8:F1} ms   allocated {bytesArray,14:N0} bytes");
Console.WriteLine($"params ReadOnlySpan    time {bestSpan,8:F1} ms   allocated {bytesSpan,14:N0} bytes");
Console.WriteLine($"bytes per call (array) : {(double)bytesArray / Iterations:F1}");
Console.WriteLine($"time ratio array/span  : {bestArray / bestSpan:F2}x");
Enter fullscreen mode Exit fullscreen mode

What's the sneakiest allocation you've found hiding behind syntax you thought was free? Drop it in the comments.

— still benchmarking things nobody asked me to benchmark

Top comments (0)