Inlining, constant folding, bounds-check elimination, devirtualisation, loop optimisations, struct promotion and escape analysis, and how to verify them.
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.
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.
| 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 |
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.
[DisassemblyDiagnoser] to see what the JIT produced and measure impact.DOTNET_JitDisasm for specific methods (Release, Tier 1).call instructions you expected to be inlined, CORINFO_HELP_RNGCHKFAIL (bounds-check failure helper) in loops, CORINFO_HELP_NEWSFAST (allocations), indirect calls.AggressiveInlining; fix structure instead.unsafe to remove bounds checks before trying canonical span loops.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.
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.
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.
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.
.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.
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.
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.
throw to a small method and inspect inlining with and without a throw helper.IndexOfAny(SearchValues) and benchmark.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);
}
What happens: micro-optimising rarely executed paths. Why it is wrong: no measurable gain; harder code. Do instead: profile first.
What happens: throw statements inside small methods.
Why it is wrong: can prevent inlining.
Do instead: throw helpers.
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.
AggressiveInliningWhat happens: attribute on many methods. Why it is wrong: code bloat, worse I-cache behaviour. Do instead: restructure and measure.
What happens: passing small structs by ref/in unnecessarily.
Why it is wrong: prevents struct promotion.
Do instead: pass by value.
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.
What happens: reading disassembly from first calls. Why it is wrong: not optimised. Do instead: confirm Tier 1.
What happens: hand-written searches and comparisons.
Why it is wrong: slower than vectorised implementations.
Do instead: use IndexOf, SequenceEqual, SearchValues.
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.
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.
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.
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.
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.
Last updated 31 Oct 2026.