The path from IL to native code: method descriptors, precode and prestub, code heap, back-patching, and shared versus specialised generic code.
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.
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.
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
ThePreStub, which identifies the method, checks for R2R code, or invokes the JIT.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.
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,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:
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.
List<int>, Dictionary<Guid, T>) are fast — specialised, no boxing.T (e.g. typeof(T) branches remain).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.
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.
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).
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.
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.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.
DOTNET_JitDisasmSummary=1 and call Max<int>, Max<double>, Max<string>, Max<Customer>; count compiled bodies.Max<int> and Max<string>.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
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.
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.
What happens: virtual T Get<T>() called frequently.
Why it is wrong: expensive runtime resolution.
Do instead: generic interfaces/classes with fixed type parameters.
typeof(T) branches to vanish for classesWhat happens: relying on folding in shared code. Why it is wrong: branches remain for reference types. Do instead: accept the cost or specialise differently.
What happens: timing includes JIT compilation. Why it is wrong: misleading results. Do instead: warm up.
What happens: casting T to interfaces inside generic methods.
Why it is wrong: boxes value types.
Do instead: constraints for constrained calls.
MakeGenericType in AOT appsWhat happens: runtime instantiation with new value types. Why it is wrong: not supported/slow fallbacks. Do instead: known instantiations.
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.
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.
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.
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.
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).
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].
Last updated 31 Oct 2026.