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

📚 Interview question

How does IL become machine code at run time, including how generic methods are compiled for reference and value types?

The path from IL to native code: method descriptors, precode and prestub, code heap, back-patching, and shared versus specialised generic code.

Senior Theory Expert clr generics jit method-desc runtime-internals

🎯 Key points

  • Each method has a MethodDesc whose entry point starts as a precode stub to the prestub
  • The prestub uses ReadyToRun code or calls the JIT, stores code in the code heap and back-patches the entry
  • The JIT emits GC info and unwind info alongside machine code
  • Reference-type generic instantiations share one canonical body using runtime lookups
  • Each value-type instantiation gets specialised code, avoiding boxing and enabling inlining
  • Generic shape affects JIT time, code size and NativeAOT compatibility

⏱ 30-second answer

Each method has a MethodDesc in the runtime. Its entry point starts as a small precode stub that jumps to the prestub. On the first call, the prestub finds R2R code or asks the JIT to compile the IL, writes the machine code into the code heap (with GC info and unwind info), then back-patches the entry point so future calls go directly to native code. Generics are compiled per instantiation shape: all reference-type instantiations share one canonical body (__Canon) that looks up type information through a generic dictionary, while each value-type instantiation gets its own specialised, fully optimised code.

🗣 Full answer (senior)

How I would answer this in an interview

IL becomes machine code lazily, per method: each method starts with a small stub; on the first call the runtime asks the JIT to compile it (or finds precompiled ReadyToRun code), then patches the entry so later calls go straight to native code. For generic methods, the runtime decides between shared code for reference-type instantiations and specialised code for each value-type instantiation — the key to understanding generic performance.

1. Method descriptors and precode

For each method, the runtime keeps a MethodDesc (metadata pointer, flags, entry point). Initially the entry point is a precode/prestub:

caller --call--> [precode] --first time--> ThePreStub -> runtime -> JIT compile -> backpatch entry
         later:  [precode/slot] ------------------------------------------------> native code
  1. The caller calls through an indirection (a vtable slot for virtual methods, a precode or indirection cell for others).
  2. First call lands in ThePreStub, which identifies the method, checks for R2R code, or invokes the JIT.
  3. The JIT compiles IL to native code with GC info, unwind info and debug info.
  4. The runtime backpatches the precode/slot so future calls go directly to the native code (and with tiering, later to a newer Tier 1 version).

Type loading happens alongside: before compiling a method, the types it touches must be loaded (MethodTables built), static constructors may need to run (or checks inserted), and string literals interned.

2. What the JIT produces

  • Machine code for the specific CPU (feature detection at start-up, e.g. AVX2/AVX-512, Arm64 extensions).
  • GC info: which registers/stack slots hold object references at each safe point.
  • EH info: exception handling regions.
  • Unwind info: for stack walking.
  • Code is stored in the code heap; method entry points updated.

3. Generic methods: shared vs specialised code

public static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;

Max(1, 2);                    // T = int
Max(1.5, 2.5);                // T = double
Max("a", "b");                // T = string
Max(customerA, customerB);    // T = Customer (class)

Value-type instantiations (int, double, any struct): the JIT compiles a separate specialised body per type. T is known exactly, so:

  • a.CompareTo(b) is a direct call on the struct (no boxing), often inlined,
  • sizes and layouts are known,
  • typeof(T) == typeof(int) checks fold to constants.

Reference-type instantiations (string, Customer, any class): the JIT compiles one shared body using a canonical placeholder __Canon (all references look the same to the machine code — pointer-sized, GC-tracked). Type-specific information needed at run time (for casts, typeof(T), new T[], statics, constrained calls) comes from a generic dictionary:

  • For instance methods of generic classes → reached via the object's MethodTable.
  • For generic methods and static methods → passed as a hidden instantiation argument.

So Max<string> and Max<Customer> run the same machine code, with CompareTo dispatched through the interface (with the dictionary supplying type info). Shared code saves JIT time and memory; specialised code maximises speed.

