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

📚 Interview question

Walk through the fetch-decode-execute instruction cycle. How does it apply to the machine code the .NET JIT generates?

The instruction cycle (fetch, decode, execute, memory access, write-back), the instruction pointer, how jumps, calls and returns change the flow, and how to read a few lines of JIT-generated x64 or Arm64 assembly.

Junior Theory Intermediate assembly cpu fetch-decode-execute instruction-cycle jit

🎯 Key points

  • The instruction pointer (RIP/PC) holds the address of the next instruction
  • Fetch reads instruction bytes via the L1 instruction cache and advances the pointer; a cache miss here stalls the front end
  • Decode works out the operation and operands; x64 variable-length instructions become micro-operations, Arm64 uses fixed 4-byte instructions
  • Execute runs the operation on an execution unit; memory access goes through the L1 data cache, and write-back updates registers and flags
  • Jumps, calls and returns change the instruction pointer, which is how if, loops and method calls work; calls save the return address on the stack (x64) or in the link register (Arm64)
  • Real CPUs pipeline the stages and are superscalar and out-of-order: they run several instructions per cycle in whatever order dependencies allow, then retire them in program order
  • The JIT turns IL into native code in executable memory (Tier 0, then optimised Tier 1 with PGO); DOTNET_JitDisasm or BenchmarkDotNet's DisassemblyDiagnoser shows it
  • Reading JIT output lets you verify inlining, bounds-check elimination and vectorisation instead of guessing

⏱ 30-second answer

The CPU repeats one logical loop billions of times a second. Fetch: read the instruction at the address in the instruction pointer (RIP on x64, PC on Arm64) from the L1 instruction cache and move the pointer on. Decode: work out the operation and operands (x64 instructions become micro-operations). Execute: run it on the ALU or another unit, then access memory and write back the result. Jumps, calls and returns load a new value into the instruction pointer, which is how if, loops and method calls work.

The .NET JIT compiles IL into optimised machine code (tiered: quick Tier 0 first, then optimised Tier 1 with dynamic PGO for hot methods) and writes it into executable memory. The CPU then runs it exactly like native code. Real cores pipeline these stages and execute many instructions out of order in parallel, while still retiring results in program order so the code behaves as if it ran one instruction at a time.

🗣 Full answer (senior)

The cycle

Stage What happens Example: add eax, edx
Fetch Read instruction bytes at the instruction pointer from the L1 instruction cache; advance the pointer Bytes 01 D0 are read
Decode Identify the operation and operands; on x64, translate into micro-operations "Integer add, sources EAX and EDX, destination EAX"
Execute Perform the operation on an execution unit ALU adds the values
Memory access Load or store, if the instruction touches memory Not needed here
Write-back Write the result to the register file; update flags EAX gets the sum; zero and carry flags updated

Control flow is just changing the instruction pointer

  • Conditional jump (jne, jg, b.ne on Arm64): if the flags match, load the target address into the instruction pointer; otherwise carry on.
  • Call: push the return address on the stack (x64) or put it in the link register (Arm64), then jump to the method.
  • Return: pop the return address back into the instruction pointer.
  • A for loop is a compare, a conditional jump backwards, and the body.

Where .NET fits

  • The JIT turns IL into machine code and writes it into memory pages marked executable. The method's entry stub is updated to point at that code.
  • From then on, the CPU fetches and executes those bytes exactly like code from a native .exe.
  • Tools to see it: DOTNET_JitDisasm=MethodName (.NET 7+), sharplab.io, the Disassembly window in Visual Studio, and BenchmarkDotNet's [DisassemblyDiagnoser].

Reading a small example

For static int Max(int a, int b) => a > b ? a : b; optimised x64 code is typically:

cmp   ecx, edx        ; compare a and b, set flags
mov   eax, edx        ; assume b
cmovg eax, ecx        ; if a > b, take a (conditional move, no branch)
ret                   ; return value in EAX

Four instructions, no memory access, no branch. Recognising patterns like this lets you check whether the JIT did what you hoped.

Production example

A hot parsing loop was slower than expected. The JIT disassembly showed a bounds check (cmp + jae to a throw helper) on every array access, because the loop indexed a different array from the one in its condition. Rewriting the loop over a Span<T> with for (var i = 0; i < span.Length; i++) let the JIT eliminate the bounds checks, and throughput improved by about 15%.

