Instruction Fetch: Code Is Data Too
Before a CPU can do anything with an instruction it has to load it from memory, through a cache, at an address it may have had to guess. The front end is a supply chain, and a starved front end leaves the most sophisticated execution engine in the world with nothing to do.
Software view, hardware view
The gap between what you wrote and what the machine does is where this whole domain lives.
The front end is a supply chain
Fetch reads a *block* of bytes — a fixed width per cycle, sized by the microarchitecture — not one instruction. The block is decoded into as many instructions as it contains. This is the machine's supply rate, and it sets a hard ceiling: a core that fetches enough bytes for four instructions per cycle can never sustain more than four instructions per cycle, no matter how many execution units it has.
That block comes from the instruction cache, which is a separate cache from the data cache at the first level on essentially all designs. Separate, but not free: both are backed by the same unified caches further out, so a program with a large data working set can evict the code it is about to run, and vice versa. This is the mechanism behind Your Code Is Data Too pressure being a real and frequently missed cost.
The address for the fetch comes from the program counter — and for anything other than straight-line code, the CPU does not yet know what the next address is. It predicts it, fetches from the predicted address, and continues. That is not an optimisation bolted on the side; it is the only way a pipelined front end can operate at all, which is why Branch Prediction: Guessing Well Enough to Matter belongs in the fetch story rather than as an advanced topic.
Ways the supply breaks down
An instruction cache miss is the expensive one. The core has nothing to execute and must wait for the line to arrive from an outer cache or from memory — the same latency any data miss would pay, but with the entire pipeline idle behind it. Large binaries, deep call chains through cold code, and aggressive inlining that bloats hot loops all push in this direction.
A misprediction is the common one. The predictor guessed wrong, so everything fetched after the branch is discarded and fetch restarts at the correct address. The cost is the time to refill the pipeline, which scales with pipeline depth (Misprediction: What a Wrong Guess Costs).
A fetch bandwidth limit is the subtle one. Even with every access hitting, a fetch block that straddles a cache line boundary or contains a taken branch early may yield only one or two useful instructions instead of a full block. Code laid out so that hot paths are contiguous fetches better than code where the hot path jumps over cold error-handling — which is one concrete reason profile-guided layout produces measurable wins on large binaries.
| Problem | Cause | Signal | What helps |
|---|---|---|---|
| I-cache miss | Hot code too large or scattered | Front-end stalls, instruction-fetch miss counters | Smaller hot path, hot/cold splitting, less aggressive inlining |
| Misprediction | Data-dependent branch the predictor cannot learn | Branch-miss counter, high stalls after branches | Make the branch predictable, or remove it (Branchless Code: A Trade, Not an Upgrade) |
| Fetch bandwidth | Taken branches early in a block; poor layout | Low IPC with few misses of any kind | Profile-guided layout, straight-line hot paths |
Why this is invisible from source
Nothing in a function's text tells you how large its compiled form is, where the compiler placed it, or how far it sits from the function it calls in a loop. Two implementations with identical logic and identical data access can differ substantially in front-end behaviour purely because of code size and layout.
The example below is the shape that catches people: an "optimisation" that adds a rarely-taken special case. The logic is strictly better — the special case is genuinely faster when it hits. But the extra code lands in the middle of the hot loop body, and the loop no longer fits as neatly in the instruction cache. Whether this is a net win depends entirely on the hit rate of the special case and the size of the loop, which is a measurement question, not a reasoning question.
The practical stance: treat code size as a resource that hot loops spend, exactly like registers or cache capacity. It is the one front-end factor an application programmer can influence without leaving the source language, and it is why Your Code Is Data Too and inlining decisions belong together.
1for (i = 0; i < n; i++) {2 if (rare_condition(a[i])) {3 // 200 instructions of specialised handling,4 // taken on ~0.1% of iterations5 handle_special_inline(a[i]);6 } else {7 sum += a[i];8 }9}1for (i = 0; i < n; i++) {2 if (unlikely(rare_condition(a[i]))) {3 handle_special(a[i]); // not inlined; lives elsewhere4 } else {5 sum += a[i];6 }7}Both versions execute the same instructions on the 99.9% path. The difference is that in the first, those 200 rarely-executed instructions occupy cache lines *inside* the loop body, so every iteration fetches around them. Moving the cold path out of line leaves the hot path contiguous. Whether this matters depends on loop size and cache capacity — measure before assuming either direction.
Key points
- Fetching instructions is a memory access: code occupies cache, and code and data compete for it in the shared levels.
- Fetch width sets a hard ceiling on instructions per cycle regardless of how many execution units the core has.
- The fetch address for non-straight-line code is predicted, not known — prediction is intrinsic to fetching, not an optimisation.
- Three distinct front-end problems (i-cache miss, misprediction, fetch bandwidth) look identical in source and need different fixes.
- Code size is a resource hot loops spend; inlining trades call overhead for front-end pressure.
Follow the mechanism
The path through the machine, hop by hop — and the conclusions it invites that are wrong.
- 1Predictor → PC: for anything but straight-line code, the branch predictor supplies the address to fetch from before the branch has resolved.
- 2PC → L1 instruction cache: the fetch unit requests a fixed-width block of bytes at that address.
- 3L1 I-cache → fetch buffer: on a hit, bytes are delivered in a cycle or two; on a miss, the request goes to the unified L2 and the front end stalls.
- 4Fetch buffer → decoders: the block is split into instruction boundaries and handed to the decoders, yielding between one and fetch-width instructions.
- 5Decoders → back end: if the front end supplies fewer instructions than the back end can consume, the machine is front-end bound and execution units idle.
- • "The loop is slow so the arithmetic must be expensive" — the execution units may be idle waiting for instructions.
- • "Inlining is an optimisation" — it removes call overhead and adds code size; which dominates is a property of the specific loop.
- • "Only data has cache behaviour" — instruction supply has its own cache, its own misses, and its own bandwidth limit.
Consequences, controls and cost
- • Large binaries and deep, cold call chains run slower than their instruction count suggests, with no algorithmic explanation.
- • Aggressive inlining can make code slower by inflating the hot path beyond the instruction cache.
- • A program can show low IPC and low cache miss rates simultaneously — the tell for a fetch bandwidth or layout problem.
- • Keep the hot path contiguous: move cold error handling and rare special cases out of line so the loop body stays compact.
- • Use profile-guided optimisation on large binaries — its main benefit is code layout, and layout is exactly what the front end is sensitive to.
- • Treat inlining as a trade rather than a win, and measure both directions on the actual hot loop.
- • For most application code the honest answer is that the compiler already does this better than you can; the value is in recognising the symptom rather than hand-tuning.
- • Read front-end stall counters if the vendor exposes them; a top-down breakdown attributing stalls to the front end is the direct signal.
- • Read the instruction-fetch miss counter alongside the data miss counter — they are separate events and confusing them misdirects the whole investigation.
- • Compare IPC against branch-miss and cache-miss rates: low IPC with low misses of both kinds points at fetch bandwidth or layout.
- • Measure binary and hot-function size across builds; large jumps in hot-path size are worth correlating with performance changes.
- • Optimising layout by hand is fragile: it depends on compiler version, flags and the profile used, and it decays as the code changes.
- • Moving cold paths out of line makes control flow less obvious to a reader and can complicate debugging.
- • Profile-guided optimisation requires representative profiles and a more complex build; an unrepresentative profile can make things worse.
Scope
§224 — what these claims are specific to.
- MICROARCH-SPECIFICFetch width, instruction cache size and the presence of a decoded micro-operation cache vary per design. Some x86 cores can serve tight loops entirely from a micro-op cache, bypassing fetch; most embedded in-order cores have neither.
- GENERALThat instructions are fetched from memory through a cache, and that the front end can starve the back end, is true of every cached processor.
Misconceptions
Where the rest of this lives
The compiler or JIT decides how large your hot loop is and where the cold paths live. Those decisions are made before the CPU ever fetches a byte, and they are the main determinant of front-end behaviour.