.NET JIT and IL: what happens to your code between the compiler and the CPU

  • Modern .NET
  • CLR
  • JIT
  • AOT

When you write code in C#, F# or VB.NET and press F5, the processor doesn't run that code directly. The compiler first turns it into intermediate code called IL (Intermediate Language), and then the JIT (Just-In-Time) compiler converts this IL into machine code your processor can execute. That two-step setup is what lets .NET be fast, portable and secure at the same time.

graph TD
    A[You write C# code] --> B[The compiler generates IL code]
    B --> C[IL is stored in an .exe/.dll file]
    C --> D[You run the program]
    D --> E[The JIT compiler converts IL into machine code]
    E --> F[The processor executes the machine code]
    
    style A fill:#e1f5fe
    style F fill:#c8e6c9
    style E fill:#fff3e0

What is .NET IL and why does it exist

Intermediate Language (IL), also known as Common Intermediate Language (CIL), is a low-level bytecode language that sits between your high-level code and the processor's machine instructions. In .NET, IL plays the role of a shared translation that everyone understands. The C# compiler doesn't emit machine instructions for a specific processor right away; it converts the code to IL first. That's what makes the code portable across platforms and processor architectures: the same IL code runs on Windows x64, Linux ARM, or any other supported platform. And since every .NET language compiles to the same IL, code written in different languages can call each other without friction.

graph LR
    subgraph "Different .NET languages"
        A[C# Code]
        B[F# Code] 
        C[VB.NET Code]
        D[C++/CLI Code]
    end
    
    subgraph "Universal IL"
        E[IL Bytecode]
    end
    
    subgraph "Different platforms"
        F[Windows x64]
        G[Linux x64]
        H[macOS ARM64]
        I[Windows ARM]
    end
    
    A --> E
    B --> E
    C --> E
    D --> E
    E --> F
    E --> G
    E --> H
    E --> I

IL works as a stack machine: calculations are performed by loading operands onto the stack, performing operations on them, and saving the result back onto the stack. Arguments and local variables have their own separate slots in memory, but interact with calculations across the stack. This keeps code generation simple and gives flexibility at runtime.

Detailed analysis of the IL code structure

Take a simple C# method and look at its IL equivalent:

1
2
3
4
5
6
7
public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}
.class public auto ansi beforefieldinit Calculator
    extends [System.Runtime]System.Object
{
    // Methods
    .method public hidebysig 
        instance int32 Add (
            int32 a,
            int32 b
        ) cil managed 
    {
        // Method begins at RVA 0x2050
        // Code size 9 (0x9)
        .maxstack 2
        .locals init (
            [0] int32
        )

        IL_0000: ldarg.1 // Load the argument 'a' onto the stack
        IL_0001: ldarg.2 // Load the argument 'b' onto the stack
        IL_0002: add     // Add the top two values ​​from the stack
        IL_0003: ret     // Return the result and end the method
    } // end of method Calculator::Add
}

The .method directive defines the beginning of a method with all its characteristics: public means that the method is accessible from the outside, hidebysig indicates that the method is hidden behind a signature, instance means that it is not a static method, int32 indicates the return type, and cil managed indicates that it is managed Common Intermediate Language code. The .maxstack 2 directive specifies the maximum number of elements that can be on the stack at the same time while this method runs. For a call to Add(5, 3), the stack changes like this:

graph TD
    subgraph "Executing Add(5, 3)"
        A["Start: stack []"] 
        B["ldarg.1: stack [5]"]
        C["ldarg.2: stack [5, 3]"] 
        D["add: stack [8]"]
        E["ret: return 8, stack []"]
    end
    
    A --> B --> C --> D --> E

ldarg.1 pushes the method's first argument onto the stack. In .NET, argument numbering starts at 0, but for non-static methods, argument 0 is reserved for reference this, so the first real argument has index 1. Similarly, ldarg.2 loads the second argument. The add instruction takes the top two values from the stack, adds them, and puts the result back on the stack. Finally, ret returns the top value from the stack as the result of the method and terminates its execution.

With local variables, the IL gets a bit longer:

1
2
3
4
5
public int Multiply(int x, int y)
{
    int result = x * y;
    return result;
}

Here's its IL:

    .method public hidebysig 
        instance int32 Multiply (
            int32 x,
            int32 y
        ) cil managed 
    {
        // Method begins at RVA 0x2050
        // Code size 11 (0xb)
        .maxstack 2
        .locals init (
            [0] int32 result,
            [1] int32
        )

        IL_0000: nop
        IL_0001: ldarg.1      // Push x onto the stack
        IL_0002: ldarg.2      // Push y onto the stack
        IL_0003: mul          // Multiply two values ​​(x * y)
        IL_0004: stloc.0      // Save the result in result
        IL_0005: ldloc.0      // Download result
        IL_0006: stloc.1      // Save it to a second local variable
        IL_0007: br.s IL_0009 // Unconditional transition to IL_0009

        IL_0009: ldloc.1      // Load variable [1]
        IL_000a: ret          // Return it as the result of the method
    } // end of method Calculator::Multiply

This one introduces a new directive .locals init ([0] int32 result, [1] int32) that defines local method variables. The variable result has index 0 and type int32. The instruction stloc.0 stores the top value from the stack into a local variable at index 0, and ldloc.1 loads the value of that variable back onto the stack.

Conditional logic and flow control in IL

For conditional constructs like if-else, the compiler generates IL with labels and jump instructions. For example:

1
2
3
4
5
6
7
public string CheckAge(int age)
{
    if (age >= 18)
        return "Adult";
    else
        return "Minor";
}

In IL, that becomes:

.method public hidebysig 
        instance string CheckAge (
            int32 age
        ) cil managed 
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 01 00 00
        )
        // Method begins at RVA 0x2050
        // Code size 31 (0x1f)
        .maxstack 2
        .locals init (
            [0] bool,
            [1] string
        )

        IL_0000: nop
        IL_0001: ldarg.1
        IL_0002: ldc.i4.s 18
        IL_0004: clt
        IL_0006: ldc.i4.0
        IL_0007: ceq
        IL_0009: stloc.0
        // sequence point: hidden
        IL_000a: ldloc.0
        IL_000b: brfalse.s IL_0015

        IL_000d: ldstr "Adult"
        IL_0012: stloc.1
        IL_0013: br.s IL_001d

        IL_0015: ldstr "Minor"
        IL_001a: stloc.1
        IL_001b: br.s IL_001d

        IL_001d: ldloc.1
        IL_001e: ret
    } // end of method Calculator::CheckAge

