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

📚 Interview question

How does the JIT compiler work in .NET, and how can you see the machine code it generates?

Just-in-time compilation per method via stubs, what the JIT produces, and inspecting output with DOTNET_JitDisasm.

Senior Theory Advanced clr disassembly jit performance ryujit

🎯 Key points

  • The JIT compiles IL to native code per method on first call, through a stub that is then back-patched
  • It emits machine code plus GC info and unwind info
  • It optimises for the actual CPU and can use run-time facts such as initialised static readonly values
  • Tiered compilation means a method can be compiled more than once
  • DOTNET_JitDisasm prints generated assembly on release runtimes since .NET 7
  • Always inspect Release code and look at the Tier 1 version

⏱ 30-second answer

The JIT (RyuJIT) compiles IL to native code one method at a time, on first call. Each method initially points at a small stub; the first call enters the runtime, which JIT-compiles the IL, stores the code in the code heap, and patches the stub so later calls go straight to native code. The JIT also produces GC info and unwind info so the GC and exception handling work. With tiered compilation a method may be compiled more than once, first quickly then optimised. You can print the generated assembly with DOTNET_JitDisasm="MethodName", which works on release runtimes since .NET 7.

🗣 Full answer (senior)

How I would answer this in an interview

The JIT (Just-In-Time) compiler — RyuJIT in modern .NET — compiles each method's IL into native machine code the first time the method is called, for the exact CPU it's running on. With tiered compilation, it first produces quick, lightly optimised code and later recompiles hot methods with full optimisations (and profile data). I'd explain the pipeline, how a method gets compiled, what the JIT optimises, and how to see the generated assembly.

1. How a method gets compiled

  1. Each method initially has a stub (precode). The first call jumps into the runtime.
  2. The runtime asks the JIT to compile the method's IL (or uses precompiled ReadyToRun code if available).
  3. The JIT produces machine code plus GC info (where references live), unwind info and debug info.
  4. The stub is backpatched so future calls go directly to the native code.
  5. With tiering, hot methods are recompiled at Tier 1 and the entry point updated again.

Cost: compilation takes microseconds to milliseconds per method — negligible over a long-running service, significant at start-up (thousands of methods).

2. The JIT pipeline (simplified)

IL → import (build IR trees) → inlining → morph (simplify) → SSA/value numbering
   → optimisations (CSE, loop opts, bounds-check elimination, devirtualisation, struct promotion)
   → lowering → register allocation (LSRA) → code generation → emit machine code + GC/unwind info

Key optimisations: inlining (the most impactful), constant folding/propagation, dead code elimination, common subexpression elimination, range check elimination, loop hoisting/cloning, devirtualisation (sealed types, exact types, PGO-guarded), struct promotion (keep struct fields in registers), hardware intrinsics (SIMD via Vector128/256/512), and treating static readonly values as constants at Tier 1.

3. Advantages of JIT compilation

  • Targets the actual CPU (AVX2/AVX-512, Arm64 features) at run time.
  • Runtime knowledge: knows static readonly values, loaded types, and with dynamic PGO observes real behaviour (hot paths, common types) to optimise.
  • Portability: one IL binary runs on Windows/Linux/macOS, x64/Arm64.

Trade-offs: start-up cost and memory for the JIT; that's what ReadyToRun and NativeAOT address.

4. How to see the machine code

  • SharpLab (JIT Asm view) — quick experiments in the browser (Release mode).
  • BenchmarkDotNet with [DisassemblyDiagnoser(printSource: true, maxDepth: 2)] — shows assembly for benchmarked methods, including tier-1 code.
  • Environment variables (Release builds of .NET 7+ support these):
DOTNET_JitDisasm="MyNamespace.Pricing:CalculateGst"   # method filter, wildcards allowed
DOTNET_JitStdOutFile=jit.txt                          # write to a file
DOTNET_JitDisasmSummary=1                             # list every method jitted and its tier
DOTNET_TieredPGO=0 / DOTNET_TieredCompilation=0       # compare behaviours
  • Visual Studio Disassembly window while debugging (but debugging disables optimisations unless "Suppress JIT optimization" is off and you attach to a Release build).
  • dotnet-trace with JIT events to see what's compiled and when; PerfView for JIT stats.

Example output:

; Assembly listing for method Pricing:CalculateGst(decimal):decimal
; Tier1, optimized code
...

The header tells you the tier and whether it's optimised — important, because Tier 0 code looks very different.

