Performancei-cachefront endinliningcode sizestalls

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.

Follow the mechanism

Software view, hardware view

The gap between what you wrote and what the machine does is where this whole domain lives.

The question
Why does a program with excellent data locality still stall, and why can inlining make it slower?
What you wrote
Inlining removes call overhead, so more inlining is more speed, and the compiler should be encouraged to inline aggressively.
What the hardware does
Every inlined copy is more bytes of instruction stream competing for a small instruction cache. Past a threshold the loop no longer fits, and the front end begins stalling on instruction fetch — a cost that does not appear in any data cache counter.
Front-end stalls are one of the most commonly missed causes of poor performance, because the entire vocabulary of cache optimisation is normally applied to data. A program can have flawless data locality and still be starved of instructions.
SourceCompilerInstructionsFront EndExecutionRegistersCachesMemoryI/OBehavior

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.

A hot loop body against instruction cache lines. Once the body exceeds capacity, the first lines are evicted before the loop comes back around.
usedfetched, never readSIMPLIFIED
loop headinlined fn Ainlined fn A (cont)inlined fn Bcold error pathcold error pathinlined fn Cloop tail + branch
line 0line 1line 2line 3line 4line 5line 6line 7
512 bytes total8 cache lines touched128 bytes fetched and never read

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.

When inlining pays and when it does not
SituationEffect on call overheadEffect on code sizeUsual net
Tiny function in a hot loopRemoved, and enables further optimisationNegligible growthClear win
Medium function, few call sitesRemovedModest growthUsually a win
Large function in a hot loopRemovedLoop body may exceed I-cacheOften a loss
Function called from many sitesRemoved at eachMultiplied across all sitesFrequently a loss
Cold error path inside a hot functionIrrelevant — rarely executedConsumes hot cache linesMove it out of line instead
Virtual call with many targetsCannot inline without specialisationSpecialisation multiplies codeMeasure; 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.

  1. 1
    PC → I-cache: the fetch unit requests the instruction bytes at the program counter.
  2. 2
    I-cache miss → L2: the request goes to a unified level shared with data, where it competes with the program's data traffic.
  3. 3
    Fetch stall → decode starved: the decoders have nothing to work on and the back end drains.
  4. 4
    Back end idle → cycles with no retirement: CPI rises with no data-cache miss to explain it.
  5. 5
    Loop repeats → same misses: if the body exceeds capacity, each iteration re-fetches lines evicted by the previous one.
What people conclude from this — wrongly
  • 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

What it causes
  • • 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.
What you can do
  • • 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.
How to see it
  • • 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.
What it costs
  • • 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.

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

Claim
“Cache optimisation is about data layout.”
Reality
Instructions are fetched through their own cache with the same capacity limits. A program with perfect data locality can still be front-end-bound on instruction fetch.
Claim
“Inlining is always faster because it removes the call.”
Reality
It removes call overhead and enables further optimisation, at the cost of code size. In a hot loop calling a large function, or across many call sites, the size cost can dominate.
Claim
“Binary size does not matter on modern machines.”
Reality
Total binary size matters little; *hot path* size matters a great deal, because the instruction cache is small and did not grow with disk capacity.

Where the rest of this lives

Programming Languages & Runtime Internals
JIT compilation and code layout

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.