The ldc.i4.s 18 instruction loads the constant 18 onto the stack. The prefix ldc means load constant, i4 indicates a 32-bit integer, and s means that the constant is written in short form. The bge.s (branch if greater or equal, short form) instruction compares the top two values from the stack and moves to the specified label if the first value is greater than or equal to the second. br.s jumps unconditionally to the specified label. Without it, the block for adults would run right after the block for minors.

Loops in IL code

Loops are built from the same labels and jump instructions. Here's a for loop:

1
2
3
4
5
6
7
8
9
public int Sum(int n)
{
    int sum = 0;
    for (int i = 1; i <= n; i++)
    {
        sum += i;
    }
    return sum;
}

The IL code for this method looks something like this:

.method public hidebysig 
        instance int32 Sum (
            int32 n
        ) cil managed 
    {
        // Method begins at RVA 0x2050
        // Code size 34 (0x22)
        .maxstack 2
        .locals init (
            [0] int32 sum,
            [1] int32 i,
            [2] bool,
            [3] int32
        )

        IL_0000: nop
         // Initialization sum = 0
        IL_0001: ldc.i4.0   // Download 0
        IL_0002: stloc.0    // sum = 0

         // Initialization i = 1
        IL_0003: ldc.i4.1   // Download 1
        IL_0004: stloc.1    // i = 1

        // sequence point: hidden
        // Go to condition check
        IL_0005: br.s IL_0011   // Go to condition check
        // loop start (head: IL_0011)
            IL_0007: nop
            IL_0008: ldloc.0    // Download sum
            IL_0009: ldloc.1    // Download i
            IL_000a: add        // sum + i
            IL_000b: stloc.0    // sum = sum + i
            
            // Increment i++
            IL_000c: nop        
            IL_000d: ldloc.1    // Download i
            IL_000e: ldc.i4.1   // Download 1
            IL_000f: add        // i + 1
            IL_0010: stloc.1    // i = i + 1

            // Checking the condition i <= n
            IL_0011: ldloc.1    // Download i
            IL_0012: ldarg.1    // Download n
            IL_0013: cgt
            IL_0015: ldc.i4.0
            IL_0016: ceq
            IL_0018: stloc.2
            // sequence point: hidden
            IL_0019: ldloc.2
            IL_001a: brtrue.s IL_0007   // If i <= n, return to loop body
        // end loop

        IL_001c: ldloc.0
        IL_001d: stloc.3
        IL_001e: br.s IL_0020

        IL_0020: ldloc.3
        IL_0021: ret
    } // end of method Calculator::Sum

