Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

My IEnumerable Parameter Boxed 80 MB. PGO Clawed Back 85%.

I had a helper that summed a column of ints. Tiny method, the kind you write without thinking. I'd typed its parameter as IEnumerable<int> out of pure habit, the "accept the most general thing you can" reflex that every

I had a helper that summed a column of ints. Tiny method, the kind you write without thinking. I'd typed its parameter as IEnumerable<int> out of pure habit, the "accept the most general thing you can" reflex that every C# tutorial drills into you. Months later it turned up near the top of an allocation trace, and I stared at it for a while because there's no new anywhere in it. It's a foreach and a +=. What could it possibly be allocating?

The allocation wasn't in my code. It was hiding in the foreach.

static long SumConcrete(List<int> items)
{
    long total = 0;
    foreach (var x in items)
        total += x;
    return total;
}

static long SumInterface(IEnumerable<int> items)
{
    long total = 0;
    foreach (var x in items)
        total += x;
    return total;
}

Same body, character for character. The only thing that differs is the parameter type, and that one difference decides whether the enumerator the foreach grabs lives on the stack or on the heap.

Here's why. List<T>.GetEnumerator() returns List<T>.Enumerator, which is a struct. When you foreach over a concrete List<int>, the compiler binds straight to that struct method and the enumerator never leaves the stack. But IEnumerable<int>.GetEnumerator() returns IEnumerator<int>, an interface. To pass a struct back through an interface reference, the runtime has to box it. So every call into the interface version allocates one boxed enumerator before the loop even starts.

I measured it the way I measure most of these: GC.GetAllocatedBytesForCurrentThread() wrapped around two million calls on an eight-element list. Allocation counts don't jitter the way timings do, so the number actually means something.

calls = 2,000,000, both checksums equal: True
SumConcrete(List<int>)   :            0 bytes   gen0=0
SumInterface(IEnumerable):   12,551,760 bytes   gen0=0
per call (interface)     :          6.3 bytes

Zero versus twelve megabytes for identical arithmetic. Then I ran it again, expecting the same twelve.

SumInterface(IEnumerable):   13,801,880 bytes   gen0=0
per call (interface)     :          6.9 bytes

A different number. Allocation counts are supposed to be deterministic. Same work in, same bytes out. A figure that wobbles between runs means something non-deterministic is deciding how much to allocate, and that's the clue that pointed me at the JIT.

What PGO is quietly doing

.NET compiles your hot methods twice. The first pass, tier-0, is fast and dumb and boxes the enumerator on every single call. While that version runs, the JIT keeps notes: which concrete type actually shows up at this call site? Every time, it's List<int>. So when the method gets hot enough to recompile at tier-1, the JIT bakes in a guard that says "if it's a List, run the inlined struct enumerator, skip the box," and the common path stops allocating. The ~12 MB I measured is just the boxes from the warmup window, before tier-1 took over. How many that is depends on how quickly the method tiered up on that particular run, which is exactly why the number wobbles.

If you want to see the box with no PGO covering for it, switch Dynamic PGO off:

DOTNET_TieredPGO=0 dotnet run -c Release
SumInterface(IEnumerable):   80,000,000 bytes   gen0=0
per call (interface)     :         40.0 bytes

There's the honest number. Forty bytes a call, two million calls, exactly 80 MB, flat across runs. That's the real weight of a boxed List<int>.Enumerator, and PGO had been eating about 85% of it on my behalf without telling me.

When PGO can't save you

The rescue comes with one condition: the call site has to be monomorphic. The JIT devirtualizes to one type precisely because it only ever sees one type. Hand the same method a mix and it can't pick a winner.

for (int i = 0; i < calls; i++)
    s += Sum(i % 2 == 0 ? (IEnumerable<int>)list : arr);
polymorphic (List + array alternating): 72,001,992 bytes, 36.0/call

Back to boxing on nearly every call. Which means the methods most likely to pay full freight are the flexible, reusable ones that cheerfully accept whatever you throw at them. The exact spots where IEnumerable<T> felt like the grown-up choice.

The honest part

None of this touched gen0. Eighty megabytes didn't even trigger a collection on this box, because the container has a roomy ephemeral budget and best-of-a-few-runs is the lens I care about, not a lab-grade millisecond. For a method you call a few hundred times, 40 bytes each is noise. Type it however reads cleanest and move on. This only earns a second look on a genuinely hot inner loop, and on those, modern .NET already handles the monomorphic case for you.

My rule now, for whatever it's worth: when I own both sides of the call and the loop is hot, I type the parameter as the concrete collection, or I take a ReadOnlySpan<int> and stop thinking about enumerators altogether. I keep IEnumerable<T> for public APIs, where accepting anything is the whole point. "Accept the most general type" is great advice for a library and a quiet tax on a hot internal helper.

The thing I keep relearning is that the allocation wasn't in the code I wrote. It was in the contract I picked for one parameter, and whether the JIT argues me back out of it depends entirely on how predictable my call sites are.

Here's the full runnable sample. Drop it in a Program.cs, dotnet run -c Release, then run it again with DOTNET_TieredPGO=0 to watch the number jump to the un-rescued 40 bytes a call.

using System.Diagnostics;

static long SumConcrete(List<int> items)
{
    long total = 0;
    foreach (var x in items)   // List<int>.Enumerator is a struct -> no box
        total += x;
    return total;
}

static long SumInterface(IEnumerable<int> items)
{
    long total = 0;
    foreach (var x in items)   // IEnumerator<int> comes back boxed on the heap
        total += x;
    return total;
}

const int Calls = 2_000_000;
var data = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8 };

_ = SumConcrete(data);   // warm up the JIT before measuring
_ = SumInterface(data);

long Measure(Func<long> body, out long checksum)
{
    var before = GC.GetAllocatedBytesForCurrentThread();
    long sum = 0;
    for (int i = 0; i < Calls; i++)
        sum += body();
    checksum = sum;
    return GC.GetAllocatedBytesForCurrentThread() - before;
}

int g0 = GC.CollectionCount(0);
long concreteBytes = Measure(() => SumConcrete(data), out long c1);
int concreteG0 = GC.CollectionCount(0) - g0;

g0 = GC.CollectionCount(0);
long ifaceBytes = Measure(() => SumInterface(data), out long c2);
int ifaceG0 = GC.CollectionCount(0) - g0;

Console.WriteLine($"calls = {Calls:N0}, both checksums equal: {c1 == c2}");
Console.WriteLine($"SumConcrete(List<int>)   : {concreteBytes,12:N0} bytes   gen0={concreteG0}");
Console.WriteLine($"SumInterface(IEnumerable): {ifaceBytes,12:N0} bytes   gen0={ifaceG0}");
Console.WriteLine($"per call (interface)     : {(double)ifaceBytes / Calls,12:N1} bytes");

Have you caught one of these in your own hot paths, or is PGO covering for all of us quietly enough that nobody looks? I'd love to hear where it bit you.

— still counting bytes nobody asked me to count

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.