.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:
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.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:
| Pattern | Off | On | Time (off → on) |
|---|---|---|---|
int[] a = {x, 2, 3} | 40 B | 0 B | 11.98 → 5.87 ns |
string[] w = {"Hello", "World!"} | 40 B | 0 B | 12.46 → 2.12 ns |
| Lambda capturing a local | 88 B | 24 B | 20.44 → 10.69 ns |
| Array held in a struct field | 40 B | 0 B | 14.01 → 5.46 ns |
Boxed int (object o = i) | 0 B | 0 B | 7.53 → 4.30 ns |
Array iterated through IEnumerable | 72 B | 40 B | 18.40 → 10.43 ns |
| Array stored into a static field | 40 B | 40 B | 12.64 → 12.87 ns |
| Array passed to a method | 40 B | 40 B | 13.38 → 13.45 ns |
Array with a variable length (new int[n]) | 40 B | 40 B | 12.19 → 12.27 ns |
List<int>(4) | 72 B | 72 B | 15.84 → 19.00 ns |
"id-" + i.ToString() | 83.6 B | 83.6 B | 21.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 objectslocal3. 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:
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
- Short-lived, fixed-size small arrays are no longer suspects. I'll stop hesitating over
new int[8]inside a loop — under half a kilobyte and not leaking out of the method, the GC never sees it. - The case for
stackallocandArrayPoolgot weaker, not void. Above the threshold, or when the length is only known at runtime, or when the buffer leaves the method, they're still the answer. The difference is that reaching for a pool without measuring first no longer has a justification. - I still watch lambdas in hot loops. With a captured variable, 24 bytes per round remain. With nothing captured, the compiler caches the delegate anyway and it's zero.
List<T>is not invited to this party. Even a four-element list keeps its 72 bytes exactly where they were.
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.trDetails: Advertise & partner. For user support, see the support page.