Notice that the compiler moved the condition check to the end of the loop. That means fewer jumps and a faster loop.

CLR: The heart of the .NET ecosystem

Before getting to the JIT compiler, it's worth covering the Common Language Runtime (CLR), the platform every .NET application runs on. You can think of CLR as an operating system for managed code: it provides all the services .NET programs need to run. CLR is responsible for loading and executing assemblies (assemblies), memory management via garbage collector, type safety, exception handling and, most important for us, JIT-compiling IL code into machine code. When you start a .NET application, what actually starts is the CLR, which then loads your code and runs it.

graph TD
    A[.NET Application] --> B[CLR is loading]
    B --> C[CLR loads assemblies]
    C --> D[The CLR initializes the AppDomain]
    D --> E[The CLR runs the JIT compiler]
    E --> F[Execution of machine code]
    F --> G[Garbage Collection]
    F --> H[Exception Handling]
    F --> I[Security Checks]
    
    style B fill:#e1f5fe
    style E fill:#fff3e0
    style F fill:#c8e6c9

The CLR provides a single runtime environment for all .NET languages, allowing code written in C# to interoperate with code in F# or VB.NET without any additional effort. This is achieved by Common Type System (CTS), which defines how types are declared, used, and managed at runtime, and Common Language Specification (CLS), which defines a subset of features available to all .NET languages.

JIT Compiler: From IL to Machine Code

The JIT compiler (Just-In-Time) is the part of the CLR that turns IL code into machine code for the processor. Traditional compilers convert all the code before it runs; JIT works at runtime and compiles a method only when it's first called:

sequenceDiagram
    participant App as Your program
    participant CLR as .NET Runtime
    participant JIT as JIT Compiler
    participant CPU as Processor
    participant Cache as Compiled code cache
    
    App->>CLR: Calling the method for the first time
    CLR->>JIT: It is necessary to compile IL into machine code
    JIT->>JIT: Analysis of IL code and metadata
    JIT->>JIT: Optimization for the current processor
    JIT->>Cache: Save the compiled code
    JIT->>CLR: Ready machine code
    CLR->>CPU: Execution of machine code
    CPU->>App: Performance result
    
    Note over App,Cache: The following calls use the cached code
    App->>CLR: Calling the same method again
    CLR->>Cache: Get ready machine code
    Cache->>CPU: Execution without compilation
    CPU->>App: Performance result

It all starts with the first call to a method. JIT reads the method's IL code along with its metadata to work out which operations to perform. Then it looks at the current processor: available instructions, number of registers, cache size and other architectural details. From that, JIT generates machine code optimized for that specific processor.

JIT can also do optimizations that traditional compilers can't. For example, it can inline small methods directly into the code that calls them, eliminating the overhead of calling the method. It can also optimize loops, rearrange instructions to make better use of the CPU pipeline, and even remove code that never executes.

Here are some of the strategies JIT uses. Constant optimization computes values at compile time when they're known in advance. Dead code optimization removes instructions whose output is not used anywhere. Common Subexpression Elimination avoids re-evaluating identical expressions. Loop unrolling unrolls small loops to reduce the overhead of checking conditions.

Tiered compilation (Tiered JIT)

Modern versions of .NET use tiered compilation (Tiered JIT) so an application can both start quickly and later run at full performance. A method goes through these stages:

graph LR
    A[The first method call] --> B[Tier 0: Fast Compilation]
    B --> C[The method is executed]
    C --> D{Is the method called often?}
    D -->|Yes| E[Tier 1: Optimized compilation]
    D -->|No| F[Leave Tier 0]
    E --> G[High performance code]
    F --> H[Simple code for rarely used methods]

On the first call, JIT compiles the method with minimal optimizations (Tier 0) so startup doesn't pay for expensive optimizations. If the method gets called often, JIT notices and recompiles it with the full set of optimizations (Tier 1). Rarely used methods stay as simple code, and hot ones get fully optimized.

