I Only Wanted Six Sums. GroupBy Gave Me 100 MB
A reporting endpoint I wrote months ago started turning up in our gen0 collection graphs, and for a while I couldn't work out why. All it does is total an amount column per region and hand back six numbers. Six. Nothing
A reporting endpoint I wrote months ago started turning up in our gen0 collection graphs, and for a while I couldn't work out why. All it does is total an amount column per region and hand back six numbers. Six. Nothing that small should be making the garbage collector sweat, but the allocation counter said otherwise, and that counter has never once flattered me.
The offending line was the one I'd have waved through in review without a second look:
var totals = rows
.GroupBy(r => r.Region)
.ToDictionary(g => g.Key, g => g.Sum(r => (long)r.Amount));
It reads clean and does exactly what it says. And to produce those six sums, it first builds a grouping that holds every single row, bucketed by key, so it can walk each bucket and add things up. I only ever look at the totals. I'm paying to materialize two million rows into groups, then throwing the groups away one line later.
Measuring it instead of guessing
I didn't want to argue from a hunch, so I wrote the smallest honest version. Two million rows of (region, amount) across six regions, built once up front so the data itself doesn't land on the scorecard. Then the same total computed a few ways, each wrapped in a best-of-five loop with GC.GetAllocatedBytesForCurrentThread() and a checksum so a wrong answer couldn't slip through.
The GroupBy version printed this:
GroupBy().ToDictionary(Sum) 100,667,344 bytes 97.4 ms
A hundred megabytes, to keep six longs.
What AggregateBy actually does
AggregateBy shipped in .NET 9 and I'd walked straight past it in the release notes without registering what it was for. It's Aggregate, but keyed: you give it a key selector, a seed, and an accumulator, and it keeps one running value per key as it goes. No buckets of rows, no intermediate groupings. One pass, one small dictionary of accumulators that it hands back as key/value pairs.
var totals = rows
.AggregateBy(r => r.Region, seed: 0L, (acc, r) => acc + r.Amount)
.ToDictionary();
Same six numbers. Here's what it cost, next to the old line, best-of-five in a cloud container with workstation GC. This isn't a lab, so I care about the ratio, not the absolute milliseconds:
GroupBy().ToDictionary(Sum) 100,667,344 bytes 97.4 ms
AggregateBy(seed, acc) 1,168 bytes 30.1 ms
From a hundred megabytes down to a bit over a kilobyte, and about three times quicker on the clock. The allocation figure came back byte-for-byte identical on every run, which is exactly what you'd expect: GroupBy has to hold all the rows, AggregateBy holds six longs.
The part that keeps me honest
Before anyone files AggregateBy under free speed, I added a third contestant: the dumb dictionary loop I'd have written years ago without thinking.
var totals = new Dictionary<string, long>();
foreach (var r in rows)
{
totals.TryGetValue(r.Region, out long acc);
totals[r.Region] = acc + r.Amount;
}
hand-rolled Dictionary loop 504 bytes 33.2 ms
It allocated less than AggregateBy and ran at basically the same speed. That's the honest result, and I'd rather you have it than a flattering one. AggregateBy isn't beating a hand-written loop, because it pretty much is a hand-written loop with a nicer face. What it beats is GroupBy pressed into service as a counting machine. So the lesson here isn't "LINQ got faster." It's "I was using the wrong LINQ method, and the right one happens to read just as well as the wrong one."
My own take: when what you want out of a collection is one value per key, a sum, a max, a running count, AggregateBy (or CountBy, its count-only sibling) should be the first reach, and GroupBy should wait for when you actually need the grouped items. If you need the rows inside each bucket, GroupBy is correct and AggregateBy can't help you, because by then it's already discarded them.
And the usual note about scale: at a few hundred rows none of this shows up. The 100 MB only appears because there are two million. Don't go rewriting a loop that runs over a config list of twelve entries. But a reporting path, an import job, anything that sees real volume, that's where reaching for GroupBy out of habit taxes you.
The whole sample
I couldn't push this one to the usual repo today, so here's the complete file. Drop it in a console project targeting net10.0 (it runs fine on net9.0 too, since that's where AggregateBy landed) and dotnet run -c Release:
using System.Diagnostics;
const int RowCount = 2_000_000;
string[] regions = ["NA", "EU", "APAC", "LATAM", "MEA", "ANZ"];
var rows = new SaleLine[RowCount];
var rng = new Random(1234);
for (int i = 0; i < RowCount; i++)
rows[i] = new SaleLine(regions[rng.Next(regions.Length)], rng.Next(1, 500));
Console.WriteLine($"Totalling {RowCount:N0} rows into {regions.Length} regions, best-of-5:\n");
Measure("GroupBy().ToDictionary(Sum)", () =>
{
var totals = rows
.GroupBy(r => r.Region)
.ToDictionary(g => g.Key, g => g.Sum(r => (long)r.Amount));
return Checksum(totals);
});
Measure("AggregateBy(seed, acc)", () =>
{
var totals = rows
.AggregateBy(r => r.Region, seed: 0L, (acc, r) => acc + r.Amount)
.ToDictionary();
return Checksum(totals);
});
Measure("hand-rolled Dictionary loop", () =>
{
var totals = new Dictionary<string, long>();
foreach (var r in rows)
{
totals.TryGetValue(r.Region, out long acc);
totals[r.Region] = acc + r.Amount;
}
return Checksum(totals);
});
static void Measure(string label, Func<long> run)
{
long check = run();
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
long bestBytes = long.MaxValue;
double bestMs = double.MaxValue;
for (int i = 0; i < 5; i++)
{
long before = GC.GetAllocatedBytesForCurrentThread();
var sw = Stopwatch.StartNew();
long result = run();
sw.Stop();
long after = GC.GetAllocatedBytesForCurrentThread();
if (result != check)
throw new Exception($"{label} disagreed: {result} vs {check}");
bestBytes = Math.Min(bestBytes, after - before);
bestMs = Math.Min(bestMs, sw.Elapsed.TotalMilliseconds);
}
Console.WriteLine($"{label,-32} {bestBytes,14:N0} bytes {bestMs,7:F1} ms (checksum {check})");
}
static long Checksum(Dictionary<string, long> totals)
{
long sum = 0;
foreach (var kv in totals)
sum += kv.Value;
return sum;
}
readonly record struct SaleLine(string Region, int Amount);
Anyone else got a LINQ method you ignored for a year before realizing it was the one you needed the whole time? I'd like to hear which.
β still measuring allocations nobody asked me to measure
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.