5. Writing code that the JIT optimises well

  • Keep hot methods small so they inline; avoid exception handling inside tiny hot helpers (throw helpers instead).
  • Seal classes, use generics with struct constraints for zero-cost abstractions.
  • Write canonical loops (for (int i = 0; i < span.Length; i++)) so bounds checks are removed.
  • Use spans and static readonly configuration.
  • Avoid unnecessary virtual/interface calls in tight loops.
  • Measure with BenchmarkDotNet rather than guessing.

6. A production example

A pricing hot path was slower than expected despite simple code. DOTNET_JitDisasm showed the helper CalculateLine wasn't inlined because it contained a throw new ArgumentException(...). Moving the throw into a [DoesNotReturn] throw-helper method made CalculateLine small enough to inline; the disassembly then showed the loop with bounds checks eliminated. Throughput of the batch pricing step improved by ~20% with a two-line change.

🔍 What the interviewer is checking

The interviewer checks real understanding of JIT mechanics and the habit of verifying performance claims.

Green flags: per-method compilation via stubs and back-patching; GC and unwind info; run-time advantages; names DOTNET_JitDisasm or BenchmarkDotNet disassembly; mentions tiers.

Red flags: "compiles everything at startup"; benchmarks in Debug; cannot say how they would inspect generated code.

📚 Learn in depth

1. Code versions and backpatching

The runtime tracks code versions per method (Tier 0, Tier 1, OSR, R2R). Methods are called through a precode/stub or via "call counting" stubs during tiering; once a new version is ready, entry points are updated atomically. With tiered compilation, "call counting" begins after a delay to avoid start-up overhead.

2. GC info and safe points

The JIT emits GC info describing which registers/stack slots hold object references at each safe point, so the GC can find and update references. Fully interruptible code vs partially interruptible (only at calls) is chosen per method; loops without calls get GC polls when needed.

3. Inlining heuristics

The JIT estimates benefit vs code size: small methods (IL size < ~16 bytes) are almost always inlined; larger ones depend on heuristics (constant arguments, struct parameters, loops). Blockers: exception handling regions in the callee (partly relaxed in recent versions), virtual calls without devirtualisation, [MethodImpl(NoInlining)], very large methods, recursion, some generic shared code. [MethodImpl(AggressiveInlining)] raises the budget but isn't a guarantee.

4. JIT and hardware

The JIT queries CPU features at start-up (Avx2.IsSupported, AdvSimd.IsSupported) and treats them as constants, so if (Avx2.IsSupported) branches are resolved at JIT time. Vector<T> size adapts to the machine.

5. Alternatives to JIT

  • ReadyToRun: precompiled code (less optimised, version-resilient) used at start-up; hot methods re-JIT at Tier 1.
  • NativeAOT: no JIT; whole-program compiled; no tiering or dynamic PGO (static PGO possible via MIBC).
  • Interpreter: used on some platforms (e.g. WebAssembly modes) and for Expression.Compile(preferInterpretation: true).

Try it yourself

  1. Use DOTNET_JitDisasmSummary=1 to list methods jitted during start-up.
  2. View a method's Tier 0 vs Tier 1 code with DOTNET_JitDisasm and a loop to trigger tiering.
  3. Add a throw to a small helper and observe inlining changes.
  4. Use BenchmarkDotNet's DisassemblyDiagnoser on a span loop.

💻 Code

using System.Runtime.CompilerServices;

int[] data = Enumerable.Range(1, 1_000).ToArray();
long total = 0;

// Call enough times for tiered compilation to promote Sum to Tier 1
for (int i = 0; i < 200; i++)
{
    total += Sum(data);
    Thread.Sleep(1);
}

Console.WriteLine(total);
Console.WriteLine($"Methods jitted: {System.Runtime.JitInfo.GetCompiledMethodCount()}");
Console.WriteLine($"JIT time: {System.Runtime.JitInfo.GetCompilationTime().TotalMilliseconds:N1} ms");

[MethodImpl(MethodImplOptions.NoInlining)]
static long Sum(ReadOnlySpan<int> values)
{
    long sum = 0;
    for (int i = 0; i < values.Length; i++)   // comparing with values.Length lets the JIT drop bounds checks
        sum += values[i];
    return sum;
}
DOTNET_JitDisasm="Sum" DOTNET_JitStdOutFile=jit.txt dotnet run -c Release

⚠ Common mistakes

1. Looking at Tier 0 or Debug code

What happens: analysing disassembly from Debug builds or the first call. Why it is wrong: unoptimised code misleads. Do instead: use Release builds and tier-1 output.

2. Assuming JIT cost matters for steady state

What happens: worrying about JIT in long-running services. Why it is wrong: JIT cost is mostly at start-up. Do instead: focus JIT concerns on cold start (R2R/AOT).

3. Sprinkling AggressiveInlining

