Compilers · Optimization

Loop Unrolling, Trip Counts, and the Cost of Code Growth

Unrolling by a known factor removes loop overhead and enables scheduling across iterations, but it needs trip-count knowledge, a remainder loop, and a register-pressure check to pay off.

Unrolling duplicates the loop body k times and advances the induction variable by k, reducing the number of backward branches and loop-counter updates by a factor of k. That accounting is the least interesting part of the transformation. The reason unrolling appears in every optimizing compiler is that a larger body gives the scheduler independent instructions to interleave, and gives the instruction selector more opportunity to keep the machine’s execution units busy across what were previously iteration boundaries.

The cost is code size and register pressure, both of which scale with k. The transformation is therefore a search for the largest k that does not spill, subject to knowing how many iterations the loop actually runs.

The trip count question

Unrolling by k requires the loop to be rewritten so that the number of iterations is a multiple of k, with the leftover handled separately. There are three standard arrangements.

Unroll with a remainder loop. The main loop runs floor(n/k) iterations of the unrolled body, then a second loop runs n mod k iterations of the original body. This is correct for any n and any k, and it costs a second copy of the body plus the code that computes the remainder. For small bodies the remainder loop can be larger than the main loop, which is a real size cost for a loop that runs twice.

Unroll with a runtime guard and a prologue. Instead of a trailing loop, the prologue executes up to k-1 iterations before entering the unrolled loop. The two are equivalent in effect; the prologue form avoids a second backward branch but duplicates the body anyway.

Unroll fully. If n is known at compile time and small, the loop can be replaced by straight-line code with no loop at all. This is the best outcome — the entire body becomes a scheduling region — and it applies to loops over small constant bounds, which in practice means loops whose bounds come from array sizes or template parameters rather than from data.

The compile-time knowledge matters more than the factor. A loop with an unknown trip count forces the remainder machinery; a loop with a known trip count that is a multiple of k needs none. This is why loop unrolling benefits so much from the constant propagation and range analysis described in Constant Propagation over a Flat Lattice: proving n is 16 does not merely enable unrolling, it enables the cheapest form of it.

A rotated loop is a prerequisite in most optimizing compilers. Rotation moves the loop test to the bottom, creating a preheader and a single back edge, and it establishes that the body executes at least once — which is what licenses speculative motion inside the unrolled body, as noted in Loop-Invariant Code Motion and the Conditions It Needs.

What the larger body enables

Three effects, in increasing order of importance.

Loop overhead amortization. The induction variable increment, the comparison, and the conditional branch cost a few instructions per iteration. Unrolling by four reduces that to roughly one set per four bodies. For a body of ten instructions this is a visible fraction; for a body of a hundred it is noise.

Scheduling across iterations. Without unrolling, the scheduler cannot move an instruction from iteration i+1 before the branch at the end of iteration i, because the branch is a control-flow boundary. After unrolling, iteration i+1’s body is straight-line code in the same block, so a load from the next iteration can be issued while the current iteration’s arithmetic is still in flight. On an out-of-order core the hardware may find that parallelism anyway; on an in-order core, or when the loop body has a long dependency chain, the compiler’s scheduling is what exposes it. This is the main reason unrolling helps on targets without dynamic reordering.

Enabling other transformations. A constant index after unrolling turns an indexed load into a displacement load, removing address arithmetic. A conditional inside the body that becomes constant after unrolling by a factor matching its period disappears. A loop that carries a dependency at distance two becomes two independent chains after unrolling by two, which is the classic software-pipelining prerequisite.

/* Unroll by 4 with a remainder loop. The compiler emits the equivalent IR. */
for (i = 0; i + 4 <= n; i += 4) {
    acc += a[i]; acc += a[i+1]; acc += a[i+2]; acc += a[i+3];
}
for (; i < n; i++) acc += a[i];

Register pressure is the binding constraint

