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

📚 Interview question

How do generics perform in .NET? Explain how the JIT shares code for reference-type instantiations and specialises it for value types, what that means for typeof(T) checks, new T(), generic virtual methods and generic static caches, and how generics affect Native AOT size.

Generic performance internals: canonical shared code (__Canon), value-type specialisation, runtime generic dictionaries, constant folding of typeof(T), boxing avoidance and AOT code size.

Senior Theory Expert csharp generics jit native-aot performance

🎯 Key points

  • Value-type instantiations get their own specialised native code; reference-type instantiations share one canonical body (`__Canon`)
  • Shared code finds type-specific data (typeof(T), statics, casts) through a runtime generic dictionary lookup
  • In specialised value-type code, `typeof(T) == typeof(int)` is a JIT-time constant and dead branches are removed
  • Generics remove boxing for value types; constrained calls let the JIT call struct methods directly and inline them
  • `new T()` and generic virtual methods are slower paths; use factories or non-virtual designs in hot code
  • Generic static class caches give O(1), lock-free per-type lookup
  • Many value-type instantiations increase code size and JIT time; Native AOT must know every instantiation at compile time
  • Measure with BenchmarkDotNet and DisassemblyDiagnoser instead of guessing

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

.NET generics are reified and compiled by the JIT with a hybrid strategy: shared code for reference-type instantiations and specialised code for each value-type instantiation. That design explains most generic performance characteristics — why List<int> is fast, why typeof(T) checks can be free or not, why new T() and generic virtual methods have costs, how generic static caches work, and why heavy generic use can grow NativeAOT binaries.

1. Shared code for reference types

For List<string>, List<Customer>, List<Order>, the JIT compiles each method once using a canonical placeholder (__Canon). Since all references are pointer-sized and handled identically by the GC, the same machine code works. Type-specific information (the exact T for casts, typeof(T), static fields, new T[]) is obtained at run time via a generic dictionary — an extra hidden argument or a lookup through the object's MethodTable.

Benefit: less code, less JIT time, smaller images. Cost: operations needing exact type info do a dictionary lookup (usually cached and cheap, but not free).

2. Specialised code for value types

For List<int>, List<double>, List<Guid>, the JIT generates separate code per value type, with T known exactly:

  • no boxing,
  • direct calls (and inlining) for interface-constrained methods (T.CompareTo, INumber<T> operators),
  • correct sizes and layout for elements,
  • vectorisation opportunities.

This is why Dictionary<int, X> lookups, Sum<T> over int with generic math, and sorting Span<int> compile to the same tight code as hand-written specialised versions.

3. typeof(T) checks

static int Size<T>()
{
    if (typeof(T) == typeof(int)) return 4;
    if (typeof(T) == typeof(long)) return 8;
    return -1;
}
  • For value-type instantiations, typeof(T) == typeof(int) is a JIT-time constant, so all but one branch is eliminated — zero cost. The BCL uses this pattern heavily (e.g. in Span helpers, Vector<T>).
  • For reference-type instantiations (shared code), the check needs the generic dictionary at run time — cheap, but a real comparison, and branches remain.
  • typeof(T).IsValueType, RuntimeHelpers.IsReferenceOrContainsReferences<T>() and default(T) == null are similarly folded for value types.

4. new T()

With the new() constraint, new T() historically compiled to Activator.CreateInstance<T>() (reflection). Modern runtimes optimise it substantially — for value types it's just default(T) (unless there's an explicit parameterless constructor), and for reference types the runtime uses cached allocators — but a factory delegate (Func<T>) or a static abstract factory (static abstract T Create() on an interface) is still the fastest and most flexible approach in hot paths.

5. Generic virtual methods (GVMs)

virtual T Get<T>() or interface methods with their own type parameters need runtime lookup of the right instantiation for the receiver's type on each call (a hash-table lookup with caching). They're noticeably slower than ordinary virtual calls and can't be devirtualised as easily. Avoid GVMs on hot paths; prefer generic interfaces/classes (IHandler<T>) where the type parameter is fixed per object.

6. Generic static caches

internal static class Cache<T> { public static readonly Func<T, string> Formatter = BuildFormatter(); }

Each closed type has its own statics, initialised once. Accessing Cache<int>.Formatter in specialised code is a direct static load; in shared code it's a lookup via the generic dictionary. This pattern (used by EqualityComparer<T>.Default, Array.Empty<T>()) is faster than a Dictionary<Type, X> lookup and is thread-safe through static initialisation.

7. Native AOT size

NativeAOT must generate code ahead of time for every instantiation that might be used:

  • each value-type instantiation gets its own native code → many value types × many generic methods = code bloat;
  • generic code over __Canon is shared for references;
  • MakeGenericType/MakeGenericMethod at run time with value types not seen at compile time fails (or needs annotations);
  • deep generic recursion (Foo<List<List<T>>>) can explode instantiations.