4. Consequences

  • Generic collections of value types (List<int>, Dictionary<Guid, T>) are fast — specialised, no boxing.
  • Generic math over structs compiles to the same code as hand-written loops.
  • Many value-type instantiations increase code size/JIT time (and NativeAOT binary size).
  • Shared generic code has small extra costs for dictionary lookups and can't specialise on T (e.g. typeof(T) branches remain).
  • Generic virtual methods need runtime lookup of the right instantiation per call — slower.
  • ReadyToRun can't always precompile cross-assembly generic instantiations; some are JIT-ed at start-up.

5. A production example

A high-throughput event processor used Dictionary<EventKey, Stats> where EventKey was a class. Changing it to a readonly record struct made the dictionary's methods specialised for the key type (no virtual Equals/GetHashCode calls via shared code, no per-key allocation), improving lookup throughput by ~30%. Conversely, a generic Serializer<T> instantiated for ~200 different struct DTOs inflated JIT time at start-up; making those DTOs classes (shared code) cut start-up JIT time, since that path wasn't hot.

🔍 What the interviewer is checking

The interviewer checks deep runtime knowledge useful for performance and AOT work.

Green flags: MethodDesc and precode, prestub, code heap, back-patching, GC info, canonical sharing versus value-type specialisation, generic dictionaries.

Red flags: thinks generics are erased; thinks classes are compiled as a unit; unaware the GC depends on JIT metadata.

📚 Learn in depth

1. Entry points and slots

  • Non-virtual methods: callers use a precode (or, after backpatching, direct calls when the JIT knows the target is compiled).
  • Virtual methods: callers load the vtable slot; the slot initially points to the precode and is backpatched.
  • Interface methods: virtual stub dispatch cells resolve and cache targets.
  • Tiered compilation: call counting stubs sit in front; entry points move from Tier 0 to Tier 1 code.

2. Type loading and class constructors

Before running a method that accesses a type's statics, the runtime must ensure the type's .cctor has run (for precise-init types) or will run before first static access (beforefieldinit). The JIT inserts checks or helper calls unless it knows the class is already initialised (common at Tier 1).

3. Generic dictionaries in detail

Dictionary slots are filled lazily via helper calls ("generic lookup"). Hot lookups are cached in the dictionary. Code accessing T-dependent things in shared code pays a small indirection. The JIT can sometimes avoid lookups by inlining shared code into specialised callers.

4. Canonical forms

  • List<string> and List<object> share List<__Canon> code.
  • Dictionary<string, int> shares Dictionary<__Canon, int> code with Dictionary<Customer, int>.
  • Dictionary<int, string> uses Dictionary<int, __Canon> code.
  • Each distinct value-type combination produces another specialisation.

5. AOT and generics

NativeAOT must know all instantiations at compile time; it generates specialised code for value types and shared code for references, plus dictionaries. Unknown instantiations at run time can't be created. R2R similarly precompiles known instantiations within version bubbles.

Try it yourself

  1. Use DOTNET_JitDisasmSummary=1 and call Max<int>, Max<double>, Max<string>, Max<Customer>; count compiled bodies.
  2. Inspect disassembly for Max<int> and Max<string>.
  3. Benchmark a dictionary with class vs struct keys.
  4. Measure JIT time with many struct instantiations of a generic type.

💻 Code

using System.Runtime.CompilerServices;

Console.WriteLine(Max(3, 7));                         // Max<int>: specialised code
Console.WriteLine(Max(2.5, 1.5));                     // Max<double>: specialised code
Console.WriteLine(Max("apple", "pear"));              // Max<__Canon>: shared code
Console.WriteLine(Max(new Version(1, 2), new Version(1, 10)));  // same shared code as string

Console.WriteLine($"Methods jitted: {System.Runtime.JitInfo.GetCompiledMethodCount()}");

[MethodImpl(MethodImplOptions.NoInlining)]
static T Max<T>(T a, T b) where T : IComparable<T> =>
    a.CompareTo(b) >= 0 ? a : b;   // for value types: direct, inlinable call; no boxing
