JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Classic Optimizations: Inlining, Folding, Loops

Now that you can read LLVM IR and watch the pass pipeline run, it is time to meet the famous optimizations by name and see exactly how each one rewrites the code — why inlining is the one that unlocks all the others, how a constant flows and folds until whole branches die, and what the compiler does to loops to make them fly.

Inlining: the optimization that unlocks the rest

By now you can read LLVM IR and watch a pass rewrite it. This guide names the passes that earn their keep, and the very first one is the keystone. Inlining replaces a call to a function with a copy of that function's body, pasted right in at the call site, with the arguments substituted for the parameters. Call a tiny helper like square(x) that just returns x * x, and after inlining the call simply becomes x * x sitting in line — no jump out, no jump back. The immediate win is that you pay none of the calling-convention tax: no pushing arguments, no saving registers, no return address, no prologue and epilogue.

But the call-overhead saving, real as it is, is the small prize. The real reason inlining sits first in the pipeline is that it destroys a boundary. Before inlining, the compiler had to treat your helper as a black box: it could not see that the caller always passes a constant, and the helper could not see what the caller does with the result. Paste the body in, and suddenly the optimizer is looking at one flat stretch of code where the argument's known value and the body's computation live side by side. Every other optimization in this guide — folding, dead-code removal, loop work — now gets a far larger, far more transparent region to chew on. Inlining is less an optimization than an enabler of optimizations.

Folding and propagation: letting constants flow

The next family is the one inlining feeds. Constant folding evaluates at compile time any operation whose inputs are all known constants: the IR for 3 + 4 is simply replaced by 7, and 60 * 60 * 24 becomes the literal 86400 before the program ever runs. Constant propagation is its partner — when a value is known to be a constant, every later use of that value is replaced by the constant itself. The two run hand in hand and feed each other: propagate a constant into an expression, that expression now has all-constant inputs, so fold it; the folded result is itself a new constant to propagate further.

after inlining a call with a constant argument:

    n  = 5            // propagated in from the caller
    t1 = n * n        // propagate n=5  ->  t1 = 5 * 5
    t2 = t1 + 1       //   fold 5*5=25  ->  t1 = 25
                      //   propagate    ->  t2 = 25 + 1
                      //   fold         ->  t2 = 26

    if (t2 > 100)     // propagate t2=26 -> if (26 > 100)
        slow_path();  //   fold          -> if (false)  -> branch is dead
    else
        fast_path();  // only this survives

Fold + propagate together collapsed the arithmetic AND killed a branch.
Constant propagation and folding ping-pong until the arithmetic is gone and one branch is provably never taken — at which point dead-code elimination deletes the slow path entirely. This whole cascade was unlocked by inlining bringing the constant 5 into view.

Watch what that cascade did at the end: once the condition folded to a constant, one whole branch became provably unreachable. That hands the baton to dead-code elimination, which deletes any computation whose result is never observed — and you saw in the SSA guide why that is near-trivial once each name has a single definition. A sibling cleanup, common-subexpression elimination, spots when the same expression is computed twice with the same inputs (two separate a * b where neither a nor b changed in between) and keeps just one copy, reusing its result. None of these are exotic; together they are the bread and butter that makes -O2 code so much tighter than what you wrote.

Loops: where the time actually goes

Programs spend almost all their time in loops, so the optimizer fights hardest there. The first and most intuitive loop transform is loop-invariant code motion, usually called LICM. If a computation inside the loop body produces the same value on every iteration — it depends only on things that do not change in the loop — there is no reason to recompute it each time around. LICM hoists that computation out, above the loop, so it runs exactly once. A classic offender is an array length or a pointer-plus-offset address that the body recomputes every pass; pull it out and a loop that ran the work a million times now runs it once.