What happens: marking many methods aggressively inlined. Why it is wrong: code bloat and worse performance. Do instead: restructure (throw helpers, smaller methods) and measure.

4. Throwing directly in tiny hot methods

What happens: throw new ... inside small helpers. Why it is wrong: can prevent inlining. Do instead: use throw helpers (ArgumentNullException.ThrowIfNull, custom [DoesNotReturn] methods).

5. Non-canonical loops

What happens: loops indexing spans with unusual bounds. Why it is wrong: bounds checks remain. Do instead: iterate 0..span.Length or use foreach over spans.

6. Ignoring CPU differences

What happens: benchmarking on a dev laptop and assuming production equivalence. Why it is wrong: different SIMD support and caches. Do instead: benchmark on production-like hardware.

7. Turning off tiering globally to "fix" performance

What happens: TieredCompilation=false for all apps. Why it is wrong: slower start-up and loses PGO benefits. Do instead: understand tiering; tune only if measured.

8. Guessing instead of disassembling

What happens: micro-optimising based on assumptions. Why it is wrong: the JIT may already optimise or not as expected. Do instead: inspect disassembly and benchmark.

🏛 Senior insight: trade-offs

Senior engineers treat the JIT as a partner whose output can be checked.

Goal Approach
Verify a micro-optimisation BenchmarkDotNet plus [DisassemblyDiagnoser]
Understand startup cost JitInfo counters, DOTNET_JitDisasmSummary, then ReadyToRun
Remove bounds checks Loop to span.Length, slice first, then check disassembly
Avoid guesswork Never claim "the JIT will optimise this" without looking

The trade-off to articulate: JIT gives peak performance tuned to the machine and run-time data; AOT gives predictable startup.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says a method can be compiled more than once Why: Checks tiered compilation and OSR knowledge
    How to answer

    Tiered compilation (on by default since .NET Core 3.0) compiles a method first at Tier 0: fast to produce, barely optimised. The runtime counts calls; a method called about 30 times (after a short startup delay) is recompiled at Tier 1 with full optimisation on a background thread and the entry point is swapped. ReadyToRun code counts as a starting tier and is also promoted when hot. On-stack replacement (default on x64 and Arm64 since .NET 7) lets a long-running loop in a Tier 0 method jump into optimised code mid-execution. The result is fast startup and good steady-state performance, but benchmarks must warm up.

  2. When: Candidate mentions importing IL Why: Checks they can read the IL the JIT starts from
    How to answer

    IL (Common Intermediate Language, CIL) is the CPU-independent, stack-based instruction set that C# compiles to. Instructions push and pop values on an evaluation stack: ldarg.0, ldarg.1, add, ret. Calls use metadata tokens (call, callvirt, newobj), and conversions such as box are explicit. You read it with ILSpy, SharpLab or ildasm-style tools. Reading IL helps you spot hidden allocations (boxing, closures), see how async, foreach or using are lowered, and settle "what does the compiler really do" arguments.

  3. When: Candidate uses a span loop to remove bounds checks Why: Probes Span knowledge and its constraints
    How to answer

    Span<T> is a ref struct holding a reference and a length: a typed, bounds-checked view over contiguous memory, whether that memory is an array, a stackalloc buffer, native memory or part of a string (through ReadOnlySpan<char>). Slicing (span[2..6], Slice) creates a new view without copying, so parsers and formatters can work on pieces of a buffer with no allocations. Because it can point at the stack, it is stack-only: it cannot be a field of a class, be boxed, be captured by a lambda, or live across await/yield (C# 13 allows span locals in async and iterator methods as long as they do not cross those points). Helpers in MemoryExtensions (IndexOf, SequenceEqual, Sort, Trim) are vectorised. CollectionsMarshal.AsSpan(list) exposes a list's backing array, but adding to the list invalidates it.

🛠 Real-world problems: how you handle them

Span-based parser kept bounds checks in its hot loop
Situation
A CSV ingestion service processed [millions of rows] per batch and the parser was the top CPU frame.
Signals
Profiler showed the parse loop hot; BenchmarkDotNet results were slower than an equivalent unsafe prototype.
Investigation
Used `[DisassemblyDiagnoser]` and saw `cmp`/`jae` bounds checks before each element access because the loop bound was a separate variable.
Resolution
Rewrote the loop to iterate to `span.Length` after slicing, which let the JIT eliminate the checks.
Prevention
Kept the benchmark with disassembly in the repo to catch regressions.

How to tell it (STAR)

Our [CSV ingestion service] spent most CPU in parsing. I looked at the JIT's assembly and found bounds checks in the loop. A small loop rewrite let the JIT remove them, improving throughput by [N%] without unsafe code.

↩ Usually asked after

More in CLR Fundamentals

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me