Tools: the AOT compiler's size reports (IlcGenerateMstatFile, sizoscope), trimming analysis warnings (IL2xxx/IL3050), and reducing gratuitous value-type instantiations in libraries.

8. A production example

A serialisation library used a generic virtual method Write<T>(T value) on an abstract writer, called for every field. Profiling showed significant time in GVM resolution. Restructuring to cached IFieldWriter<T> instances (resolved once per type via a static generic cache) turned per-call lookups into ordinary interface calls, and on value types the specialised code inlined the writes — throughput improved by roughly a third.

🔍 What the interviewer is checking

Separates people who use generics from people who understand the runtime.

Green flags: canonical shared code versus specialisation; generic dictionary lookups; constant folding of typeof(T); new T() and GVM costs; AOT implications; measures with BenchmarkDotNet.

Red flags: "generics are compiled away like C++ templates"; "generics always box"; no idea how reference-type code is shared.

📚 Learn in depth

1. Canonical forms and generic dictionaries

When compiling List<__Canon>.Add, the JIT emits code that needs type info for:

  • allocating T[] when growing (new T[]),
  • type checks (is T, casts),
  • statics (EqualityComparer<T>.Default).

It gets them from the generic dictionary of the closed type (List<string>), reached through the this object's MethodTable (for instance methods on generic classes) or a hidden instantiation argument (for generic methods and static methods). Dictionary slots are filled lazily and cached.

2. Value types inside reference generics

Dictionary<string, int>: TKey is a reference type, TValue is a value type — the instantiation counts as needing specialisation because int is a value type; the canonical form is Dictionary<__Canon, int>, shared across all reference-type keys with int values.

3. Constrained calls

For where T : IComparable<T>, the IL uses constrained. !!T callvirt IComparable<T>::CompareTo. In specialised (value-type) code, the JIT resolves it to a direct call on the struct (no boxing, inlinable). In shared code, it resolves through the dictionary to an interface call. That's why generic algorithms over value types match hand-written performance.

4. Static abstract members

Generic math calls like T.Zero or a + b on T : INumber<T> are resolved per instantiation; in value-type specialised code they inline completely. In shared reference-type code they'd require dictionary lookups, but numeric types are almost always value types.

5. Tiered compilation and PGO

Shared generic code benefits from dynamic PGO (guarded devirtualisation of interface calls on the observed T). ReadyToRun images may not contain all generic instantiations (especially those combining types from different assemblies), so some generic code is JIT-compiled at start-up — a cold-start consideration.

6. AOT specifics

  • The AOT compiler builds a whole-program view and generates all reachable instantiations.
  • [DynamicallyAccessedMembers] and RequiresDynamicCode annotations flag APIs that can't be AOT-safe.
  • Libraries designed for AOT avoid MakeGenericType and reflection over generics; source generators replace them.

7. Measuring

  • BenchmarkDotNet comparing int vs object instantiations, and with [DisassemblyDiagnoser] to see specialised code.
  • DOTNET_JitStdOutFile + DOTNET_JitDisasmSummary to list compiled instantiations.
  • NativeAOT .mstat files to analyse size by generic instantiation.

Try it yourself

  1. Inspect disassembly for Size<int>() and Size<string>() from the example above.
  2. Benchmark a generic virtual method vs a generic interface call per type.
  3. Benchmark new T() vs Func<T> vs static abstract factory.
  4. Publish a small app with NativeAOT using many value-type instantiations and inspect binary size.

💻 Code

using System.Buffers;
using System.Numerics;
using System.Text;

namespace Generics.Performance;

public static class Numeric
{
    // Specialised per value type: no boxing, typeof checks fold away
    public static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
    {
        var total = T.Zero;
        foreach (var value in values) total += value;
        return total;
    }

    public static string Describe<T>()
    {
        if (typeof(T) == typeof(int)) return "32-bit integer"; // constant for T = int
        if (typeof(T) == typeof(double)) return "double";
        return typeof(T).Name;
    }
}

// Generic static cache: one formatter per T, built once, no locks
public static class Utf8Formatter<T> where T : IUtf8SpanFormattable
{
    public static readonly Action<T, IBufferWriter<byte>> Write = static (value, writer) =>
    {
        var span = writer.GetSpan(64);
        if (value.TryFormat(span, out var written, default, null)) writer.Advance(written);
    };
}

public static class Metrics
{
    public static void Record<T>(ArrayBufferWriter<byte> buffer, T value) where T : IUtf8SpanFormattable
        => Utf8Formatter<T>.Write(value, buffer); // no boxing
}

public static class Program
{
    public static void Main()
    {
        Console.WriteLine(Numeric.Sum<int>([1, 2, 3]));
        Console.WriteLine(Numeric.Sum<double>([1.5, 2.5]));
        Console.WriteLine(Numeric.Describe<int>());
        Console.WriteLine(Numeric.Describe<string>());

        var buffer = new ArrayBufferWriter<byte>();
        Metrics.Record(buffer, 42);
        Metrics.Record(buffer, 3.14);
        Console.WriteLine(Encoding.UTF8.GetString(buffer.WrittenSpan));
    }
}

