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
An unhandled error has occurred. Reload 🗙