Measured

.NET 10 made 267 MB of garbage disappear from the same code — I measured exactly where the limit is

Which code shapes does .NET 10's widened escape analysis actually move to the stack, and how big can an array be before it stops? I measured it on Apple Silicon by flipping a single JIT switch, and found the cutoff byte by byte.

Bu yazının Türkçesi: Türkçe sürüm.

There is one line in the .NET 10 release notes that everybody skims past: "escape analysis was extended, small arrays and delegates can now be allocated on the stack." One paragraph, two assembly listings underneath. Nobody asks the obvious follow-up: how far does it go?

I asked, because short-lived small arrays are the bread and butter of my hot loops: score validation, input buffers, little statistics windows. For years I have been deciding between ArrayPool and stackalloc in places like that. If the JIT now does the job for free, I can stop deciding. I just need to know which shapes qualify and up to what size.

Everything below is measured. Machine: Apple Silicon Mac (arm64), .NET SDK 10.0.302, runtime 10.0.10, Release build. Most .NET performance write-ups measure on x64; these numbers are from arm64.

The setup: one binary, not two runtimes

The usual approach is to put .NET 9 and .NET 10 side by side. I don't like it: escape analysis is not the only thing that differs between two runtimes — GC defaults, library code and a few hundred other JIT decisions move too.

So instead I ran the same binary, on the same machine, flipping a single JIT switch:

DOTNET_JitObjectStackAllocation=0   # optimization off
(default)                           # on

That switch sits in the runtime's JIT config list as a RELEASE_CONFIG_INTEGER, so it works in a release build too. Now the only difference between the two runs is one optimization. What I measure is the delta of GC.GetAllocatedBytesForCurrentThread(): bytes that hit the heap per iteration. Every case gets 600,000 warmup iterations (so it reaches tier-1) and then 2 million measured ones.

The number that matters first

I wrote a realistic bit of work: an 8-element scratch array per round, a struct holding it, and a small lambda. The shape I keep seeing in score-validation loops. Five million rounds:

5 million rounds — bytes allocated on the heap
.NET 10, default0 MB · 0 Gen0 · 57–69 ms
Stack allocation off267 MB · 33 Gen0 · 72–82 ms

267 megabytes and 33 Gen0 collections disappear without a single character changing in the source.

Note the second half though: the time win is only 15–20%, nowhere near proportional to the memory win. That makes sense — Gen0 collection is cheap. The real prize isn't total runtime, it's that the pauses and the memory footprint are gone. On a game backend that is exactly the difference between average latency and p99 latency.

What the JIT is actually asking

The whole mechanism hangs on one question:

new int[8] inside a methodan object is born
JIT: escape analysisdoes this reference leak out of the method — stored in a static field, passed to a method that wasn't inlined, or returned?
It doesn'tGoes on the stack. The GC never hears about it; the space is reclaimed when the method returns.
It doesGoes on the heap. Ordinary Gen0 garbage, nothing changes.

.NET 10's contribution isn't asking the question — .NET 9 asked it too. What's new is that fewer things answer "yes, it leaks": value-type arrays, reference-type arrays, arrays held in struct fields and delegate objects can now pass the filter (Microsoft's release notes).

Which shapes qualify, which don't

I measured eleven patterns separately. Left column with the optimization off, right column on, both per iteration:

PatternOffOnTime (off → on)
int[] a = {x, 2, 3}40 B0 B11.98 → 5.87 ns
string[] w = {"Hello", "World!"}40 B0 B12.46 → 2.12 ns
Lambda capturing a local88 B24 B20.44 → 10.69 ns
Array held in a struct field40 B0 B14.01 → 5.46 ns
Boxed int (object o = i)0 B0 B7.53 → 4.30 ns
Array iterated through IEnumerable72 B40 B18.40 → 10.43 ns
Array stored into a static field40 B40 B12.64 → 12.87 ns
Array passed to a method40 B40 B13.38 → 13.45 ns
Array with a variable length (new int[n])40 B40 B12.19 → 12.27 ns
List<int>(4)72 B72 B15.84 → 19.00 ns
"id-" + i.ToString()83.6 B83.6 B21.06 → 21.52 ns