Each unrolled copy of the body brings its own live values. A body with four simultaneously live temporaries, unrolled by four, has sixteen — more than the sixteen general-purpose registers available on x86-64 before any address or loop-control values are counted. The allocator then spills, and each spill is a store and a reload per iteration, which can cost more than the loop overhead the unrolling removed.

The standard mitigation is to unroll only as far as the pressure budget allows. Implementations estimate the number of live values in the unrolled body, compare against the register file size minus a reserve for the loop counter and addresses, and reduce k if the estimate exceeds the budget. The estimate is necessarily approximate because allocation happens later, which is why unrolling is usually placed before allocation but with a conservative estimate rather than an exact one.

A second mitigation is to unroll without increasing the live set: if the copies of the body write different array elements rather than accumulating into one variable, their live ranges do not overlap and the pressure does not grow. Recognising that case requires knowing the memory dependence structure, which is the same analysis that vectorization needs and that Dependence Analysis and Polyhedral Loop Transformations formalizes.

Partial unrolling versus full unrolling, and the size budget

Full unrolling of a large loop is a size disaster. A loop with a thousand iterations unrolled fully produces a thousand copies, which will not fit in any instruction cache and may exceed the compiler’s patience. Implementations cap full unrolling at a small trip count — on the order of a few dozen iterations for a small body, fewer for a large one — and use partial unrolling otherwise.

The two decisions interact with inlining. A callee that is fully unrolled after inlining may grow past the size threshold that would have prevented the inlining, so the pass order determines the outcome. Compilers that inline first and unroll second sometimes produce larger code than the reverse order for the same source, and the global size budget described in Inlining Decisions and the Cost Model Behind Them is what keeps the combination bounded.

Unrolling and the memory system

Unrolling changes the pattern of memory accesses, not just the instruction stream. Four consecutive loads from adjacent addresses can be combined by the hardware into fewer cache transactions if they fall in the same line; a 64-byte line holds sixteen consecutive 4-byte elements, so unrolling by four usually keeps all accesses within one or two lines. That reduces the number of distinct lines touched per iteration, which matters for the load-store unit’s throughput even when the data is in cache.

Unrolling also makes prefetching easier for the compiler: with a known stride and a known unroll factor, it can issue a prefetch for the address k iterations ahead, and the prefetch distance becomes an explicit constant rather than an inferred one. Hardware prefetchers detect strides on their own, so the compiler-side benefit is mostly on targets with weak or absent stride detection, and on access patterns that the hardware prefetcher cannot follow — multiple streams with interleaved strides, for example.

The counter-case is a loop whose body has a branch that is unpredictable. Unrolling multiplies the number of branches, and if each is mispredicted a fraction of the time, the total misprediction cost grows with k. Branch prediction is per-branch-site, so four copies of an unpredictable branch occupy four predictor entries instead of one; whether that helps or hurts depends on the predictor’s capacity and replacement policy, and it is not predictable from the source alone.

When not to unroll

A loop that is executed once should not be unrolled: the remainder machinery and the duplicated body cost code size and the main loop never runs. Profile data is what distinguishes this case, and the frequency estimate described in Profile-Guided Optimization and Hot-Cold Code Splitting is the input that suppresses unrolling for cold loops.

A loop whose body contains a call that cannot be inlined should not be unrolled aggressively, because each copy contains the call and the call overhead dominates whatever was saved. A loop with a carried dependency of distance one — each iteration reading what the previous wrote — gains nothing from unrolling, since the dependency chain is unchanged and the scheduler still cannot overlap the bodies. And a loop that is already vectorized should usually not be unrolled further: the vector body is already wide, and unrolling it multiplies the vector register pressure rather than the scalar instruction-level parallelism.

References

  1. Advanced Compiler Design and Implementation — Steven S. Muchnick; Morgan Kaufmann
  2. A Catalogue of Optimizing Transformations — Frances E. Allen and John Cocke, in Design and Optimization of Compilers, Prentice Hall 1972
  3. Compilers: Principles, Techniques, and Tools, 2nd edition — Chapter 9, Machine-Independent Optimizations — Aho, Lam, Sethi, Ullman; Addison-Wesley