DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

616 Bytes to Add Up Sixteen Numbers

I was reading an allocation profile for a request handler that parses a line of comma-separated numbers off an upstream feed. The parse itself is three lines. Nothing in it says new. So I wasn't expecting the fat column of string and string[] allocations sitting right on top of it, climbing every second the feed stayed busy.

They were coming out of one method call: line.Split(',').

Where the bytes come from

Here's the parser, more or less how I'd written it and how I've written it a hundred times before:

static long ParseWithStringSplit(string line)
{
    long sum = 0;
    foreach (string field in line.Split(','))
        sum += int.Parse(field, CultureInfo.InvariantCulture);
    return sum;
}
Enter fullscreen mode Exit fullscreen mode

Clean, correct, reads like a sentence. The problem is Split. For a line with sixteen fields it hands you back a string[] of length sixteen, and every one of those sixteen entries is a freshly allocated string holding one little number. That's an array plus sixteen substrings on the heap, used for a few microseconds, then dropped on the floor for the GC to sweep up. You call it once at startup and nobody cares. You call it a few thousand times a second and it's a steady drip into gen 0, and gen 0 collections aren't free.

The thing is, I didn't actually want those substrings. I never kept them. I parsed each one into an int and threw the string away. I was allocating sixteen strings purely to look at sixteen slices of a string I already had in my hand.

The span version never makes the substrings

Since .NET 8 there's a split that works directly over a ReadOnlySpan<char> and gives you back Range values instead of strings. You slice into the original span yourself, and int.Parse has taken a span since .NET Core 2.1, so nothing ever gets copied into a new string:

static long ParseWithSpanSplit(string line)
{
    long sum = 0;
    ReadOnlySpan<char> span = line;
    foreach (Range r in span.Split(','))
        sum += int.Parse(span[r], CultureInfo.InvariantCulture);
    return sum;
}
Enter fullscreen mode Exit fullscreen mode

The logic is identical. The only difference is Range r instead of string field, and span[r] instead of the field. span[r] isn't a copy, it's a window onto the characters already sitting in line. No array, no substrings, nothing for the collector to chase.

I counted it

I don't trust my own intuition about allocations, so I wrote the smallest thing that would show the gap. Same sixteen-field line, both parsers, five million calls each. I read GC.GetAllocatedBytesForCurrentThread() around each loop and took the best of seven rounds. Here's the output:

Both parsers sum the line to 7074. Good.

string.Split:  3,080,000,040 bytes,   1657.5 ms  (best of 7)
span Split  :             40 bytes,   1105.2 ms  (best of 7)

Allocated per call: string.Split 616 B, span 0 B
Time ratio (best of 7): span is 1.50x the throughput of string.Split
Enter fullscreen mode Exit fullscreen mode

616 bytes a call to add up sixteen numbers. The span version: a flat 40 bytes for the whole five-million-call run, which rounds to zero per call. That 40 is a one-time constant, not something that scales with the work.

I ran it a few times and the allocation numbers didn't move a single byte, which is what I'd expect since they're structural. The timing wandered between about 1.5x and 1.7x the throughput depending on the round. I'm on a shared two-core container, not a quiet benchmark box, so I trust the allocation figure a lot more than the milliseconds. The honest read is: the span path is clearly faster here, and the real, repeatable win is that it stops allocating entirely.

When I don't bother

I'll say the obvious part out loud, because it's the part that matters more than the benchmark. If your parse runs once when the app boots, 616 bytes is nothing. Don't go rewriting readable code to save a few hundred bytes you allocate at startup and never again. string.Split is clearer, and clearer wins when the perf doesn't matter.

There are real limits to the span split too, and they bit me the first time I reached for it. It splits on every occurrence of the separator, full stop. There's no StringSplitOptions.RemoveEmptyEntries, no trimming, no cap on the number of pieces. If your input has empty fields you care about skipping, or you want the "split into at most 3" behaviour, you're either back on string.Split or writing that logic by hand. And because a span is a ref struct, this parser has to stay synchronous. You can't stash the span in a field or carry it across an await, so the moment your parsing wants to be async, this shape stops working.

So my rule is narrow on purpose. When the parse is sitting in a loop that runs often and I can see it in a profile, I switch to the span split and the allocations just disappear. Everywhere else I leave string.Split alone, because the only thing I'd be buying is a worse-looking method.

Here's the whole thing if you want to run it yourself. New console project targeting net10.0, drop this in Program.cs, dotnet run -c Release:

using System.Diagnostics;
using System.Globalization;

const string line = "1024,37,8,512,9,1,4096,16,64,2,128,256,7,3,900,11";

long expected = ParseWithStringSplit(line);
long actual = ParseWithSpanSplit(line);
if (expected != actual) { Console.Error.WriteLine("Parsers disagree"); return 1; }
Console.WriteLine($"Both parsers sum the line to {expected}. Good.\n");

const int iterations = 5_000_000;
const int rounds = 7;

var (splitBytes, splitMs) = Measure("string.Split", iterations, rounds, ParseWithStringSplit, line);
var (spanBytes, spanMs) = Measure("span Split  ", iterations, rounds, ParseWithSpanSplit, line);

Console.WriteLine($"\nAllocated per call: string.Split {splitBytes / (double)iterations:F0} B, span {spanBytes / (double)iterations:F0} B");
Console.WriteLine($"Time ratio (best of {rounds}): span is {splitMs / spanMs:F2}x the throughput of string.Split");
return 0;

static (long bytes, double ms) Measure(string name, int iterations, int rounds, Func<string, long> parse, string line)
{
    long bestBytes = long.MaxValue;
    double bestMs = double.MaxValue;
    for (int r = 0; r < rounds; r++)
    {
        long acc = 0;
        long before = GC.GetAllocatedBytesForCurrentThread();
        var sw = Stopwatch.StartNew();
        for (int i = 0; i < iterations; i++) acc += parse(line);
        sw.Stop();
        long bytes = GC.GetAllocatedBytesForCurrentThread() - before;
        if (acc == long.MinValue) Console.WriteLine(acc); // keep acc observable
        bestBytes = Math.Min(bestBytes, bytes);
        bestMs = Math.Min(bestMs, sw.Elapsed.TotalMilliseconds);
    }
    Console.WriteLine($"{name}: {bestBytes,14:N0} bytes, {bestMs,8:F1} ms  (best of {rounds})");
    return (bestBytes, bestMs);
}

static long ParseWithStringSplit(string line)
{
    long sum = 0;
    foreach (string field in line.Split(','))
        sum += int.Parse(field, CultureInfo.InvariantCulture);
    return sum;
}

static long ParseWithSpanSplit(string line)
{
    long sum = 0;
    ReadOnlySpan<char> span = line;
    foreach (Range r in span.Split(','))
        sum += int.Parse(span[r], CultureInfo.InvariantCulture);
    return sum;
}
Enter fullscreen mode Exit fullscreen mode

Do you reach for the span split by default, or only after a profiler points at it? I'm curious where other people draw that line.

— still counting bytes nobody asked me to count

Top comments (0)