#WETHEPEOPLE
#GOTV
#Nevada
Reno Nevada 89557
Reno Students for Solidarity Town Hall with Congressman Ro Khanna & Don Lemon!
Friday, Oct 23
1:30 – 3pm PDT
University of NV, Reno
Wells Fargo Auditorium (first floor of Mathewson
IGT Knowledge Ctr)
1664 N Virginia St
www.mobilize.us/mobilize/eve...
Reno Students for Solidarity T...
Search
2 results for “1664”
-
-
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 ofstringandstring[]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;
}Clean, correct, reads like a sentence. The problem is
Split. For a line with sixteen fields it hands you back astring[]of length sixteen, and every one of those sixteen entries is a freshly allocatedstringholding 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
intand 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 aReadOnlySpan<char>and gives you backRangevalues instead of strings. You slice into the original span yourself, andint.Parsehas 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;
}The logic is identical. The only difference is
Range rinstead ofstring field, andspan[r]instead of the field.span[r]isn't a copy, it's a window onto the characters already sitting inline. 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 readGC.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.Split616 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.Splitis 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 onstring.Splitor writing that logic by hand. And because a span is aref struct, this parser has to stay synchronous. You can't stash the span in a field or carry it across anawait, 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.Splitalone, 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 inProgram.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;
}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#csharp #dotnet #programming #webdev #software #coding #development #engineering #inclusive #community
616 Bytes to Add Up Sixteen Numbers