The second is strength reduction: replacing an expensive operation with a cheaper one that computes the same thing. The textbook case is a loop that uses its counter i to index an array, computing the byte address as base + i * 4 each iteration. A multiply is costlier than an add, and across iterations i only ever increases by 1, so i * 4 only ever increases by 4. The compiler turns the repeated multiply into a running address it nudges forward by 4 each pass — a multiply becomes an add. Strength reduction also covers tricks like turning x * 8 into the shift x << 3 and a division by a constant power of two into a shift, since shifts and adds are far cheaper than general multiplies and divides.

Unrolling and vectorization: doing more per pass

Even a tight loop pays a fixed cost per iteration that has nothing to do with the real work: increment the counter, test it against the bound, branch back to the top. Loop unrolling dilutes that overhead by pasting the body several times within one iteration. Unroll a loop by 4 and it does four elements' worth of work before checking the counter, so the per-iteration bookkeeping is paid once for every four useful operations instead of every one. It also gives the back end a longer straight run of instructions to schedule and overlap, which a pipelined CPU loves. The cost is bigger code and a messy remainder — if the trip count is not a multiple of 4, the compiler emits a little tail loop to mop up the leftover iterations.

Unrolling sets the stage for the showpiece: auto-vectorization. Modern CPUs have SIMD instructions — single instruction, multiple data — that operate on a whole vector of values at once, say adding eight pairs of 32-bit numbers in one instruction. If the iterations of your loop are independent, the compiler can rewrite the loop to process a vector's worth of elements per pass using these wide instructions instead of one element at a time. A loop summing a million floats one at a time can become a loop adding eight at a time, a roughly eightfold reduction in instructions for the same answer. Vectorization is where the biggest numeric speedups on modern hardware come from, and it is built directly on the loop work above.

Be honest about why vectorization so often fails to fire, because the reasons are instructive. The compiler will only vectorize when it can prove the iterations are truly independent, and three things routinely block that proof: a loop-carried dependency (each iteration reads what the previous one wrote), possible aliasing (two pointers the compiler cannot prove are distinct, so writing through one might change what the other reads), and tangled control flow inside the body. This is why a loop that looks trivially parallel to you may stay scalar — and why compilers offer a vectorization report (try clang's -Rpass=loop-vectorize or -Rpass-missed) that tells you, loop by loop, what it did and what stopped it.

How the passes chain into a cascade

No single one of these optimizations is the whole story; the magic is how they hand work to each other, exactly as you saw the pipeline order do in the LLVM guide. The canonical chain runs like this: inlining pastes a callee in and erases a boundary; constant propagation pushes the caller's known values into the pasted body; folding collapses the now-constant arithmetic; a folded condition makes a branch provably dead; dead-code elimination deletes the unreachable branch and any computation only it used. Each step is simple on its own, but each one creates the opportunity the next step needs.

  1. Inline: paste the called function's body in at the call site so the caller's facts and the callee's code sit together with no boundary between them.
  2. Propagate: replace each use of a now-known value with the constant itself, pushing the caller's constants deep into the pasted body.
  3. Fold: evaluate any operation whose inputs are all constant at compile time, so 5 * 5 + 1 simply becomes 26.
  4. Eliminate: a condition that folded to a constant makes one branch unreachable, so dead-code elimination deletes that branch and everything only it used.
  5. Repeat over loops: with the body now smaller and clearer, hoist invariants out, reduce multiplies to adds, unroll, and where the iterations are independent, vectorize.

Two honest framings keep all this in perspective. First, every one of these rewrites is licensed by the same single rule you met in guide 1 — the as-if rule: the compiler may transform your code however it likes as long as the observable behavior is unchanged, which is precisely why a deleted variable or a folded computation is still a correct compilation. Second, none of it is magic you should rely on to fix a bad algorithm: the optimizer makes your code faster, not your idea better, and it cannot turn a quadratic loop into a linear one. Knowing these passes by name is most useful not for hand-tuning but for reading what -O2 produced — and for understanding why, when undefined behavior lets the optimizer assume too much, the very same machinery can make a bug vanish at -O0 and bite at -O2, which is exactly where the next guide picks up.