Elisions
10/12/2025
The dotnet's JIT compiler is very clever, able to perform a wide suite of optimizations while also having to generate assembly on a dime. These optimizations work silently, making your code faster without you needing to think twice. However, it can be useful to be aware of some of these optimizations and take advantage of them when writing high performance code.
I've compiled (ha!) a non-exhausive list of some useful JIT optmizations to know if you want to squeeze every last drop of performance out of C#.
Note: All assembly examples are the fully optimized x86-64 assembly versions.
struct monomorphization
Struct generics in C# are monomorphized. In other words, each struct type gets its own code copy of MyMethod<T>, so calling MyMethod<MyStruct1>() and MyMethod<MyStruct2>() jumps to different copies of MyMethod<T>. In contrast, all classes share the same method body, since the size of a reference is always the same.
typeof(T).IsValueType
When the JIT is generating code for typeof(T).IsValueType, it already knows whether or not its true. If its generating it for a class, then its false. If its generating a copy for a struct, then its always true, so the constant an be embedded into the assembly or even take part in other optimizations like dead code elimination.
static bool M<T>() => typeof(T).IsValueType;
; Assembly for M[int]()
mov eax, 1 ; move constant value true into the eax register
ret ; return from the method
; All reference types share this generic instantation
; Assembly for M[System.__Canon]()
xor eax, eax ; clear the return register (anything xor itself is 0)
ret ; return from the method
is, and casts
Since the type is known when generating code for a struct type parameter, type checking operations like is, casting, or even typeof(T) == typeof(TKnown) evaporate at JIT time.
static Strooct? Call<T>(T inst) { if(inst is Strooct strooct) { strooct.Speak(); } if(typeof(T) == typeof(Strooct)) { return (Strooct)(object)inst!; } return null; } struct Strooct { public void Speak() => Console.WriteLine("I am Strooct"); }
; Assembly for Call[Strooct](Strooct):System.Nullable`1[Strooct]
push rbx ; save previous rbx value
sub rsp, 48 ; construct stack frame
mov ebx, ecx ; save inst into ebx, rbx is callee saved
mov rcx, 0x143001006C8 ; move constant address of string
call [System.Console:WriteLine(System.String)]
mov byte ptr [rsp+0x29], bl ; Value field for Nullable<Stroot>
mov byte ptr [rsp+0x28], 1 ; HasValue field for anullable<Strooct>
mov eax, dword ptr [rsp+0x28] ; move into return register
add rsp, 48 ; deallocate stack space
pop rbx ; restore old rbx value
ret
bounds checks on unknown indicies
One of the most common operations is the humble array access. Since C# is a memory safe language, the runtime must prove every array access is valid so you don't address into arbitrary memory.
But having a branch before every array access is slow, so the JIT uses a variety of methods to elide bounds checks. For example, if you already manually bounds checked an array index, the JIT might be able to elide it. There are a few catches to do this well though.
Firstly, the array must be a local. An array in a field (except for static readonly fields) can be changed at any time by another thread, so simply checking the length of an array that is a field is not enough to prove it is safe later.
Secondly, the check must be exhausive and handle negative indicies and indicies greater than or equal to the length. A simple check like index >= 0 && index < arr.Length would work, but is not optimal, since it requires two branches. Instead, you can use an unsigned integer comparison: (uint)index < (uint)arr.Length.
If index is positive, then the check proceeds as you would expect, evaluating to false if it is out of bounds.
If index is negative, then it would overflow into the range of [2147483648, 4294967295]. Since the largest value that .Length can be is int.MaxValue or 2147483647, it would always evaluate to false.
You may need to use unchecked() if your assembly is checked.
static int[] _array; static int Index(int index) { var arrLocal = _array; if ((uint)index < (uint)arrLocal.Length) return arrLocal[index]; return 1; }
; Assembly for Index(int):int
mov rax, 0x144A3C00160 ; move address of _array into rax
mov rax, gword ptr [rax] ; dereference _array
mov edx, dword ptr [rax+0x08] ; read _array.Length into edx
cmp edx, ecx
jbe SHORT G_M000_IG05 ; jump if edx (_array.Length) is below or equal to ecx (index)
mov ecx, ecx ; clear upper 32 bits of rcx
mov eax, dword ptr [rax+4*rcx+0x10] ; index into the array
ret
G_M000_IG05:
mov eax, 1 ; return 1
ret
In dotnet 10, you no longer need to do tricky uint casts and can instead write the more natural index < array.Length && index >= 0, which is converted into the optimized form. Note that the order of the operations are important. The JIT cannot optimize index >= 0 && index < array.Length, since if array is null and index is negative, an exception would not be thrown due to short circuiting. Meanwhile, the (uint)index < (uint)array.Length form always throws an exception if array is null.
throw helpers
Inlining is the optimization of replacing a method call with its method body. For the JIT compiler, it is especially important in order to allow for other optimizations as the JIT cannot see across method boundaries otherwise.
You can help the jit by using throw helpers, which moves the heavy work of creating and throwing the exception into a helper method, reducing the amount of code in the primary method. Less assembly code in a method makes inlining more likely and also saves memory.
Let's take our array example and invert the condition and call a throw helper in the failed case.
static int[] _array; static int Index(int index) { var arrLocal = _array; if (!((uint)index < (uint)arrLocal.Length)) Throw(); return arrLocal[index]; } static void Throw() => throw new IndexOutOfRangeException();
; Assembly for Index(int):int
sub rsp, 40
mov rax, 0x260AE000160
mov rax, gword ptr [rax]
mov edx, dword ptr [rax+0x08]
cmp edx, ecx
jbe SHORT G_M000_IG04
mov ecx, ecx
mov eax, dword ptr [rax+4*rcx+0x10]
add rsp, 40
ret
G_M000_IG04:
call [Program.Throw()]
int3
; Assembly for Throw() omitted
The Throw() method alone is over 40 bytes of code, which would have almost doubled the size of Index. A pretty sizable difference indeed.
The keen observer would have noticed that the JIT was able to actually detect Throw() as a throw helper that never returns - it is placed away from the hot path and there is no return after it. Normally, any method as small as Throw() would be inlined, but the JIT knows better here. In fact, if you try to force this method to not be inlined and mark Throw() with [MethodImpl(MethodImplOptions.NoInlining)], the JIT would not realize Throw() is a throw helper and would emit a bounds check as if Throw() is any other method.
sealed and calls
Virtual calls are the default method of achieving polymorphism in C#, but come at an additional cost. Even if you call a method and know that there are no subclasses that currently derive from it at compile time, a virtual call still needs to be generated as a subclass can appear at runtime, whether by dynamically loading an assembly or emitting IL at runtime.
Sealing classes that implement virtual methods prevents this from happening, and allows the JIT to avoid the virtual call and even inline the method.
static void Call(Derived d) { d.Method(); } abstract class Base { public abstract void Method(); } sealed class Derived : Base { public override void Method() { Console.WriteLine("Hello"); } }
; Assembly for Call(Derived) when sealed
cmp byte ptr [rcx], cl ; null check the "d" parameter
mov rcx, 0x280800021E8 ; move the reference of "Hello" into rcx
jmp [System.Console:WriteLine(System.String)] ; tail call. Derived.Method is inlined.
; Assembly for Call(Derived) when not sealed
mov rax, qword ptr [rcx] ; dereference method table pointer
mov rax, qword ptr [rax+0x40] ; dereference the correct method chunk
jmp [rax+0x20]Base:Method():this ; lookup the method in the chunk and jump