📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

๐Ÿ“š Interview question

What actually happens between writing C# source code and the CPU executing it?

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.

Junior Theory Intermediate clr computer-basics how-code-runs il jit native-aot

๐ŸŽฏ Key points

  • Roslyn compiles C# into IL plus metadata inside an assembly, not into machine code
  • The host starts the CLR, which loads assemblies and type metadata on demand
  • The JIT compiles each method to native code on first call; tiered compilation starts with quick Tier 0 code and recompiles hot methods at Tier 1 (OSR handles long loops)
  • Dynamic PGO is enabled by default since .NET 8 (it can be turned off with TieredPGO=false) and feeds runtime profile data into Tier 1 for devirtualisation and inlining
  • JIT time is real CPU cost: it drives cold-start latency in serverless and scale-out, which is why ReadyToRun and Native AOT exist
  • Native AOT removes the JIT, so Reflection.Emit, dynamic assembly loading and some reflection patterns are unavailable; source generators replace them
  • The OS creates the process and maps the code into virtual memory; the CPU then fetches, decodes and executes the machine code, pipelined and out of order

โฑ 30-second answer

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.

๐Ÿ—ฃ Full answer (senior)

The pipeline in one sentence

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.

1. Build time: Roslyn produces IL

  • dotnet build runs MSBuild, which calls the Roslyn C# compiler.
  • Roslyn checks syntax and types and emits Common Intermediate Language (IL) and metadata (types, methods, references) into an assembly such as MyApp.dll.
  • IL is a stack-based instruction set that doesn't depend on any CPU. The same .dll runs on Windows x64, Linux Arm64 and macOS.

2. Start-up: the host and the CLR

  • Running dotnet MyApp.dll (or the MyApp apphost) starts the host, which reads MyApp.runtimeconfig.json, finds the right runtime version and loads CoreCLR.
  • The CLR loads assemblies lazily, builds in-memory type information, and sets up the garbage collector and the thread pool.

3. Run time: the JIT turns IL into machine code

  • Each method starts with a stub. On the first call, the stub asks the JIT (RyuJIT) to compile that method's IL into native code for the actual CPU.
  • Tiered compilation (on by default):
    • Tier 0: compiled fast with minimal optimisation, so start-up is quick.
    • Methods that are called often (about 30 calls) or loop a lot are recompiled in the background at Tier 1 with full optimisation. On-stack replacement (OSR) lets a long-running loop switch to optimised code mid-method.
    • Dynamic PGO (on by default since .NET 8) instruments hot Tier 0 code, records which types and branches are actually used, and feeds that into Tier 1, enabling guarded devirtualisation and better inlining.

4. Ahead-of-time options

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

5. The CPU and the OS

  • The OS loader maps the executable and native libraries into the process's virtual memory, creates the main thread, and the scheduler gives it CPU time.
  • The CPU only understands machine code. Its instruction pointer points at the next instruction; it fetches it from memory (via caches), decodes it and executes it with registers and the ALU.

Production example

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.

๐Ÿ” What the interviewer is checking

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.

๐Ÿ“š Learn in depth

1. The stages, drawn out

  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

2. What IL looks like

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

3. Tiered compilation in detail

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.

4. ReadyToRun versus Native AOT

  • ReadyToRun code is precompiled but less optimised (it can't assume the exact CPU or inline across version bubbles). Hot methods are still re-JIT-compiled at Tier 1.
  • Native AOT removes the JIT entirely. The app can't generate code at run time, so features that depend on it (some reflection patterns, Reflection.Emit, dynamic assembly loading) don't work. The compiler warns about this with trimming and AOT analysers.

5. How the OS and CPU take over

  1. The OS creates a process with its own virtual address space.
  2. The loader maps the executable and libraries into that space; pages are loaded lazily on page faults.
  3. The OS creates the main thread and the scheduler puts it on a core.
  4. The CPU's instruction pointer moves through machine code: each instruction is fetched (from L1i cache if possible), decoded into micro-operations, and executed.
  5. JIT-compiled code lives in memory pages marked executable; the CPU doesn't care whether code came from disk or from the JIT.

6. Why this matters in interviews and production

  • Cold start: JIT work is real CPU time. Serverless and scale-out scenarios care about it.
  • Benchmarks: never time the first call; it includes JIT. BenchmarkDotNet runs warm-up iterations for this reason.
  • Diagnostics: stack traces show IL-level methods, but performance depends on the generated machine code (inlining, bounds-check elimination, devirtualisation).

7. Try it yourself

  1. Write a small Add method, build it, and open the .dll in ILSpy to see the IL.
  2. Paste the same code into sharplab.io and switch the output to JIT Asm to see the x64 machine code.
  3. On .NET 8 or later, run your app with 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.
  4. Publish the same app with -p:PublishReadyToRun=true and then with -p:PublishAot=true, and compare start-up time and output size.

๐Ÿ’ป Code

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

โš  Common mistakes

  • Mistake: Saying C# is interpreted. Why it is wrong: .NET does not interpret IL in normal use; every method is compiled to native code before it runs. Instead: Say Roslyn produces IL and the JIT (or an AOT compiler) produces machine code that the CPU executes directly.
  • Mistake: Thinking the .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.
  • Mistake: Assuming the JIT compiles the whole app at start-up. Why it is wrong: It compiles each method on its first call, which spreads the cost but makes the first request to a new code path slower. Instead: Describe per-method, on-demand compilation, and use warm-up requests or ReadyToRun where first-request latency matters.
  • Mistake: Timing the first call in a benchmark. Why it is wrong: The first call includes JIT time and Tier 0 code, so the numbers say nothing about steady-state performance. Instead: Use BenchmarkDotNet, which warms up and measures optimised code.
  • Mistake: Treating Native AOT as a free speed-up. Why it is wrong: It improves start-up and memory but forbids runtime code generation, limits reflection, and loses Dynamic PGO, so peak throughput can be lower than JIT. Instead: Choose it for start-up-sensitive workloads (CLI tools, serverless, dense containers) after fixing trimming and AOT warnings.
  • Mistake: Picking a compilation mode without measuring the workload. Why it is wrong: A long-running API mostly cares about steady-state throughput, where JIT with Dynamic PGO usually wins; a function that scales from zero cares about cold start. Instead: Measure cold start, steady-state latency and memory for each mode before choosing.
  • Mistake: Ignoring the OS's part. Why it is wrong: Process creation, virtual memory mapping and thread scheduling all happen before and during execution. Instead: Mention the loader, virtual memory and the scheduler to show you understand the whole stack.

๐Ÿ› Senior insight: trade-offs

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.

๐Ÿ”— Follow-ups the interviewer may ask

  1. When: Candidate mentions ReadyToRun or Native AOT. Why: Checks whether the candidate can turn JIT versus AOT knowledge into a deployment decision.
    How to answer

    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.

  2. When: Candidate mentions that the JIT optimises code at Tier 1. Why: Probes how compiler and JIT optimisations change behaviour between configurations.
    How to answer

    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.

โ†ฉ Usually asked after

More in Computer Basics

Last updated 23 Oct 2026.

๐ŸŒ Sangam's Webring

โช Previous ๐Ÿ  Main Next โฉ
๐Ÿ’ฌ Chat with me