The full journey of .NET code: Roslyn compiles C# to IL, the runtime loads the assembly, the JIT (with tiered compilation and Dynamic PGO) or an AOT compiler turns IL into machine code, and the CPU runs those instructions.
C# source is not executed as written. Roslyn compiles it to IL (an intermediate, CPU-neutral instruction set) plus metadata in a .dll. When the app starts, the host loads the CLR, which loads assemblies as needed. The first time a method is called, the JIT compiles its IL into machine code for the current CPU (x64 or Arm64). With tiered compilation, it first produces quick, lightly optimised code, then recompiles hot methods with full optimisation, guided by Dynamic PGO profile data. From then on the CPU runs that native code directly, through its pipeline. ReadyToRun or Native AOT can move the compilation to publish time, which helps cold starts but limits runtime code generation.
Source code becomes IL at build time, IL becomes machine code at run time (or at publish time with AOT), and the CPU executes machine code that the OS has loaded into a process.
dotnet build runs MSBuild, which calls the Roslyn C# compiler.MyApp.dll..dll runs on Windows x64, Linux Arm64 and macOS.dotnet MyApp.dll (or the MyApp apphost) starts the host, which reads MyApp.runtimeconfig.json, finds the right runtime version and loads CoreCLR.| Option | What happens | Trade-off |
|---|---|---|
| JIT only (default) | Everything compiled at run time | Best peak performance, slower start-up |
ReadyToRun (PublishReadyToRun) |
Pre-compiled native code shipped alongside IL; JIT can still re-optimise hot methods | Faster start-up, bigger files |
Native AOT (PublishAot) |
Whole app compiled to one native executable, no JIT at run time | Fastest start-up and small memory, but no runtime code generation and reflection limits |
An Azure Functions app had cold starts of about 4 seconds. Profiling showed most of the time was JIT work for thousands of methods on start-up. Publishing with ReadyToRun cut cold start roughly in half, because most methods no longer had to be JIT-compiled before the first request. Steady-state latency was unchanged, because tiered compilation still re-optimised the hot paths.
Checks whether the candidate understands what .NET really does with their code, beyond "it compiles".
Green flags: IL versus machine code, per-method JIT, tiered compilation, AOT options with trade-offs, the CPU executing machine code, the OS creating the process.
Red flags: "C# is interpreted", "the dll is machine code", no idea what the JIT does, or no awareness that start-up and steady state behave differently.
Program.cs
| Roslyn (dotnet build)
v
MyApp.dll = IL + metadata + manifest
| host (dotnet / apphost) reads runtimeconfig.json, loads CoreCLR
v
CLR: assembly loader -> type loader -> method stubs
| first call of a method
v
RyuJIT: IL -> native code (Tier 0)
| hot method (call counting, loops via OSR)
v
RyuJIT again: optimised native code (Tier 1, with Dynamic PGO data)
|
v
CPU: fetch -> decode -> execute, using registers, caches, RAM
For static int Add(int a, int b) => a + b; Roslyn emits roughly:
ldarg.0 // push a
ldarg.1 // push b
add // pop two, push the sum
ret // return top of stack
IL is a stack machine. Real CPUs are register machines, so the JIT's main job is to map the evaluation stack onto CPU registers. On x64, the optimised result is often just:
lea eax, [rcx+rdx]
ret
| Stage | Optimisation | Why |
|---|---|---|
| Tier 0 | Minimal | Compile quickly so start-up isn't blocked |
| Instrumented Tier 0 (Dynamic PGO) | Minimal plus counters | Learn which types, branches and call targets are actually hot |
| Tier 1 | Full | Re-compile hot methods with profile data |
| OSR | Full, mid-method | Escape a slow Tier 0 loop that runs once but loops for a long time |
Methods with loops used to skip Tier 0; since .NET 7, OSR lets them start at Tier 0 too. You can switch tiering off with <TieredCompilation>false</TieredCompilation> or the DOTNET_TieredCompilation=0 environment variable, which is mainly useful for benchmarking experiments.
Reflection.Emit, dynamic assembly loading) don't work. The compiler warns about this with trimming and AOT analysers.Add method, build it, and open the .dll in ILSpy to see the IL.JIT Asm to see the x64 machine code.DOTNET_JitDisasm=Add and look at the assembly printed to the console, once for Tier 0 and once for Tier 1 after calling it in a loop.-p:PublishReadyToRun=true and then with -p:PublishAot=true, and compare start-up time and output size.namespace HowCodeRuns;
using System.Diagnostics;
using System.Runtime.CompilerServices;
public static class Program
{
public static void Main()
{
// First call: includes JIT compilation of Add (Tier 0).
var sw = Stopwatch.StartNew();
Add(1, 2);
Console.WriteLine($"First call: {sw.Elapsed.TotalMicroseconds:F1} ยตs");
// Call it many times so tiered compilation promotes it to Tier 1.
long total = 0;
for (var i = 0; i < 1_000_000; i++)
{
total += Add(i, 1);
}
sw.Restart();
Add(1, 2);
Console.WriteLine($"Warm call: {sw.Elapsed.TotalMicroseconds:F3} ยตs (total {total})");
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Add(int a, int b) => a + b;
}
# Print the machine code the JIT generates for Add (.NET 7+)
DOTNET_JitDisasm=Add dotnet run -c Release
# Compare publish modes
dotnet publish -c Release -r linux-x64 -p:PublishReadyToRun=true
dotnet publish -c Release -r linux-x64 -p:PublishAot=true
.dll contains machine code. Why it is wrong: A normal build contains IL and metadata, which is why one .dll runs on x64 and Arm64. Instead: Explain that only ReadyToRun or Native AOT add precompiled native code, and that is a publish-time choice.A senior answer connects the pipeline to decisions.
| Mode | Start-up | Peak throughput | Memory | Constraints |
|---|---|---|---|---|
| JIT (default) | slowest | best (Dynamic PGO, exact CPU) | highest | none |
| ReadyToRun | faster | same after Tier 1 re-JIT | similar | larger files |
| Native AOT | fastest | good, but no Dynamic PGO | lowest | no runtime codegen, trimming rules |
JIT gives the best peak throughput because it optimises for the exact CPU and the observed profile, but costs start-up time and memory. ReadyToRun is a cheap start-up win with almost no compatibility risk. Native AOT suits CLI tools, serverless and dense container workloads, but it changes how you write code (source generators instead of reflection, AOT-compatible serializers).
Knowing this explains odd production behaviour: the first request after a deployment is slow, latency improves for a minute as tiering kicks in, and a benchmark that ignores warm-up lies. Seniors add warm-up or readiness checks so pods only take traffic after hot paths are compiled.
By default dotnet publish gives a framework-dependent folder that needs the .NET runtime on the server. Self-contained (--self-contained -r linux-x64) bundles the runtime, so nothing needs installing, but the output is bigger and you patch the runtime by redeploying. On top of that: single-file packs everything into one executable, trimming removes unused code, ReadyToRun precompiles to speed up start-up, and Native AOT compiles to a native binary with very fast start-up and low memory but no dynamic code generation. The SDK can also build a container image directly. I choose based on where it runs, start-up and memory needs, and whether the libraries are trim- and AOT-safe, and I measure the result.
A configuration is a named set of build settings. Debug turns optimisation off and defines the DEBUG and TRACE symbols, so the debugger can show every variable and step line by line. Release turns optimisation on and defines RELEASE and TRACE instead of DEBUG. Both produce PDBs by default. Behaviour can differ for three reasons. Code under #if DEBUG, and calls such as Debug.Assert, are removed from Release. The optimising compiler and JIT inline and reorder code and end variable lifetimes earlier, which changes timing and when objects are garbage collected. And race conditions hidden by slow Debug code start to show. dotnet build defaults to Debug, while dotnet publish and dotnet pack default to Release, so you should test the build you ship.
Last updated 23 Oct 2026.