🔍 What the interviewer is checking

Checks fundamental understanding of how instructions execute and whether the candidate can connect it to .NET.

Green flags: explains each stage, the instruction pointer, how branches and calls work, knows JIT output is native code, can read a simple disassembly or knows how to get one.

Red flags: can't describe the cycle; thinks the CPU runs IL; no idea how a method call works at the machine level.

📚 Learn in depth

1. The cycle as a loop

      +--------------------------------------------------------+
      |                                                        |
      v                                                        |
  FETCH  bytes at [IP] from L1i ; IP += length                 |
      |                                                        |
  DECODE  opcode + operands -> micro-ops                       |
      |                                                        |
  EXECUTE  ALU / FPU / SIMD / branch unit                      |
      |                                                        |
  MEMORY  load or store via L1d (if needed)                    |
      |                                                        |
  WRITE-BACK  registers, flags ; branch? IP = target  ---------+

2. x64 versus Arm64 instructions

x64 Arm64
Instruction length Variable, 1–15 bytes Fixed, 4 bytes
Decode Complex; uses a micro-op cache Simpler, easier to decode many per cycle
Memory operands Many instructions can read memory directly (add eax, [rcx]) Load/store architecture: only loads and stores touch memory
Return address Pushed on the stack by call Stored in the link register (X30) by bl

3. A method call, step by step (x64, Windows)

caller:  mov ecx, 5          ; first int argument in ECX
         mov edx, 7          ; second in EDX
         call Max            ; push return address, IP = Max
Max:     cmp ecx, edx
         mov eax, edx
         cmovg eax, ecx
         ret                 ; pop return address into IP
caller:  ...                 ; result in EAX

Inlining removes the call and ret entirely, which is why small, hot methods benefit so much from it.

4. Things you'll see in JIT output

Pattern Meaning
cmp + jae to CORINFO_HELP_RNGCHKFAIL Array or span bounds check
call CORINFO_HELP_NEWSFAST (or similar) Object allocation
test rcx, rcx + jump to a throw helper Null check
vmovups ymm0, ... 256-bit SIMD load
Prolog push rbp; sub rsp, ... Stack frame set-up for locals and spills
; Tier0 or ; Tier1 header Which tier produced this code

5. How this relates to the real hardware

The five stages are the logical model. Real cores pipeline them and run many instructions at once, out of order, while preserving the illusion that each instruction completed in sequence (see the pipelining question). Interrupts and exceptions (like a page fault or a divide by zero) stop the cycle, save state, and jump to an operating system handler.

6. Try it yourself

  1. Write Max, Sum(int[] values) and Sum(ReadOnlySpan<int> values). Run with DOTNET_JitDisasm="Max Sum" in Release and find the compare, the conditional move and any bounds checks.
  2. Add [MethodImpl(MethodImplOptions.NoInlining)] to Max, call it in a loop, and compare the disassembly of the caller with and without the attribute.
  3. In Visual Studio, set a breakpoint, open Debug → Windows → Disassembly and step one instruction at a time while watching the Registers window.

💻 Code

namespace InstructionCycle;

using System.Runtime.CompilerServices;

public static class Program
{
    public static void Main()
    {
        int[] data = Enumerable.Range(0, 1_000).ToArray();
        long total = 0;
        for (var i = 0; i < 10_000; i++)
        {
            total += Sum(data);
        }
        Console.WriteLine(total);
        Console.WriteLine(Max(5, 7));
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    public static int Max(int a, int b) => a > b ? a : b;

    // Looping to span.Length lets the JIT prove i is in range and drop bounds checks.
    [MethodImpl(MethodImplOptions.NoInlining)]
    public static long Sum(ReadOnlySpan<int> values)
    {
        long sum = 0;
        for (var i = 0; i < values.Length; i++)
        {
            sum += values[i];
        }
        return sum;
    }
}
# Print the machine code for both methods (look for the Tier1 versions)
DOTNET_JitDisasm="Max Sum" dotnet run -c Release

⚠ Common mistakes

  • Mistake: thinking the CPU executes IL or C#. It only fetches and decodes machine instructions; IL is compiled away before execution. Instead: explain that the JIT emits machine code into executable memory and the CPU runs that.

