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

📚 Interview question

Which optimisations does the .NET JIT perform, and how do you write code that lets it optimise well?

Inlining, constant folding, bounds-check elimination, devirtualisation, loop optimisations, struct promotion and escape analysis, and how to verify them.

Senior Theory Expert bounds-checks inlining jit optimisation performance

🎯 Key points

  • The JIT inlines, folds constants, removes bounds checks, devirtualises, optimises loops and promotes structs
  • static readonly values are treated as constants at Tier 1
  • Loops to span.Length and slicing let the JIT remove bounds checks
  • sealed types and generic struct constraints enable direct, inlinable calls
  • Throw helpers keep hot methods small enough to inline
  • Recent releases stack-allocate some non-escaping objects; do not depend on it
  • Verify with BenchmarkDotNet and JIT disassembly

⏱ 30-second answer

The JIT inlines small methods, folds constants (including static readonly values at Tier 1), removes bounds checks it can prove safe, devirtualises calls on sealed or known types (and guards the common type with PGO), hoists invariant code out of loops, clones loops to have a check-free fast path, promotes struct fields into registers, uses SIMD hardware intrinsics, and in recent releases stack-allocates some objects that never escape the method. You help it by keeping hot methods small, using spans and loops to Length, sealing classes, avoiding needless interfaces in hot paths and measuring with BenchmarkDotNet and JIT disassembly.

🗣 Full answer (senior)

How I would answer this in an interview

The RyuJIT compiler performs the classic compiler optimisations — inlining, constant folding, dead code elimination, CSE, loop optimisations, register allocation — plus .NET-specific ones like bounds-check elimination, devirtualisation, struct promotion, escape analysis and PGO-guided transformations. The practical skill is writing code that doesn't block these optimisations and verifying with disassembly and benchmarks.

1. The key optimisations

Optimisation What it does Typical blockers
Inlining Copies small callees into callers, enabling everything else Large methods, exception handling in callee, virtual calls without devirtualisation, NoInlining, recursion
Constant folding & propagation Computes constant expressions at compile time (including static readonly at Tier 1, typeof(T) == typeof(int) for value types, Vector.IsHardwareAccelerated) Values not provably constant
Dead code elimination Removes unreachable branches Side effects
CSE (common subexpression elimination) Reuses computed values Aliasing (writes through references)
Bounds-check elimination Removes array/span range checks when provably safe Non-canonical loops, unknown relationships between index and length
Loop optimisations Hoisting invariants, loop cloning (fast path without checks), alignment, unrolling in some cases Complex loop shapes, calls inside loops
Devirtualisation Turns virtual/interface calls into direct calls (sealed types, exact types, PGO guards) Unsealed types, megamorphic sites
Struct promotion Keeps struct fields in registers instead of memory Address-taken structs (ref, in, &), large structs
Escape analysis / object stack allocation Allocates non-escaping small objects on the stack (expanding in .NET 9/10) Objects that escape (stored, returned, captured)
Boxing removal Eliminates box followed by patterns like isinst/unbox Boxes that escape
Hardware intrinsics / vectorisation Uses SIMD for Vector128/256/512<T> and BCL span helpers Not auto-vectorising arbitrary loops (limited)
PGO-driven layout & inlining Hot/cold block layout, guarded devirtualisation Insufficient profile data

2. Writing code the JIT optimises well

Help inlining:

public static decimal Gst(decimal amount)
{
    if (amount < 0) ThrowNegative(amount);        // throw helper keeps Gst small and inlinable
    return amount * 0.1m;
}
[DoesNotReturn] private static void ThrowNegative(decimal a) => throw new ArgumentOutOfRangeException(nameof(a), a, null);

Canonical loops for bounds-check elimination:

int Sum(ReadOnlySpan<int> values)
{
    int sum = 0;
    for (int i = 0; i < values.Length; i++) sum += values[i];   // no bounds check
    return sum;
}
// or foreach over the span

