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

48 Bytes a Call I Kept Handing the Garbage Collector

I was staring at a gen0 collection graph that wouldn't calm down. The service wasn't slow, exactly, but the garbage collector was busy in a way that didn't match how little the code seemed to be doing. I went looking for

I was staring at a gen0 collection graph that wouldn't calm down. The service wasn't slow, exactly, but the garbage collector was busy in a way that didn't match how little the code seemed to be doing. I went looking for the usual suspects and found a logging-ish helper that gets called on basically every request. It took params object[]. And that little params was quietly minting a brand new array every single time anyone called it.

Here's the thing I keep forgetting: params T[] isn't free. When you write Join("-", 1, "x", 3), the compiler synthesizes an object[] behind your back, fills it with your three arguments, and hands it to the method. Call that a million times on a hot path and you've handed the GC a million tiny arrays it has to sweep up later.

C# 13 gave us a way out, and I'd never actually reached for it in anger: params can bind to ReadOnlySpan<T> now, not just T[]. The call site looks identical. The allocation story is not.

Measuring it instead of guessing

I didn't want to trust the graph or my memory, so I wrote two formatters that do the exact same work — same StringBuilder, same loop, same output — and differ only in the params type.

static string JoinArray(string sep, params object?[] args)
{
    var sb = new System.Text.StringBuilder();
    for (int i = 0; i < args.Length; i++)
    {
        if (i > 0) sb.Append(sep);
        sb.Append(args[i]);
    }
    return sb.ToString();
}

static string JoinSpan(string sep, params ReadOnlySpan<object?> args)
{
    var sb = new System.Text.StringBuilder();
    for (int i = 0; i < args.Length; i++)
    {
        if (i > 0) sb.Append(sep);
        sb.Append(args[i]);
    }
    return sb.ToString();
}

Then I called each one a million times with three literal arguments and counted allocated bytes with GC.GetAllocatedBytesForCurrentThread(). The StringBuilder and the final string allocate the same in both, so whatever gap shows up is the args container and nothing else.

const int Iterations = 1_000_000;

long Measure(Func<string> call)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
    long before = GC.GetAllocatedBytesForCurrentThread();
    var sw = Stopwatch.StartNew();
    for (int i = 0; i < Iterations; i++)
        _ = call();
    sw.Stop();
    long after = GC.GetAllocatedBytesForCurrentThread();
    Console.WriteLine($"{after - before,14:n0} bytes   {sw.ElapsedMilliseconds,5} ms");
    return after - before;
}

Console.Write("params object?[]          ");
long arrayBytes = Measure(() => JoinArray("-", 1, "x", 3));

Console.Write("params ReadOnlySpan<T>    ");
long spanBytes = Measure(() => JoinSpan("-", 1, "x", 3));

I warmed both paths for 10,000 iterations first so the JIT was done before I started counting. This runs in a container on .NET 10, best of a few runs, and I care about the ratio far more than the absolute milliseconds.

The number

Calling each formatter 1,000,000 times with three literal args.

params object?[]             232,000,040 bytes     171 ms
params ReadOnlySpan<T>       184,000,040 bytes     181 ms

Array path allocated 1.26x what the span path did.
Difference: 48,000,000 bytes over 1,000,000 calls (48.0 bytes/call).

Forty-eight bytes a call. That's exactly one object[] of length three: a 24-byte object header plus three 8-byte references. The span version allocates none of it, because the compiler can stack-allocate the backing buffer and pass a span over it — nothing reaches the heap. Over a million calls that's 48 MB the GC never has to think about.

And now the part I didn't want but have to report: the wall-clock time is a tie. The span path was actually a hair slower in my runs, 181 ms against 171. Gen0 allocation is cheap; bumping a pointer and later sweeping a short-lived array barely registers in raw throughput at this size. So if you came here hoping params ReadOnlySpan<T> makes your calls faster, it doesn't. What it does is stop feeding the collector. On a quiet benchmark that's invisible. On a busy server where gen0 pressure is already your problem, 48 bytes times your request rate is the whole point.

The caveat nobody mentions

Those object? args are value types getting boxed, and span does not save you from that. The 1 and the 3 are boxed on both paths equally — the 48-byte gap is only the array itself. If you want the boxing gone too, type the parameter concretely: params ReadOnlySpan<int> boxes nothing and the delta gets bigger. The span trick removes the container, not whatever you put in it.

The other honest limit: this only helps when the compiler is building the arguments for you at the call site. If you already have your data sitting in an array or a list and you pass it along, there's nothing to save — both overloads take the collection you hand them. The win is specifically for the Foo(a, b, c) shape where the array was being conjured on your behalf.

My opinion, for what it's worth: I'd default new params APIs to ReadOnlySpan<T> now unless I have a concrete reason to keep the array around, like needing to store or defer the arguments past the call. It costs nothing at the call site, it reads the same, and it quietly refuses to allocate. For a hot path that's a free decision. For a cold one it doesn't matter either way, so there's no reason not to pick the one that behaves better under load.

The whole runnable sample is three methods and a loop — paste the formatters and the Measure helper above into a net10.0 console app with LangVersion 14, and you'll get your own version of that table in about ten seconds. (My GitHub push for this one got blocked by the sandbox, so no repo link today — the code above is the complete thing, nothing trimmed.)

Anyone else defaulting their params to spans yet, or am I late to this one? Curious whether you've seen the gen0 difference show up in a real service graph the way I did.

— still counting bytes 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.