Your Code Is Data Too
Instructions are fetched from memory through their own cache, and that cache is small. A hot loop that fits runs at full speed; a sprawling call graph with aggressive inlining can spend a large fraction of its cycles waiting for instructions to arrive — a stall that data-focused profiling is structurally unable to see.
Software view, hardware view
The gap between what you wrote and what the machine does is where this whole domain lives.
A separate, smaller cache with the same rules
The first level of cache is split: a data cache and an instruction cache, each with its own capacity and its own miss behaviour. This split exists because instruction fetch is sequential and read-only while data access is neither, so the two benefit from different design choices. The consequence for a programmer is that code occupies cache in exactly the way data does, competes for capacity in the same way, and suffers the same locality effects.
The instruction cache is typically small — modest by the standards of any data structure. A tight loop that fits inside it can be fetched at full rate indefinitely. A loop whose body has grown past capacity begins evicting its own start before reaching its end, and the front end spends cycles waiting on fetch. Because the working set here is *code size* rather than data size, the usual instincts about locality do not fire.
The unified levels behind it — L2 and beyond — are shared between code and data, which means the two compete. A workload streaming a large array can evict the instruction lines of the very loop doing the streaming, an interaction that is genuinely difficult to reason about from source and shows up only in front-end stall counters.
The two cold error-path lines are fetched because they sit inside the loop body and share lines with hot code, consuming capacity that the hot path needed. Moving cold paths out of line is one of the highest-yield instruction-cache fixes available.
The inlining trade-off, stated honestly
Inlining removes a real cost: the call sequence, the argument marshalling, the return, and the optimisation barrier a call boundary represents. Having inlined, the compiler can also propagate constants, eliminate dead branches, and vectorize across what used to be a boundary. These are substantial wins and they are why inlining is on by default everywhere.
It also has a real cost, which is code size. Each inlined copy is more bytes of hot instruction stream, and the instruction cache does not grow to accommodate your enthusiasm. In a hot loop calling a small function this is almost always a clear win. In a hot loop calling a large function, or in a call graph where an aggressive inliner has produced many specialised copies, the cost can exceed the benefit and the loop becomes front-end-bound.
The honest summary is that inlining is a size-for-speed trade whose sign depends on the specific code, and compilers make that judgement with heuristics rather than certainty. Which is why forcing it — with attributes, hints or pragmas — is a change that must be *measured* rather than assumed, and one of the few optimisation decisions where the compiler default is usually better than a confident developer.
| Situation | Effect on call overhead | Effect on code size | Usual net |
|---|---|---|---|
| Tiny function in a hot loop | Removed, and enables further optimisation | Negligible growth | Clear win |
| Medium function, few call sites | Removed | Modest growth | Usually a win |
| Large function in a hot loop | Removed | Loop body may exceed I-cache | Often a loss |
| Function called from many sites | Removed at each | Multiplied across all sites | Frequently a loss |
| Cold error path inside a hot function | Irrelevant — rarely executed | Consumes hot cache lines | Move it out of line instead |
| Virtual call with many targets | Cannot inline without specialisation | Specialisation multiplies code | Measure; often front-end limited |
Diagnosing a front-end stall
The signature is distinctive once you know to look for it: poor CPI, low data-cache miss rates, and high front-end stall or instruction-fetch-miss counters. That combination says the core is not waiting on data — it is waiting on the code itself. Because most performance tutorials focus entirely on data, this combination is regularly misread as "unexplained slowness".
Beyond raw code size, three patterns produce it disproportionately. Deeply layered abstractions where every call is a small indirection spread the hot path across many distant lines. Megamorphic call sites, where one virtual call reaches many implementations, defeat both the instruction cache and the branch predictor at once. And templated or generic code instantiated many times generates near-duplicate machine code that multiplies footprint without adding behaviour.
The fixes mirror the data-side ones with the roles swapped: shrink the hot path, move cold code out of line so it stops sharing lines with hot code, reduce needless specialisation, and lay out functions so that code executed together sits together. That last one — profile-guided layout — is one of the few optimisations that is nearly free at the source level, because the compiler and linker do the work once you give them the profile.
- Signature: poor CPI and IPC: The Number Everyone Misreads, low data miss rate, high front-end stall or instruction-fetch-miss counters.
- Cold paths inline with hot ones waste instruction cache capacity on code that almost never runs.
- Megamorphic call sites hurt the instruction cache and Branch Prediction: Guessing Well Enough to Matter simultaneously.
- Template and generic instantiation can multiply footprint without changing behaviour.
- Profile-guided layout groups code that runs together and is unusually cheap for the benefit.
Key points
- The first-level cache is split, and instructions occupy their own small cache with the same capacity and locality rules as data.
- A hot loop that outgrows the instruction cache stalls the front end, no matter how good its data locality is.
- Inlining trades code size for call overhead; the sign of that trade depends on the specific code and must be measured.
- Cold code inlined into a hot function wastes instruction cache capacity — moving it out of line is a high-yield fix.
- The diagnostic signature is poor CPI with low data misses and high front-end stalls, which data-focused profiling will not surface.
Follow the mechanism
The path through the machine, hop by hop — and the conclusions it invites that are wrong.
- 1PC → I-cache: the fetch unit requests the instruction bytes at the program counter.
- 2I-cache miss → L2: the request goes to a unified level shared with data, where it competes with the program's data traffic.
- 3Fetch stall → decode starved: the decoders have nothing to work on and the back end drains.
- 4Back end idle → cycles with no retirement: CPI rises with no data-cache miss to explain it.
- 5Loop repeats → same misses: if the body exceeds capacity, each iteration re-fetches lines evicted by the previous one.
- • Assuming poor performance must be a data problem because that is what cache optimisation usually means.
- • Treating more inlining as unconditionally better and forcing it with attributes.
- • Attributing front-end stalls to branch misprediction without separating the two counters.
- • Ignoring binary size growth on the grounds that memory is cheap — the instruction cache did not get cheaper.
Consequences, controls and cost
- • Aggressively inlined code can run slower than the version with calls left intact.
- • Deep abstraction layers cost front-end bandwidth even when every individual function is cheap.
- • A data-streaming loop can evict its own instructions from shared cache levels.
- • Performance investigations stall when only data counters are consulted, because the constraint is invisible there.
- • Move cold paths — error handling, logging, rare branches — out of line so they stop occupying hot cache lines.
- • Let the compiler make inlining decisions by default; when overriding, measure both cycles and front-end stalls.
- • Reduce needless template or generic instantiation that produces near-duplicate machine code.
- • Use profile-guided optimisation so hot code is laid out contiguously — a large benefit for very little source change.
- • Shrink the hot loop body itself: fewer specialised paths inside the loop, more work per line of code.
- • Front-end stall cycles and instruction-fetch miss counters, read against data-cache miss counters.
- • Hot loop body size from the disassembly, compared against the target machine's instruction cache capacity.
- • A before-and-after on binary size and hot-path size when changing inlining settings.
- • A profile-guided build compared against the default build on the same workload.
- • Disabling inlining to save code size reintroduces call overhead and blocks cross-boundary optimisation.
- • Moving cold paths out of line complicates source structure and can hurt readability.
- • Profile-guided optimisation requires a representative profile and adds build complexity and a training step.
Scope
§224 — what these claims are specific to.
- MICROARCH-SPECIFICInstruction cache capacity, fetch width and any decoded-instruction caching in front of it differ substantially between designs, so the code-size threshold at which stalling begins is a per-machine property.
- SIMPLIFIEDThe layout illustration uses a 64-byte line and a small number of lines to show the shape of the competition; real instruction caches hold far more lines and sit behind additional front-end structures.
Misconceptions
Where the rest of this lives
A JIT decides inlining and code placement at runtime with profile data the ahead-of-time compiler never had, which changes instruction-cache behaviour dramatically between a cold and a warmed-up process.