I was reading a gen0 allocation profile for a service that does almost nothing interesting, and sitting near the top was a four-line helper I'd written months ago and never thought about again. It picks a display name: try the nickname, fall back to the full name, fall back to the email, fall back to "Anonymous". It allocates nothing of its own. Or so I assumed. It was handing the garbage collector an array on every single call, and I never passed it one.
Here's the helper, more or less as it was:
static string FirstNonEmpty(params string?[] parts)
{
foreach (var p in parts)
if (!string.IsNullOrWhiteSpace(p))
return p;
return "Anonymous";
}
And the call site, which runs once per request:
string name = FirstNonEmpty(user.Nickname, user.FullName, user.Email, "Anonymous");
That looks like I'm passing four strings. What I'm actually doing is asking the compiler to build a string?[4], drop those four references into it, and hand the array to the method. params T[] has worked that way since C# 1. Every call with inline arguments mints a fresh array, uses it for a few nanoseconds, and throws it away. The strings themselves already exist and get reused; the array is pure packaging.
Measuring the packaging
I don't trust my gut on allocation sizes, so I measured it with GC.GetAllocatedBytesForCurrentThread() around a tight loop rather than a stopwatch. Allocation counts are deterministic, so the number doesn't drift between runs the way milliseconds do. Two million calls, rotating through a few fake users so the branch isn't always the same:
.NET 10.0.15, 2,000,000 calls each
params string?[] 106.8 MB gen0=0 (checksum 18000000)
107 MB of arrays to pick a name two million times. Each array is only about 56 bytes, but 56 bytes times two million is real, and in a service choosing a display name on every request it's a steady drip, not a one-off.
I want to be honest about one thing in that output: gen0=0. The collector hadn't fired during my measured window, because this box has a roomy ephemeral budget and 107 MB didn't fill it. So this isn't a GC-pauses horror story. The garbage is real, it just hadn't been swept yet. On a busy box with a tighter budget, that's exactly the kind of drip that turns into gen0 collections you actually feel.
The one-word fix
C# 13 added params collections, and the one I reach for is params ReadOnlySpan<T>. Change the parameter type and nothing else:
static string FirstNonEmpty(params ReadOnlySpan<string?> parts)
{
foreach (var p in parts)
if (!string.IsNullOrWhiteSpace(p))
return p;
return "Anonymous";
}
The call site doesn't change at all. FirstNonEmpty(user.Nickname, user.FullName, user.Email, "Anonymous") compiles the same way it reads. But now the compiler stack-allocates the arguments instead of newing up an array, because the span doesn't escape the call. Same loop, same inputs, same checksum:
params ReadOnlySpan<string?> 0.0 MB gen0=0 (checksum 18000000)
Zero. The strings are still there, the result is identical, and the packaging is gone. The timing moved too, though I trust it less than the allocation number. Best of five runs in the same cloud container, I care about the ratio and not the absolute milliseconds:
array : 36 ms (best of 5)
span : 8 ms (best of 5)
Most of that gap is the allocation work itself plus the collections the array version triggers across repeated runs. In a real service you'd never see a clean 4x, but you would see the arrays disappear, and that part is deterministic.
When I'd leave it alone
This isn't a reason to go sed your whole codebase. The win only shows up when the arguments are passed inline at the call site, so the compiler can see they don't escape. If your callers already have a List<string> or a string[] in hand and pass that, you were never allocating a throwaway array, and a ReadOnlySpan<T> overload buys you nothing there. It also does nothing for boxing: if the parameter is params ReadOnlySpan<object> and you pass ints, each int still boxes onto the heap, array or not. And on a path that runs a few hundred times a day, none of this matters and I wouldn't touch it.
My honest opinion: for a hot-path helper you control, params ReadOnlySpan<T> is close to free money, because the call sites don't change and the signature reads the same. For a public API, I'd think harder, since a span parameter can't be captured in an async method or a lambda, and that constraint leaks to your callers.
Here's the whole thing I ran, if you want to reproduce it:
using System.Diagnostics;
static string FirstNonEmptyArray(params string?[] parts)
{
foreach (var p in parts)
if (!string.IsNullOrWhiteSpace(p)) return p;
return "Anonymous";
}
static string FirstNonEmptySpan(params ReadOnlySpan<string?> parts)
{
foreach (var p in parts)
if (!string.IsNullOrWhiteSpace(p)) return p;
return "Anonymous";
}
const int N = 2_000_000;
string?[] nick = [null, "", "jpeg_enjoyer", null, " "];
string?[] full = ["Dana Okoro", null, "Lee Park", "", "Mina Roy"];
string?[] mail = ["dana@x.io", "sam@x.io", "lee@x.io", "al@x.io", "mina@x.io"];
static long RunArray(string?[] a, string?[] b, string?[] c, int n)
{
long sum = 0;
for (int i = 0; i < n; i++)
{
int k = i % 5;
sum += FirstNonEmptyArray(a[k], b[k], c[k], "Anonymous").Length;
}
return sum;
}
static long RunSpan(string?[] a, string?[] b, string?[] c, int n)
{
long sum = 0;
for (int i = 0; i < n; i++)
{
int k = i % 5;
sum += FirstNonEmptySpan(a[k], b[k], c[k], "Anonymous").Length;
}
return sum;
}
RunArray(nick, full, mail, 10_000); // warm up
RunSpan(nick, full, mail, 10_000);
long Measure(string label, Func<long> run)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
int g0 = GC.CollectionCount(0);
long before = GC.GetAllocatedBytesForCurrentThread();
long checksum = run();
long bytes = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"{label,-28} {bytes / 1024.0 / 1024.0,8:F1} MB gen0={GC.CollectionCount(0) - g0} (checksum {checksum})");
return bytes;
}
Console.WriteLine($".NET {Environment.Version}, {N:N0} calls each\n");
Measure("params string?[]", () => RunArray(nick, full, mail, N));
Measure("params ReadOnlySpan<string?>", () => RunSpan(nick, full, mail, N));
Have you found one of these hiding in your own hot path, or is there a params call you'd never convert because the callers always hand it an array already? I'd like to hear which side you land on.
— still benchmarking helpers nobody asked me to profile
Top comments (0)