DOTNET_JitDisasmSummary=1 dotnet run -c Release | grep Max

⚠ Common mistakes

1. Assuming all generic code is specialised

What happens: expecting reference-type instantiations to be as optimised as value types. Why it is wrong: they share code with runtime lookups. Do instead: use value types for hot generic paths when appropriate.

2. Ignoring code-size effects

What happens: many struct instantiations of large generic types. Why it is wrong: more JIT time and memory; larger AOT binaries. Do instead: limit gratuitous struct instantiations.

3. Generic virtual methods in hot paths

What happens: virtual T Get<T>() called frequently. Why it is wrong: expensive runtime resolution. Do instead: generic interfaces/classes with fixed type parameters.

4. Expecting typeof(T) branches to vanish for classes

What happens: relying on folding in shared code. Why it is wrong: branches remain for reference types. Do instead: accept the cost or specialise differently.

5. Measuring first-call performance

What happens: timing includes JIT compilation. Why it is wrong: misleading results. Do instead: warm up.

6. Boxing in generic code without constraints

What happens: casting T to interfaces inside generic methods. Why it is wrong: boxes value types. Do instead: constraints for constrained calls.

7. Using MakeGenericType in AOT apps

What happens: runtime instantiation with new value types. Why it is wrong: not supported/slow fallbacks. Do instead: known instantiations.

8. Overlooking static constructor checks

What happens: heavy static constructors in generic types used in hot code. Why it is wrong: checks/initialisation overhead and per-instantiation runs. Do instead: keep static initialisation light.

🏛 Senior insight: trade-offs

Generic code shape affects startup, memory and speed.

Design Effect
Many value-type instantiations More JIT time and code size, fastest code
Reference-type instantiations Shared code, small, slight lookup cost
Generic virtual methods over structs Expensive and AOT-unfriendly
where T : struct, INumber<T> maths Specialised and fully inlined

Senior engineers keep this in mind when designing generic libraries used across hundreds of types.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains shared versus specialised generic code Why: Probes generic performance in everyday code
    How to answer

    Generics are usually zero-cost, but how depends on T. For value types the JIT generates separate, fully specialised code, so there is no boxing and typeof(T) == typeof(int) folds to a constant. For reference types all instantiations share one canonical body, which looks up type-specific data such as statics and typeof(T) through a runtime dictionary. new T() and generic virtual methods take slower paths. The cost of specialisation is code size and JIT time, which matters for Native AOT, where every instantiation must be known at build time.

  2. When: Candidate mentions the call counting stub Why: Checks tiered compilation
    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.

  3. When: Candidate mentions MethodTables and MethodDescs Why: Checks object and type layout knowledge
    How to answer

    Every heap object starts with a pointer to its MethodTable (the runtime's type descriptor), preceded by an 8-byte object header on 64-bit, followed by the fields. The MethodTable holds the base size, flags, parent type, the interface map and the vtable slots used for virtual dispatch; rarely used data lives in a separate EEClass. The type object is the managed System.RuntimeType instance you get from typeof(T) or GetType(); it is a normal heap object that points to the MethodTable (RuntimeTypeHandle.Value). On 64-bit the smallest object is 24 bytes. You can inspect all of this with dotnet-dump and SOS (dumpobj, dumpmt).

🛠 Real-world problems: how you handle them

Startup JIT time inflated by many value-type generic instantiations
Situation
A service with a generic repository over [about 200] entity types had slow startup.
Signals
`JitInfo.GetCompiledMethodCount()` reported [tens of thousands] of methods at startup.
Investigation
`DOTNET_JitDisasmSummary` showed separate instantiations per struct id type in `` `Repository<TEntity, TId>` ``.
Resolution
Standardised on a small set of id types and moved shared logic into non-generic helpers.
Prevention
Tracked JIT method count at startup as a metric in CI performance tests.

How to tell it (STAR)

Our [service] started slowly. I found [N] distinct struct id types multiplying generic instantiations. Consolidating them and moving logic to non-generic helpers cut jitted methods by [X%] and startup by [Y].

More in Runtime Execution

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me