Tools for analyzing IL code

You can inspect IL code with several tools.

  • ILSpy is one of the most popular free tools for decompiling .NET assemblies. It shows IL code next to decompiled C# code, which makes it a good way to learn how the compiler transforms your code.

  • ILDasm (IL Disassembler) is an official tool from Microsoft that is part of the .NET SDK. It can disassemble .NET assemblies and create text files with IL code that can then be edited and reassembled using ILAsm.

  • dotPeek by JetBrains is an alternative with advanced navigation and code analysis. It can build Visual Studio projects from decompiled code and has integration with other JetBrains tools.

For quick experiments there's SharpLab.io, an online tool that shows IL code in real time as you type C#. It's the easiest way to see what various C# constructs turn into.

Practical scenarios for using IL knowledge

Knowing IL pays off most often when you're tuning performance. For example, if you notice that a certain part of your code is running slowly, an IL analysis can show whether the compiler is generating efficient instructions, whether there are redundant boxing/unboxing operations, or whether compiler optimizations are working correctly.

In high-performance code, it helps you avoid constructs that generate inefficient IL. For example, using foreach for arrays generates different IL code compared to the traditional for loop, and understanding this difference can help you make the right choice.

Debugging complex problems sometimes requires analyzing the IL code, especially when the problem is related to unexpected compiler or runtime behavior. For example, problems with closure in lambda expressions often become clear only after analyzing the generated IL code.

And if you're writing your own compiler, code generator or static analysis tool, you can't do without it. Many tools, such as Entity Framework, generate IL code dynamically, and knowing how that works makes them easier to use well.

Optimizations and pitfalls

The JIT compiler performs many optimizations, but some of them may not be obvious. Method inlining automatically inserts small methods at their call sites, eliminating method call overhead. However, this can increase the size of the code, so the JIT uses heuristics to decide on inlining.

Dead code elimination removes code that is never executed, but this process can be difficult in the presence of reflection or dynamic code loading. Constant folding allows constant expressions to be evaluated at compile time, but may be limited in the presence of side effects. Keep in mind that JIT optimizations may differ between Debug and Release modes: in Debug, many of them are disabled to make debugging easier. So always measure performance on Release builds.

AOT: An alternative to JIT compilation

Ahead-of-Time (AOT) compilation takes a completely different approach to running .NET code. AOT doesn't compile IL to machine code at runtime; it compiles everything in advance, when the application is built. The result is a self-contained executable that doesn't need the .NET runtime installed on the target machine. Compare the two paths:

