What Actually Changed Inside the .NET 11 JIT Compiler
Every .NET release ships a “performance improvements” blog post, and every year some colleagues asks me the same question: “does this mean our code got faster for free?” The honest answer: some of it, in some places, if the JIT can prove certain things about your code. Not because Microsoft is holding anything back, but because compiler optimizations are conditional by nature. A guard that fails costs you. An allocation that escapes still lands on the heap. A bounds check the JIT cannot disprove stays right there in the generated code, quietly doing its job, unimpressed by your changelog reading.
.NET 11 is currently in release candidate status (RC1 shipped September 8, 2026), with general availability expected around November 10, 2026 as a Standard Term Support release. The behaviors described here come from the official .NET 11 performance blog post and the linked dotnet/runtime pull requests. RC-stage codegen can still shift before GA, so treat exact numbers as directional, not contractual.
This article stays in the JIT and codegen layer: devirtualization, escape analysis, delegate layout, and bounds-check elimination. Runtime Async (a separate architectural change moving async lowering from the C# compiler into the JIT) and the AI/high-throughput implications of these changes are out of scope here; both deserve dedicated treatment rather than a rushed paragraph.
Guarded Devirtualization Gets Wider Reach
Interfaces are the polite fiction of object-oriented design: you write IValidator, the JIT sees an indirect call through a method table, and every optimization that depends on inlining, which is most of them, simply gives up at that boundary. The abstraction you added for testability has a runtime cost, and that cost is real regardless of how clean the diagram looks.
Guarded devirtualization (GDV) is the JIT’s workaround for that cost: instead of always emitting the indirect call, it inserts a type check with a fast path for the type it predicts is likely, and a fallback to the indirect call for everything else.
public interface IValidator
{
bool IsValid(Order order);
}
public sealed class DefaultValidator : IValidator
{
public bool IsValid(Order order) => order.Total > 0 && order.Items.Count > 0;
}
public decimal ProcessOrders(IEnumerable<Order> orders, IValidator validator)
{
decimal total = 0;
foreach (var order in orders)
{
if (validator.IsValid(order)) // GDV can guess DefaultValidator here
total += order.Total;
}
return total;
}
If validator is DefaultValidator at every call site in practice (a common shape: one production implementation, maybe a test double), the JIT can guess that type, guard on it, and inline IsValid on the fast path. .NET 11 extends this to generic virtual methods (GVMs) as well, both shared and non-shared instantiations, which previously fell back to indirect dispatch far more often because generic dispatch has an extra layer of indirection through the method table.
None of this is free, and it shouldn’t be sold as such. Every GDV guard is a type check that runs even when the guess is wrong, and a wrong guess pays for both the check and the fallback path. That is why GDV is profile-informed rather than blanket: the JIT only guards call sites where it has reason to believe one type dominates. Hand it a genuinely polymorphic call site with three or four implementations spread evenly, and GDV either does not fire or adds overhead without a corresponding win. “I used an interface” is not a performance argument in .NET 11 any more than it was in .NET 10; the benefit depends entirely on how monomorphic your actual call sites are, not on how the code is typed.
Escape Analysis Now Reaches Further
Every allocation you did not have to write a workaround for is an allocation the GC never has to touch. That is the entire pitch behind escape analysis, and it comes down to one question: does an object outlive the method that allocates it? If the JIT can prove the answer is no, the object can live on the stack, which the runtime documentation describes as decrementing a register-resident stack pointer, freed automatically when the method returns, instead of a heap allocation that eventually contributes to a GC pass.
.NET 11 widens the set of shapes escape analysis can reason about:
Nullable boxing. Boxing a Nullable<T> used to be an opaque operation from the analysis’s point of view; the JIT now expands it into its component parts before running escape analysis, so a boxed nullable that never actually escapes can be elided entirely.
public string FormatNullableInt(int? value)
{
object boxed = value; // forces a heap box whenever value.HasValue is true
return boxed switch
{
null => "none",
int i => i.ToString(),
_ => "unexpected"
};
}
When boxed never leaves the method as a reference anyone else can observe, the allocation disappears.
Enumerator chaining. Conditional Escape Analysis (CEA) now follows patterns where one GetEnumerator() call delegates to another, a shape that shows up constantly in wrapper types and LINQ-adjacent custom collections. Previously the indirection through the delegating enumerator was often enough to block analysis; instance-field-backed collection expressions can now match the allocation profile of static-field-backed ones.
Constrained calls. Generic equality comparisons go through constrained call sites (constrained. callvirt in IL), and the way the receiver was represented at those call sites used to force it onto the heap regardless of whether it escaped afterward. That representation no longer blocks stack allocation, which matters for anything doing EqualityComparer<T>.Default.Equals(a, b) in a hot loop over structs.
Why Allocations Still Escape
Widening the analysis does not soften the rule behind it: escape analysis is a proof, not a heuristic guess. The moment a reference is stored into a field, captured by a closure that outlives the method, or passed to a method the JIT cannot inline and inspect, the object escapes and goes on the heap exactly as before. Wider analysis means more provable cases, not fewer real escapes. If you are hand-rolling struct wrappers specifically to avoid allocation, it is worth re-profiling on .NET 11 RC before you keep defending that complexity in code review: some of what you engineered around might now happen automatically, and code written to route around a compiler limitation should not outlive the limitation itself.
And here is where I want to head off a claim I keep seeing repeated: none of this touches the garbage collector directly. Wider escape analysis and a smaller delegate layout (next section) both reduce the number of objects that ever reach the GC heap in the first place, which is less work for the collector without any change to the collector itself. If you come across a third-party summary claiming dedicated GC subsystem changes for .NET 11 (adaptive sizing, region strategy, or otherwise), trace it back to an official source before repeating it. This article deliberately does not, because the JIT-level changes above are what I could verify against the runtime repository.
Delegates Get Eight Bytes Smaller (and Better Laid Out)
Eight bytes sounds like nothing until you count how many delegates your average ASP.NET Core application allocates before breakfast. Two separate, small changes to MulticastDelegate’s internal layout landed for .NET 11, and both matter precisely because of that volume.
dotnet/runtime#99200 removes one pointer-sized field from every delegate instance, saving 8 bytes per delegate on 64-bit runtimes. Delegates are allocated constantly: every Action, Func, event subscription, and LINQ lambda capture is one. Shaving 8 bytes off a type instantiated that relentlessly is a real, if modest, reduction in allocation volume and GC pressure across a typical application, and it costs nothing to opt into: it is a runtime-level layout change, not a source-level one.
dotnet/runtime#129410 reorders the remaining fields so the target object and method pointer sit adjacently, enabling paired loads on Arm64 during delegate invocation. This is a cache-locality optimization: invoking a delegate touches both fields together, so keeping them in the same cache line and load-pairable order shaves cycles off every single invocation, which matters disproportionately for event-heavy or callback-heavy code on Arm64 (Apple Silicon dev machines, AWS Graviton, Azure Cobalt).
Neither change requires touching a single line of your code. It is easy to wave off “only 8 bytes” as a rounding error, right up until you remember it applies to one of the most frequently allocated reference types in the entire BCL, and rounding errors compound at that scale.
Bounds-Check Elimination Gets Meaningfully Sharper
This is the section that actually moves the needle for parsers, serializers, and anything looping over spans.
Array and span indexing in C# is bounds-checked by default: every array[i] compiles to a comparison against the array length before the access, because unchecked out-of-bounds reads are a memory-safety hole, not a performance feature waiting to be enabled. The JIT’s job is to prove, wherever it can, that the check is redundant, and .NET 11 adds several new proof strategies. This is where the release’s improvements are the most numerous and, in aggregate, probably the most consequential for typical throughput-sensitive code.
New Range-Analysis Strategies
Range analysis via inequality assertions. dotnet/runtime#121273 lets the JIT tighten a value’s provable range using != constant assertions, not just < or <=. C# list patterns benefit directly:
if (input is [':', not ':', ..])
{
// both indexed accesses are now provably in range
}
Same-block assertion propagation. dotnet/runtime#121527 tracks a bounds check’s success within a single basic block, so a second access to the same index later in the block reuses the first check’s proof instead of re-checking.
Bitwise operation bounds. dotnet/runtime#122263 combines the upper bounds implied by & and | operations. Base64-style table indexing is the textbook case:
byte b = GetByte();
int index = ((b & 0x03) << 4) | ((b & 0xF0) >> 4);
byte decoded = lookupTable[index]; // JIT proves index < 65, check removed
Unsigned guard deduction. dotnet/runtime#125056 derives a lower bound from the common (uint)i < span.Length guard idiom, so adjusted indices like span[i - 1] inside that guard no longer need a separate check.
Constant-index coalescing. dotnet/runtime#127439 merges multiple constant-index accesses on the same array into a single guard:
if (values[0] == a && values[1] == b && values[15] == p)
{
// sixteen accesses, one length check instead of sixteen
}
Slice bounds tracking. dotnet/runtime#127488 recognizes that span.Slice(span.Length - 4) guarded by a prior span.Length >= 4 check needs no further validation. The blog post’s ReadLastInt32 benchmark shows the generated assembly shrinking from 73 bytes to 28 bytes, purely from eliminated check instructions.
Loop cloning with != terminators. dotnet/runtime#129268 extends loop cloning (duplicating a loop body into a bounds-check-free fast path plus a checked fallback) to loops that terminate with != instead of only <, a pattern common in manually written index loops and pointer-style iteration.
Where the Proof Breaks
Every one of these is a proof the JIT did not used to be able to construct, and none of them change what your code does. They change how much redundant safety-checking machine code your code compiles down to. Here is the catch, and it is a real one: the proof only fires when the JIT can see the whole shape locally. Wrap your index computation in a method the JIT does not inline, or derive it from a field the JIT cannot track across calls, and the proof breaks, silently, with no warning that your carefully reasoned optimization just evaporated. If a hot loop matters enough to hand-tune, verify the elimination actually happened. Do not assume it from a changelog you skimmed on the train.
How to Check Whether Any of This Actually Applies to You
Reading the previous sections and nodding along is the easy part. Trusting that any of it applies to your codebase without checking is where people get burned. The one honest way to know if your code benefits is to look at the generated assembly, not to assume from a blog post, mine included. Set an environment variable and run in Release:
# find the exact JIT-internal method name first
$env:DOTNET_JitDisasmSummary = "1"
dotnet run -c Release
# then disassemble that specific method
$env:DOTNET_JitDisasmSummary = $null
$env:DOTNET_JitDisasm = "ReadLastInt32"
dotnet run -c Release
DOTNET_JitDisasmSummary prints one line per compiled method, in a form you can copy directly into DOTNET_JitDisasm. This matters because C#-compiler-mangled names (local functions, lambdas, async state machine methods) rarely match what you would guess from the source. Compare the output before and after a code change, or between .NET 10 and .NET 11 RC builds, and count the actual cmp/jae/jbe bounds-check instructions rather than trusting intuition.
Runtime Async: Out of Scope, Not Ignored
The official post also covers Runtime Async, an opt-in feature (<Features>$(Features);runtime-async=on</Features>) that moves async state-machine lowering from the C# compiler into the JIT itself. It is architecturally significant and produces large allocation and binary-size wins in deep async call chains. But it is a different kind of change, a compiler-to-runtime responsibility shift rather than a codegen-level optimization, and it deserves its own treatment rather than a rushed paragraph bolted onto this one.
The Actual Takeaway
None of these optimizations are switches you flip. They are proof strategies the JIT can now successfully complete for shapes it previously could not reason about. Three practical consequences follow from that.
- Re-measure before you re-architect. If you previously hand-rolled a workaround (manual devirtualization via
sealed, struct-based enumerators to dodge boxing, manual bounds-check hoisting withUnsafe), profile it again on .NET 11 RC. Some of that complexity may no longer be earning its keep, and code that exists to route around a compiler limitation should be revisited once the limitation narrows. - Do not assume the win transfers to your exact code. GDV needs a call site that is actually near-monomorphic in practice. Escape analysis needs the object to genuinely never escape, not almost never. Bounds-check elimination needs the JIT to see the whole index computation locally, typically meaning no un-inlined method boundary in between. Wrap the same logic differently and the proof can fail silently: no error, no warning, just a bounds check or an allocation that is still there, waiting for someone to notice in a profiler six months from now.
- Verify with
DOTNET_JitDisasm, not the changelog. Apply the same discipline here that you would to any other performance claim someone hands you: measure your own hot path before you write the win into a slide deck. RC-stage builds can still shift the exact codegen before November’s GA.
The JIT compiler team is doing real, incremental, well-documented work here, and none of my skepticism above is aimed at them. It is aimed at whoever copies the marketing conclusion straight into a performance report without opening a disassembler first. Verify it applies to your code. That is the job.

Comments