DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

The string.Create I Didn't Need

A profiler started this one. I had a cache lookup on a hot path, and the key got built with string interpolation. During review someone suggested string.Create would allocate less. Reasonable. That's the standard advice when you're hunting garbage. So I went to make the swap, and out of habit I measured both before committing. They allocated exactly the same. Byte for byte. The thing I was "fixing" was already as lean as the fix.

The real lesson turned out to be somewhere I wasn't even looking.

The key, three ways

The key looks like user:42:profile:eu-west. An int in the middle, strings on either side. Here are the three ways I had on the table.

string NaiveConcat(int id, string region) =>
    "user:" + id + ":profile:" + region;

string Interpolated(int id, string region) =>
    $"user:{id}:profile:{region}";

string Created(int id, string region)
{
    int digits = id == 0 ? 1 : (int)Math.Floor(Math.Log10(Math.Abs(id))) + 1;
    if (id < 0) digits++;
    int len = 5 + digits + 9 + region.Length;   // "user:" + digits + ":profile:" + region
    return string.Create(len, (id, region), static (span, state) =>
    {
        var (i, r) = state;
        int pos = 0;
        "user:".CopyTo(span);              pos += 5;
        i.TryFormat(span[pos..], out int w); pos += w;
        ":profile:".CopyTo(span[pos..]);   pos += 9;
        r.CopyTo(span[pos..]);
    });
}
Enter fullscreen mode Exit fullscreen mode

The first is how most of us write it without thinking. The second is how we write it once we've been told interpolation is fine. The third is what you reach for when you've decided allocations are the enemy and you want the floor.

Look how much more there is to that third one. I have to work out the exact length myself, digits and all. Get the math wrong and I either throw an ArgumentException or silently truncate the key, which in a cache is its own special kind of fun. That cost isn't in the benchmark. It's in the next person who has to read it.

I counted it

I didn't want to argue from vibes, so I built the smallest thing that would show the gap. Each method called five million times with an int and a region, allocations read with GC.GetAllocatedBytesForCurrentThread() around the loop, best of seven rounds, a warmup pass and a full GC between rounds.

long Measure(Func<int, string, string> build)
{
    for (int i = 0; i < 10_000; i++) _ = build(i, "eu-west"); // warm up

    long best = long.MaxValue;
    for (int r = 0; r < 7; r++)
    {
        GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
        long before = GC.GetAllocatedBytesForCurrentThread();
        for (int i = 0; i < 5_000_000; i++)
            sink += build(i, "eu-west").Length;   // sink keeps it from being elided
        best = Math.Min(best, GC.GetAllocatedBytesForCurrentThread() - before);
    }
    return best;
}
Enter fullscreen mode Exit fullscreen mode

The allocation counter is deterministic, so I trust it. Here's what came back:

naive  "+"           599,110,440 bytes    119.8 B/op       120 ms
interpolation        399,920,040 bytes     80.0 B/op       150 ms
string.Create        399,920,040 bytes     80.0 B/op       135 ms
Enter fullscreen mode Exit fullscreen mode

What the numbers actually say

Two things jumped out.

First, interpolation and string.Create allocated the identical 80 bytes per call. Not close. Identical. The same 399,920,040 total, both runs. That 80 bytes is the finished string itself, and nobody gets to skip that one; you asked for a string, here's your string. The DefaultInterpolatedStringHandler the compiler builds for $"..." formats the int straight into a buffer and produces the result in a single allocation. string.Create does the same thing, except I'm the one filling the span by hand. The optimization I was about to commit bought me nothing on the metric I cared about.

Second, and this is the one that mattered: naive + allocated fifty percent more. 119.8 bytes against 80. When you write "user:" + id + ..., the int can't slot straight into a string, so it gets boxed and ToString'd into an extra little object that has nothing to do with the string you wanted. That was the drip in my profile. Not the interpolation I'd been told to replace. The plus signs I hadn't thought about at all.

I'll say the quiet part: string.Create is over-prescribed as an allocation fix. Most times I see it recommended, the thing it's replacing is plain interpolation, and for that it's a lateral move with worse readability.

A fair word on the timing, since someone always asks. string.Create did come in a bit quicker in wall clock, around 135ms against 150ms, at identical allocations. But that's a two-core container, best-of-seven, and I trust the byte counter a lot more than the stopwatch in here. If ten percent of throughput on this exact loop is the difference between shipping and not, go measure it on your box. It usually isn't.

When I'd actually reach for it

string.Create earns its keep when you're in a loop you've already profiled, the length is cheap to compute, and you've watched the throughput matter: formatting IDs, masking millions of card numbers in a tight path, that sort of thing. For a cache key you build a few thousand times a second, interpolation and move on with your life. Don't make the next reader parse length arithmetic to save zero bytes.

The change that's always worth making: stop gluing non-strings onto strings with +. Interpolate instead. Same readability, a third less garbage, and you never have to think about boxing again. That one's not a tradeoff, it's just free.

What's an optimization you cargo-culted for years before you ever measured it? I'd like to know I'm not the only one.

Still out here counting bytes nobody asked me to count.

Top comments (0)