Indexing with unrelated bounds (for (i = 0; i < count; i++) arr[i] where count isn't arr.Length) keeps checks; slicing first (var s = arr.AsSpan(0, count)) lets the JIT prove safety.

Enable devirtualisation: seal classes, use generics with struct constraints, keep hot call sites monomorphic.

Avoid unnecessary address-taking of structs (ref/in) for small structs — it prevents struct promotion.

Use static readonly for configuration checked in hot paths — Tier 1 folds them.

Prefer spans and BCL helpers (IndexOf, SequenceEqual, Sum on spans) — already vectorised.

Avoid exceptions for control flow and try/catch in tiny hot methods (EH regions limit optimisations; recent JITs handle more cases).

Keep hot loops free of allocations and virtual calls; hoist invariant work.

3. Verifying

  • BenchmarkDotNet with [DisassemblyDiagnoser] to see what the JIT produced and measure impact.
  • DOTNET_JitDisasm for specific methods (Release, Tier 1).
  • Look for: call instructions you expected to be inlined, CORINFO_HELP_RNGCHKFAIL (bounds-check failure helper) in loops, CORINFO_HELP_NEWSFAST (allocations), indirect calls.

4. What not to do

  • Don't hand-optimise everything — most code isn't hot.
  • Don't sprinkle AggressiveInlining; fix structure instead.
  • Don't use unsafe to remove bounds checks before trying canonical span loops.
  • Don't trust micro-benchmarks without realistic data sizes.

5. A production example

A CSV parser's inner loop processed bytes with for (int i = start; i < end; i++) if (buffer[i] == ',') ... on a byte[]. Disassembly showed bounds checks and no vectorisation. Rewriting to buffer.AsSpan(start, end - start).IndexOfAny(SearchValues) used vectorised search; a remaining helper was un-inlined because it threw directly — moved to a throw helper. Parsing throughput rose ~3×, verified with BenchmarkDotNet and disassembly.

🔍 What the interviewer is checking

The interviewer checks for real performance engineering skills.

Green flags: names several optimisations correctly, explains what blocks them, sealed classes and generic struct specialisation, throw helpers, verifies with disassembly and benchmarks, cautious about escape analysis.

Red flags: micro-optimises without measuring; overuses AggressiveInlining; thinks Roslyn performs these optimisations.

📚 Learn in depth

1. Inlining heuristics

The JIT's inliner scores candidates: IL size (always inline tiny methods, ~16 bytes IL), constant arguments (which can fold branches), struct arguments, loops in callee (penalised), call-site frequency (PGO). Budget limits stop runaway inlining. AggressiveInlining bypasses some size limits. EH in callees was historically a blocker; .NET 9/10 relaxed some cases.

2. Bounds-check elimination patterns

Eliminated:

for (int i = 0; i < arr.Length; i++) _ = arr[i];
foreach (var x in span) { }
if ((uint)index < (uint)arr.Length) _ = arr[index];   // explicit check lets JIT remove the implicit one

Not eliminated:

for (int i = 0; i < n; i++) _ = arr[i];         // n unrelated to arr.Length
for (int i = 0; i < arr.Length; i++) _ = arr[i + 1];

Loop cloning can create a fast path without checks plus a slow path when conditions can be checked upfront.

3. Object stack allocation

.NET 9/10 expanded escape analysis so small objects (e.g. boxes, small arrays, some delegates/closures in later versions) that don't escape a method can be allocated on the stack, removing GC work. It's conservative: any escape (storing into a field, returning, passing to non-inlined calls) prevents it.

4. Vectorisation

RyuJIT does limited auto-vectorisation; the BCL uses explicit intrinsics in span methods. For custom SIMD, use Vector128<T>/Vector256<T>/Vector512<T> with IsHardwareAccelerated checks, or TensorPrimitives for numeric operations.

5. Tier differences

Only Tier 1 (and OSR/R2R to a lesser extent) applies the full optimisation set. Tier 0 code is unoptimised. Always analyse Tier 1 output.

Try it yourself

  1. Compare disassembly of two loops: one canonical, one with an unrelated bound.
  2. Add throw to a small method and inspect inlining with and without a throw helper.
  3. Benchmark sealed vs unsealed virtual calls.
  4. Replace a manual byte search with IndexOfAny(SearchValues) and benchmark.

💻 Code

using System.Diagnostics.CodeAnalysis;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<HashBench>();

public interface IHasher { uint Mix(uint h, byte b); }

public readonly struct Fnv : IHasher
{
    public uint Mix(uint h, byte b) => (h ^ b) * 16777619u;
}

[MemoryDiagnoser, DisassemblyDiagnoser(maxDepth: 2)]
public class HashBench
{
    private readonly byte[] _data = Enumerable.Range(0, 4096).Select(i => (byte)i).ToArray();
    private readonly IHasher _boxed = new Fnv();

    [Benchmark(Baseline = true)]
    public uint ViaInterface()
    {
        uint h = 2166136261u;
        foreach (byte b in _data) h = _boxed.Mix(h, b);    // interface call per byte
        return h;
    }

    [Benchmark]
    public uint ViaGenericStruct() => Hash<Fnv>(_data);

    private static uint Hash<T>(ReadOnlySpan<byte> data) where T : struct, IHasher
    {
        T hasher = default;
        uint h = 2166136261u;
        for (int i = 0; i < data.Length; i++)               // bounds check removed
            h = hasher.Mix(h, data[i]);                     // direct call, inlined
        return h;
    }
}

public static class Guard
{
    // Throw helper keeps callers small enough to inline
    [DoesNotReturn]
    public static void ThrowNegative(string name) => throw new ArgumentOutOfRangeException(name);
}

⚠ Common mistakes

1. Optimising cold code

What happens: micro-optimising rarely executed paths. Why it is wrong: no measurable gain; harder code. Do instead: profile first.

2. Throwing in tiny hot helpers

What happens: throw statements inside small methods. Why it is wrong: can prevent inlining. Do instead: throw helpers.

3. Non-canonical loops

What happens: loops with unrelated bounds over arrays/spans. Why it is wrong: bounds checks remain. Do instead: slice spans first or iterate to Length.

4. Overusing AggressiveInlining

What happens: attribute on many methods. Why it is wrong: code bloat, worse I-cache behaviour. Do instead: restructure and measure.

5. Address-taking small structs

What happens: passing small structs by ref/in unnecessarily. Why it is wrong: prevents struct promotion. Do instead: pass by value.

6. Using unsafe code to remove checks prematurely

What happens: pointers in loops for speed. Why it is wrong: risk without benefit; spans often match. Do instead: canonical span loops and BCL helpers.

7. Analysing Tier 0 code

What happens: reading disassembly from first calls. Why it is wrong: not optimised. Do instead: confirm Tier 1.

8. Ignoring BCL vectorised helpers

What happens: hand-written searches and comparisons. Why it is wrong: slower than vectorised implementations. Do instead: use IndexOf, SequenceEqual, SearchValues.

🏛 Senior insight: trade-offs

Most code does not need JIT-level tuning; the skill is knowing which code does.

Situation Worth tuning for the JIT?
Business logic per request No; clarity first
Parsers, serialisers, hashing, maths in tight loops Yes
Library code used by thousands of callers Often
One-off scripts No

The senior approach: profile first, find the hot loop, benchmark with disassembly, change one thing, and keep the benchmark in the repository.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions guarded devirtualisation Why: Checks Dynamic PGO depth
    How to answer

    Dynamic PGO (profile-guided optimisation), on by default since .NET 8, lets the JIT optimise using data gathered from your own process. Hot methods first run in an instrumented tier that records which concrete types reach virtual and interface call sites, which delegates are invoked and which branches are taken. Tier 1 then uses that profile: guarded devirtualisation inserts a cheap type check for the dominant type and inlines its method, falling back to a normal virtual call otherwise; it also lays out hot blocks together and makes better inlining decisions. It is enabled with <TieredPGO> (default true) and depends on tiered compilation.

  2. When: Candidate says sealed enables devirtualisation Why: Checks how virtual dispatch works
    How to answer

    Each MethodTable has vtable slots for its virtual methods. A derived class copies its parent's slots and replaces the ones it overrides; new methods get a new slot instead. A virtual call loads the object's MethodTable pointer, loads the slot (through a chunk indirection) and makes an indirect call: a few dependent loads and an indirect branch. That is cheap, but it prevents inlining, which is usually the bigger cost. The JIT turns virtual calls into direct calls when it knows the exact type: sealed classes or methods, a just-created object, or guarded devirtualisation from Dynamic PGO.

  3. When: Candidate uses spans to remove bounds checks Why: Probes Span usage and limits
    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

Interface dispatch in a hashing loop removed with generic struct specialisation
Situation
A deduplication service hashed [gigabytes of data per hour] through an `IHasher` abstraction.
Signals
CPU profile showed the hasher's interface calls as the top cost.
Investigation
BenchmarkDotNet disassembly showed an indirect call per byte with no inlining.
Resolution
Kept the interface for configuration but passed implementations as generic struct arguments in the hot loop, allowing devirtualisation and inlining.
Prevention
Kept the benchmark with disassembly in the repo and reviewed it on each runtime upgrade.

How to tell it (STAR)

In [our deduplication service], hashing was the top CPU cost. The disassembly showed an interface call per byte. I changed the hot loop to take the hasher as a generic struct, so the JIT inlined it, and throughput improved [about 3x] in the benchmark and [X%] in production.

More in Runtime Execution

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me