⚠ Common mistakes

1. Assuming generics always cost nothing

What happens: heavy generic virtual methods and runtime type lookups in hot paths. Why it is wrong: GVMs and dictionary lookups add overhead. Do instead: use generic types with fixed type parameters and static caches.

2. Using Dictionary<Type, X> instead of generic static caches

What happens: per-type metadata looked up by typeof(T) in a dictionary. Why it is wrong: hashing and locking on every call. Do instead: use static class Cache<T> with static readonly fields.

3. new T() in hot loops

What happens: generic factories call new T() millions of times. Why it is wrong: slower than direct construction for reference types. Do instead: use factory delegates or static abstract factories.

4. Runtime type checks instead of constraints

What happens: if (value is IFoo f) in generic code. Why it is wrong: boxing for value types and runtime branching. Do instead: constrain T.

5. Ignoring AOT implications

What happens: libraries use MakeGenericType with value types. Why it is wrong: fails or requires warnings under NativeAOT. Do instead: design for known instantiations or use source generators.

6. Explosive value-type instantiations

What happens: generic helpers instantiated with dozens of struct types. Why it is wrong: code size and JIT time grow; AOT binaries bloat. Do instead: limit gratuitous generic specialisation in libraries; share code where possible.

7. Expecting typeof(T) branches to fold for reference types

What happens: relying on branch elimination for string/class instantiations. Why it is wrong: shared code keeps the branches. Do instead: accept the small runtime check or use specialised overloads.

8. Micro-optimising without measurement

What happens: restructuring generic code based on assumptions. Why it is wrong: the JIT often optimises better than expected. Do instead: benchmark and inspect disassembly.

🏛 Senior insight: trade-offs

The senior message is that generics are a zero-cost abstraction for value types and a near-zero-cost one for reference types, with a few known slow paths. I can explain the canonical-code model, why typeof checks fold only for structs, and when to reach for a static generic cache. I also weigh code size: in AOT or serverless start-up scenarios, dozens of struct instantiations of a large generic type have a measurable cost. And I always back claims with BenchmarkDotNet and disassembly rather than folklore.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions no boxing for List<int>. Why: Boxing avoidance is the main performance win of generics for value types.
    How to answer

    Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.

  2. When: Candidate mentions constrained calls. Why: Constraints decide whether calls on T are constrained, direct and inlinable.
    How to answer

    Constraints tell the compiler what T can do so you can use it safely. C# has class and class? for reference types, struct for non-nullable value types, unmanaged for blittable structs, notnull, new() for a public parameterless constructor, base-class and interface constraints, where T : U, and Enum and Delegate constraints. C# 13 added the allows ref struct anti-constraint so T can be a Span<T>-like type. Interface constraints on struct T also avoid boxing because the JIT emits constrained calls.

  3. When: Candidate mentions generic virtual methods are slow. Why: Generic virtual methods and devirtualisation are closely related JIT topics.
    How to answer

    A virtual member can be overridden by a derived class, and a call to it is resolved by the run-time type of the object, not the variable's type. In C#, members are non-virtual by default (unlike Java), because every virtual member is a promise to subclasses and is harder to change. At run time the call loads the object's method table, reads the method's slot and calls through it, which prevents inlining unless the JIT can devirtualise (exact type known, sealed class or method, or dynamic PGO's guarded devirtualisation). Methods, properties, indexers and events can be virtual; static (outside interfaces), private and constructors cannot.

🛠 Real-world problems: how you handle them

Metrics library boxed every recorded value
Situation
An internal metrics library was called [number] times per second in a trading API. Its `Record<T>(string name, T value)` method converted value to object to format it.
Signals
Allocation profile dominated by boxed Int64 and Double objects from the metrics namespace; Gen 0 collections every few hundred milliseconds.
Investigation
Used dotnet-trace with the gc-verbose profile and PerfView allocation stacks, then BenchmarkDotNet with MemoryDiagnoser on Record<long> to confirm 24 bytes allocated per call.
Resolution
Constrained T to IUtf8SpanFormattable and dispatched through a generic static cache of formatters, so formatting happened on the specialised value-type path with no boxing.
Prevention
Added an allocation regression test (BenchmarkDotNet in CI with a threshold) and documented the pattern in the library guidelines.

How to tell it (STAR)

**S:** Our metrics library was a top allocator in a latency-sensitive API. **T:** Remove the allocations without changing every caller. **A:** I found it boxed generic values to format them, rewrote the formatting with an interface constraint and a generic static cache, and proved zero allocations with BenchmarkDotNet. **R:** Gen 0 GCs fell by [percentage] and p99 latency improved by [amount].

↩ Usually asked after

More in Generics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me