graph LR
    subgraph "Traditional JIT approach"
        A1[C# Code] --> B1[IL Code]
        B1 --> C1[.NET Runtime Required]
        C1 --> D1[JIT Compilation]
        D1 --> E1[Machine Code]
    end
    
    subgraph "AOT approach"
        A2[C# Code] --> B2[IL Code]
        B2 --> C2[AOT Compilation]
        C2 --> D2[Native Executable]
        D2 --> E2[Direct Execution]
    end
    
    style C2 fill:#fff3e0
    style D2 fill:#c8e6c9
    style E2 fill:#e8f5e8

The main win with AOT is fast startup, since no time goes into JIT compilation at runtime. You notice it most in server-side applications, microservices and container environments, where a lot depends on how fast things start. AOT applications also come out smaller, because only the code that's actually used gets included.

The price is the flexibility of dynamic code. Reflection, dynamic code generation, and some other features may work to a limited extent or not at all in an AOT environment. Also, AOT cannot perform the execution profile-based optimizations that are available to the JIT compiler.

Native AOT in .NET

Starting with .NET 8, Microsoft introduced Native AOT, which allows .NET applications to be compiled to native code without the need for a .NET runtime. To get there, static analysis determines which code is actually used, and a minimal native executable is generated.

Here's what a Native AOT project looks like:

1
2
3
4
5
6
7
8
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
</Project>

A Native AOT build goes through several steps. First, the compiler analyzes all the application code and its dependencies to determine which types and methods are actually used. This process is called tree shaking and allows you to significantly reduce the size of the final executable. The IL code is then compiled into native code using special AOT compilers such as CoreRT or RyuJIT in AOT mode.

ReadyToRun: A hybrid approach

ReadyToRun (R2R) is a compromise between JIT and full AOT. R2R assemblies contain both IL code and precompiled native code for the most common scenarios. The application starts faster because much of the code is already compiled, and JIT, with all its flexibility, handles the rest. At startup the CLR just checks whether a method already has native code:

graph TD
    A[C# Source Code] --> B[IL Code]
    B --> C[ReadyToRun Compiler]
    C --> D[R2R Assembly]
    
    subgraph "R2R Assembly contains"
        E[Original IL Code]
        F[Pre-compiled Native Code]
        G[Metadata]
    end
    
    D --> E
    D --> F
    D --> G
    
    subgraph "Runtime Execution"
        H[CLR loads R2R]
        I{Is native code available?}
        I -->|Yes| J[Use ready-made code]
        I -->|No| K[JIT compilation of IL]
    end
    
    E --> H
    F --> H
    H --> I

R2R pays off most in large applications and frameworks like ASP.NET Core, where the most frequently used code paths can be precompiled, leaving infrequently used parts for JIT compilation.

Profile-Guided Optimization (PGO)

Profile-Guided Optimization improves optimizations using data on how the code is really used. PGO works in two steps: first, the application is executed with instrumentation that collects statistics about which parts of the code are executed most often, and then this information is used to generate optimized code.

sequenceDiagram
    participant Dev as Developer
    participant App as Addition
    participant PGO as PGO System
    participant Compiler as Compiler
    
    Dev->>App: Launch with instrumentation
    App->>PGO: Collection of performance profile
    PGO->>PGO: Analysis of hot paths
    Dev->>Compiler: Compilation with PGO data
    Compiler->>PGO: Using the profile
    Compiler->>App: Optimized code

PGO helps most in complex applications with many execution branches. Say a condition in an if block is almost always true: PGO can make that path the fastest one.

The future of compilation technologies in .NET

Crossgen2 is a new generation of AOT compilation tools, with better performance and lower memory usage than previous solutions.

Dynamic PGO allows the JIT compiler to adapt to changes in the execution profile while the application is running. This means that the code can automatically optimize itself if the application's behavior changes over time.

Blazor WebAssembly AOT allows you to compile .NET code directly into WebAssembly, providing near-native performance of web applications.

Upcoming versions of .NET also improve support for reflection and dynamic code in AOT environments through the use of source generators and compile-time reflection, which will allow more existing code to run in AOT mode without modification.

Comparison of approaches: JIT vs AOT

The choice between JIT and AOT compilation depends on the specific needs of your application. JIT provides maximum flexibility and the possibility of dynamic optimizations, but requires time to compile at runtime. AOT gives you fast startup and doesn't need a runtime, but may have limitations in functionality. Here are the strengths and weaknesses of each in short:

graph TD
    subgraph "JIT Advantages"
        A1[Dynamic optimizations]
        A2[Full support for reflection]
        A3[Adaptation to runtime conditions]
        A4[Maximum flexibility]
    end
graph TD
    subgraph "JIT Disadvantages"
        B1[Slow startup]
        B2[Requires .NET Runtime]
        B3[More memory usage]
        B4[JIT compilation at runtime]
    end
graph TD
    subgraph "AOT Benefits"
        C1[Quick launch]
        C2[Smaller app size]
        C3[Does not require Runtime]
        C4[Best for containers]
    end
graph TD    
    subgraph "AOT Disadvantages"
        D1[Limited reflection]
        D2[Larger executable size]
        D3[Less flexibility]
        D4[More complex debugging]
    end

For high-load web applications, JIT is often the better choice because startup time is amortized over long runtimes, and dynamic optimizations can significantly improve performance. For microservices and containerized applications, AOT may be a better choice due to its fast startup and lower resource requirements.

Practical recommendations

If your application relies heavily on reflection or dynamic code generation, or depends on third-party libraries built on them, go with JIT. If fast startup, minimal memory usage or container deployment come first, consider AOT.

For many enterprise applications the best option may be a hybrid with ReadyToRun: fast startup without giving up functionality. You can also mix approaches: AOT for microservices that need a fast start, and JIT for core applications that need flexibility.

Only measurements will give you the final answer. Profile your application under real-world conditions with each compilation approach, and look not just at execution speed but also at startup time, memory usage and deployment size.

Download .md LLM corpus

Track .NET Runtime & Performance

Step 6 of 6. From code generation and collections to GC, async, cancellation, and the CLR/JIT runtime.