skip to content

A hot loop specialised into many generated bodies runs slower than before - what could have eaten the win?

level: seniorimportance: should knowfreq 40%

answer

  1. faster calls, more instructions
  2. the instruction cache is finite
  3. same work, larger code footprint
  4. stalls can outweigh removed indirection
  5. microbenchmark ran one body only

basics

~20 s

More distinct code doing the same work. Each generated body adds instructions competing for a finite instruction cache, so a loop that once ran entirely from cache now misses on it, and those misses cost more than the removed indirection saved.

solid answer

~50 s

Generating a body per type argument removes an indirection worth a handful of cycles per call and lets the optimiser inline across the boundary. It pays for that with instruction footprint: the same logic now exists several times over, once per argument. If the hot loop touches several of those bodies in one pass, their instructions compete for a cache of fixed size and start evicting each other, and a fetch that misses costs far more than the indirection ever did. The symptom is a loop that got slower after specialisation with no change in the work it does, and the confirmation is a measurement showing front-end stalls rather than more executed instructions. The fix is usually to specialise fewer paths, not more: keep generation for the argument that dominates the loop and leave the rest on a shared body.

go deeper

for a junior

Recall that the processor runs code out of a small cache, so the amount of distinct code in a hot path matters, not only how many operations it performs.

for a middle

Explain the two sides in their own units: a per-call saving of a few cycles from removed indirection, against a per-program cost in instruction footprint that shows up as fetch stalls.

for a senior

Demonstrate the diagnosis: separate front-end stalls from extra executed instructions, check how many distinct bodies one pass of the loop touches, and prefer a mixed strategy over more specialisation.

for a principal

Own the rule that performance claims must be measured on the assembled application, because the instruction cache is shared and a component benchmark cannot see the contention it will meet.

## What specialising the loop actually removed When a generic routine is compiled into one body per type argument, each body knows its argument concretely. Two things follow inside that body: operations on the argument are direct calls with known targets rather than selections made through a run-time indirection, and those known targets can be inlined, which in turn lets the optimiser fold constants, delete branches that are dead for this argument, and keep values in registers across what used to be a call boundary. That is a real gain, and it is worth being precise about its size. Removing one indirection is worth a small, fixed number of cycles per call. Inlining is worth more, because of what it enables downstream. Both are per-call savings measured in cycles. ## Why more code can run slower The cost sits on a different axis. A processor does not execute from the artifact; it executes from a small, fixed-size instruction cache, fed by a front end that has to fetch and decode. Code that is resident in that cache runs at full speed; code that is not stalls the front end until it arrives. Specialising a loop for many arguments multiplies the amount of distinct code that does the same job. If one iteration of the loop reaches into several generated bodies, those bodies now compete for the same cache lines, and each can evict the others. The loop thrashes its own instruction supply. A stall waiting for instructions is worth far more than the few cycles an indirection cost, so the arithmetic flips: the per-call gain is paid many times over by a front-end cost the per-call reasoning never accounted for. Two details make this worse than it first looks: - Inlining does not only duplicate a call, it duplicates the callee's body into every site, so aggressive inlining inside many generated bodies multiplies footprint twice over. - The cost is invisible in a microbenchmark that exercises one argument at a time, because with one body in play the cache is never contended. It appears only on the real mix of arguments. ## Reading the symptom before believing it | What you observe | What it usually means | |---|---| | Slower loop, same number of executed instructions | the front end is stalling - fetch, not work | | Slower loop, more executed instructions | specialisation exposed a worse code path, not a cache problem | | Faster in isolation, slower in the application | the microbenchmark ran one body, the application runs many | | Slower only after the artifact grew sharply | a new type argument multiplied bodies through a composed chain | The distinction in the first two rows is the one that matters. If the instruction count is flat and the time is up, the machine is waiting on code rather than doing more of it, and that points at footprint. If the instruction count rose, specialisation is not the villain and something else changed. ## Where the crossover sits There is no fixed number of arguments at which generation stops paying. The crossover depends on how large the generated body is, how many distinct bodies one pass of the loop touches, how much the removed indirection actually cost on this shape of call, and how much cache the rest of the program is already using. That last term is why a change that measured well in a component can regress in the assembled application: the cache is shared with everything else running. ## What to do about it 1. Confirm the diagnosis with a measurement that separates front-end stalls from extra executed work, rather than assuming it. 2. Find which arguments the hot loop actually exercises, and how many distinct bodies one pass touches. 3. Keep per-argument generation for the argument that dominates the loop, and let the long tail run on one shared body; a mixed strategy is normal and is usually better than either extreme. 4. Re-measure on the real workload, with the full application resident, not on the loop alone. The general lesson is that a per-call saving and a per-program cost are measured in different units, and only a measurement on the real workload converts between them. An engineer who says more specialisation is always faster has not met this case; one who says it is always slower has over-corrected. The answer is that it depends on footprint, and footprint is observable.

  • The loop measured faster in isolation and slower in the application. What explains the gap?
    An isolated benchmark usually exercises one type argument, so one generated body is in play and the instruction cache is never contended. The application runs the real mix, where several bodies compete for the same cache and evict each other. The benchmark measured the per-call gain without the per-program cost.
  • How would you tell a footprint problem from specialisation simply producing worse code?
    Compare executed instructions against elapsed time. If time rose while the instruction count stayed flat, the machine is waiting on instruction fetch, which points at footprint. If the instruction count rose too, the generated body is doing more work and the cause is the code produced, not the cache.
  • Is the fix to stop generating bodies for this routine entirely?
    Rarely. The usual answer is a mixed strategy: keep generation for the one or two arguments that dominate the hot loop, where the inlined path genuinely pays, and let the long tail of rarely used arguments run on a single shared body. That keeps most of the speed at a fraction of the footprint.

saying these in an interview costs you the question

  • Assumes removing an indirection can never make a path slower
  • Treats inlining as free because it removes a call
  • Trusts a microbenchmark that exercises one type argument
  • Concludes binary size stops mattering once the code is in memory
  • Responds to the regression by specialising even more aggressively