DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

I Split Two Million Rows and the GC Noticed

I had a nightly job that reads a partner CSV and sums the amount column, just to sanity-check a total before the real import runs. Boring code. row.Split(','), grab field one, parse it, add it up. It worked the day I wrote it and I forgot about it.

Then the job's memory graph grew a sawtooth I didn't like, and I opened a profiler expecting to find the database client or the file reader. Nope. The garbage collector was working full time on nothing but comma splitting. For a loop that reads one column out of four.

string.Split is generous with memory

Here's what I'd stopped seeing. string.Split(',') doesn't just find the commas. It allocates a string[] to hold the pieces, and then a brand-new string for every field it carved out. Four columns means five allocations per row before I've parsed a single number. And I was throwing away three of those strings immediately, because I only wanted field one.

Multiply that by a couple million rows and the GC has plenty to do.

The newer Span<char>.Split overloads take a different deal. You hand them a Span<Range> to fill, they write the boundaries of each field into it, and nothing gets copied. You slice the original string yourself when you actually need a piece. double.Parse happily takes a ReadOnlySpan<char>, so I never have to materialize the substring at all.

static double SumWithSpanSplit(string[] rows, CultureInfo culture)
{
    double total = 0;
    Span<Range> fields = stackalloc Range[8]; // 4 columns, room to spare
    foreach (string row in rows)
    {
        ReadOnlySpan<char> line = row;
        int count = line.Split(fields, ',');
        total += double.Parse(line[fields[1]], culture);
    }
    return total;
}
Enter fullscreen mode Exit fullscreen mode

The stackalloc Range[8] lives on the stack and gets reused on every iteration, so the per-row cost is zero. Compare that to the version I actually shipped first:

static double SumWithStringSplit(string[] rows, CultureInfo culture)
{
    double total = 0;
    foreach (string row in rows)
    {
        string[] fields = row.Split(',');
        total += double.Parse(fields[1], culture);
    }
    return total;
}
Enter fullscreen mode Exit fullscreen mode

I counted it

I don't trust my gut on allocations, so I built two million CSV-ish rows in memory (id,amount,region,active), then summed the amount column both ways. A stopwatch, a warmup call per path, best time out of seven, and the allocation counter read around each pass. This is a container, not a lab bench, so I only care about the ratio.

Rows: 2,000,000   Runtime: .NET 10.0.14

string.Split       231.8 ms      383,039,160 bytes   sum=9960642900.00
span Split         122.5 ms               40 bytes   sum=9960642900.00

time:  span is 1.9x faster
alloc: span allocated 40 bytes
sums match: True
Enter fullscreen mode Exit fullscreen mode

365 megabytes versus 40 bytes. The 40 bytes is the harness breathing, not the loop. And the loop got about 1.8x faster across repeated runs, which is the part I didn't expect, I went in thinking I'd just save memory. Turns out all that allocating and collecting isn't free on the clock either.

The sums matching is the line I care about most. A faster parser that quietly reads the wrong column is worse than a slow correct one.

When I'd leave string.Split alone

Most of the time, honestly. If I'm reading a 30-line config file once at startup, string.Split is clearer and the allocations vanish into the noise. I'm not going to stack-allocate a range buffer to save microseconds on something that runs once. The span version is also fussier: you cap the number of fields at the size of your Span<Range>, and if a row has more commas than you planned for, the extra columns fold into the last range instead of throwing. For my fixed-shape partner feed that's fine. For arbitrary user input I'd check the returned count before trusting fields[1].

My rule of thumb: reach for span Split when the same parse runs in a hot loop over a lot of rows, and leave the friendly version everywhere else. The readability cost is real, so I want the allocation win to be real too.

The rest of the harness, if you want to run it

I usually link these out to a repo, but the push to my samples repo was blocked on this run, so here's the whole thing in one place. Drop it in a net10.0 console project (<TargetFramework>net10.0</TargetFramework>) and dotnet run -c Release. The two Sum... methods above go right below this.

using System.Diagnostics;
using System.Globalization;

const int RowCount = 2_000_000;
var culture = CultureInfo.InvariantCulture;

// Build the rows once: id,amount,region,active  e.g. "1048576,37.52,emea,1"
string[] rows = new string[RowCount];
for (int i = 0; i < RowCount; i++)
{
    double amount = (i % 9973) + (i % 100) / 100.0;
    string region = (i % 4) switch { 0 => "emea", 1 => "apac", 2 => "amer", _ => "latam" };
    rows[i] = string.Create(culture, $"{i},{amount:0.00},{region},{i & 1}");
}

static (double ms, long bytes, double result) Measure(Func<double> pass)
{
    double result = pass();           // warm up the JIT and the code paths
    double best = double.MaxValue;
    long bytesBest = 0;
    for (int run = 0; run < 7; run++)
    {
        long before = GC.GetTotalAllocatedBytes(precise: true);
        var sw = Stopwatch.StartNew();
        result = pass();
        sw.Stop();
        long after = GC.GetTotalAllocatedBytes(precise: true);
        if (sw.Elapsed.TotalMilliseconds < best)
        {
            best = sw.Elapsed.TotalMilliseconds;
            bytesBest = after - before;
        }
    }
    return (best, bytesBest, result);
}

var a = Measure(() => SumWithStringSplit(rows, culture));
var b = Measure(() => SumWithSpanSplit(rows, culture));
Console.WriteLine($"string.Split  {a.ms,8:0.0} ms  {a.bytes,14:N0} bytes  sum={a.result:0.00}");
Console.WriteLine($"span Split    {b.ms,8:0.0} ms  {b.bytes,14:N0} bytes  sum={b.result:0.00}");
Enter fullscreen mode Exit fullscreen mode

If you've got a parsing loop that shows up on your allocation graph, this is a cheap swap to try. What's the worst allocation hot spot you've found hiding behind a one-line convenience method?

Catch you in the next one — still putting stopwatches on code nobody asked me to time.

Top comments (1)

Collapse
 
alexcodebytes profile image
Oleksandr •

365 MB vs 40 bytes is the kind of ratio that makes you question every Split you've ever written. I've been bitten by the same thing with string.Split in a log parsing loop — the GC pressure was invisible until I actually measured it. Great write-up.