Three things fall out of that table:

1. Reference-type arrays win biggest. A two-string array drops from 12.46 ns to 2.12 ns — a sixth of the cost. It isn't only the allocation: the GC write barriers go away with it.

2. A lambda is only half rescued. 88 bytes down to 24. Those remaining 24 bytes are the closure class the compiler generates for the captured variable (<>c__DisplayClass). The Func object moved to the stack; the closure did not. This isn't a bug, it's a documented boundary — the release notes say stack allocation for closures is planned for a future release. So "lambdas are free now" is wrong; "lambdas are half price now" is right.

var f = (int x) => x + local;the compiler emits two objects
The Func objectcarries the behaviour
.NET 10: goes on the stack88 B → 24 B
The closure classcarries the captured local
Still on the heapall 24 remaining bytes are this

3. The control group behaved. Static field, method argument, variable length — all three produced identical numbers in both modes. Those rows are the proof that the harness measures what I think it measures; if everything had gone to zero, I'd be measuring something else.

One surprise: boxing was already zero in both modes. In object o = i; return (int)o; the JIT removes the box entirely, and it does so regardless of the JitObjectStackAllocation switch — the box doesn't move to the stack, it never exists.

Where the limit is: 528 bytes, counted differently than you'd think

This was the part I actually cared about. I grew the array until it fell back to the heap. Coarse sweep, then bisection, until the boundary was a single element wide: int[128] on the stack, int[129] on the heap.

The default in the runtime source is JitObjectStackAllocationSize = 528. But a 128-element int array occupies 24 + 512 = 536 bytes in memory. 536 is more than 528, and it still qualifies. The arithmetic doesn't add up.

It only adds up if the JIT counts the object header as 16 bytes, not 24 — method table plus length plus payload, excluding the sync block. I tested that theory against three different element types, and the cutoff landed on 16 + payload ≤ 528 every time:

Both sides of the cutoff — heap bytes per iteration
int[128] · 16+512 = 5280 B — stack
int[129] · 16+516 = 532544 B — heap
long[64] · 16+512 = 5280 B — stack
long[65] · 16+520 = 536544 B — heap
byte[512] · 16+512 = 5280 B — stack
byte[520] · 16+520 = 536544 B — heap

Three element types, one threshold. In practice: fixed-size arrays up to roughly half a kilobyte go on the stack. That's 128 ints, 64 longs or 64 object references — which covers most everyday scratch buffers.

The limit is tunable, and there's a nice trap in it: these environment variables are read as hexadecimal. Writing DOTNET_JitObjectStackAllocationSize=4096 doesn't set 4096, it sets 0x4096 = 16,534. I misread one measurement before noticing; setting =218 (that is, 0x218 = 536) and watching the boundary move confirmed it.

Still: this knob is not a supported setting. Raising the limit consumes more stack and shortens the path to a stack overflow in deep call chains. Excellent for measuring, no for production.

What I'm changing

What I don't know

I don't know why the limit is 528. Probably a balance struck against typical stack frame sizes, but that's my guess — I could not find the reasoning in the source.

In the IEnumerable case I also couldn't fully attribute the drop from 72 to 40 bytes to a specific object; the release notes say de-abstraction work in that area is ongoing, and the measurement shows exactly a half-finished win.

And there's an arm64-specific item I could not measure at all: .NET 10 changed the write barriers on Arm64, and the release notes report GC pause improvements between 8% and 20%. I couldn't build a clean single-switch A/B for that one, so I'm passing it on as Microsoft's claim, not as something I measured myself.

Advertise on this blog, or work with us

MCALAB is an independent studio. For sponsorship, cross-promotion or a partnership:

ads@mcalab.com.tr

Details: Advertise & partner. For user support, see the support page.