  • Mistake: describing the cycle as strictly one instruction at a time. Modern cores overlap fetch, decode and execute for many instructions and execute them out of order. Instead: present fetch-decode-execute as the logical model and mention pipelining and out-of-order execution.

  • Mistake: not knowing how branches and calls work. Without that, stack traces, stack overflows and inlining are mysterious. Instead: explain that jumps, calls and returns load a new instruction pointer, and calls save the return address on the stack (or in a link register).

  • Mistake: reading Tier 0 disassembly and drawing conclusions. Tier 0 code is deliberately unoptimised, so it looks slow and full of memory traffic. Instead: look at the Tier 1 output (call the method enough times, or use BenchmarkDotNet's disassembler).

  • Mistake: assuming every array access costs a bounds check. The JIT removes checks it can prove are unnecessary, for example when the loop condition uses the same span's length. Instead: write loops the JIT can reason about, and verify in the disassembly rather than reaching for unsafe code.

🏛 Senior insight: trade-offs

You do not need to write assembly, but being able to read ten lines of it is a real senior skill. It settles arguments ("did the JIT inline this?", "is that bounds check gone?", "is this vectorised?") with evidence instead of folklore.

Logical stage What really happens in a modern core What slows it .NET-level fix
Fetch Several instructions fetched per cycle, branch predictor guesses ahead I-cache misses, huge methods Keep hot code small, let the JIT inline hot helpers
Decode Micro-op cache on x64; wide decoders on Arm64 Complex instruction mixes Rarely a concern in managed code
Execute Out-of-order, several units in parallel Long dependency chains, divisions Independent operations, SIMD, avoid needless division
Memory Loads and stores via L1d, store buffers Cache misses (hundreds of cycles) Contiguous data, fewer allocations
Retire In program order, so behaviour looks sequential Branch mispredicts flush work Predictable branches, branch-free patterns

The logical cycle explains correctness (what a jump or call means); the pipelined reality explains performance. A senior candidate can switch between the two models and knows which one a question needs. Security angle: speculative, out-of-order execution is what Spectre-class attacks exploit, which is why runtimes and operating systems add mitigations that can cost performance.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate describes the five stages in sequence. Why: Checks understanding beyond the textbook one-instruction-at-a-time model.
    How to answer

    Pipelining splits instruction processing into stages and overlaps them, like an assembly line: while one instruction executes, the next is decoding and the one after is being fetched. Modern cores are also superscalar (they issue several instructions per cycle to multiple execution units) and out-of-order (they run any instruction whose inputs are ready, using register renaming and a reorder buffer, then retire results in program order). The pipeline stalls when an instruction waits for a previous result (a data dependency), when a branch is mispredicted and work must be thrown away, or when data isn't in cache. In C#, a loop where each iteration depends on the last, like a single running sum, can't use that parallelism; multiple accumulators or SIMD can.

  2. When: Candidate explains conditional jumps changing the instruction pointer. Why: Probes what happens when the CPU must guess the next instruction after a jump.
    How to answer

    A modern CPU doesn't wait to find out which way an if goes: its branch predictor guesses from recent history and the pipeline runs ahead speculatively. When data is sorted, a test like value >= 128 is false for a long run, then true for a long run, so the guess is almost always right. On random data it's right about half the time, and each misprediction throws away 15–20 cycles of work. Fixes: make data predictable (sort or partition), go branchless (conditional moves, masks, Math.Max, SIMD compares), or move rare cases out of the hot path. In .NET, check the disassembly because the JIT sometimes emits a conditional move already, and Dynamic PGO helps predict virtual calls.

  3. When: Candidate mentions JIT-compiled code in executable memory. Why: Checks the bigger picture of how IL becomes machine code before this cycle runs.
    How to answer

    C# source is not executed as written. Roslyn compiles it to IL (an intermediate, CPU-neutral instruction set) plus metadata in a .dll. When the app starts, the host loads the CLR, which loads assemblies as needed. The first time a method is called, the JIT compiles its IL into machine code for the current CPU (x64 or Arm64). With tiered compilation, it first produces quick, lightly optimised code, then recompiles hot methods with full optimisation, guided by Dynamic PGO profile data. From then on the CPU runs that native code directly, through its pipeline. ReadyToRun or Native AOT can move the compilation to publish time, which helps cold starts but limits runtime code generation.

↩ Usually asked after

More in CPU

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me