Doing the Math at Compile Time

Some of the arithmetic in your program does not depend on anything that happens while it runs. If you write 7 * 24 * 60 * 60, the answer is the same on every machine, every time. There is no reason to make the CPU compute it when the program runs; the compiler can work it out once, during compilation, and bake the answer straight into the code.

Two closely related optimizations do this:

  • Constant folding: evaluating an expression made entirely of constants at compile time.
  • Constant propagation: tracking that a variable holds a known constant and substituting that value wherever the variable is used (which then often exposes more folding).

They work hand in hand, and this lesson looks at both.

Folding a Chain of Constants

Here is a function that computes the number of seconds in a week, step by step:

int secondsInWeek()
{
    int days{7};
    int hours{days * 24};
    int minutes{hours * 60};
    int seconds{minutes * 60};
    return seconds;
}

Every value here is derived from the literal 7. Nothing comes from outside the function. Watch what -O0 does with it:

secondsInWeek():
	push	rbp
	mov	rbp, rsp
	mov	DWORD PTR -4[rbp], 7
	mov	edx, DWORD PTR -4[rbp]
	mov	eax, edx
	add	eax, eax
	add	eax, edx
	sal	eax, 3
	mov	DWORD PTR -8[rbp], eax
	mov	eax, DWORD PTR -8[rbp]
	imul	eax, eax, 60
	mov	DWORD PTR -12[rbp], eax
	mov	eax, DWORD PTR -12[rbp]
	imul	eax, eax, 60
	mov	DWORD PTR -16[rbp], eax
	mov	eax, DWORD PTR -16[rbp]
	pop	rbp
	ret

The unoptimized compiler computes the whole thing at run time. It stores 7, multiplies by 24 (notice it does this as add/add/sal eax, 3, a clever way to multiply by 24 = 8 * 3), then two imul eax, eax, 60 instructions, storing each intermediate to its own stack slot. Every multiplication that you wrote actually happens on the CPU.

Now -O2:

secondsInWeek():
	mov	eax, 604800
	ret

That is the entire function. The optimizer folded 7 * 24 * 60 * 60 down to the single constant 604800, loaded it into eax, and returned. Not one multiply survives, because there was nothing to multiply at run time: the answer was always going to be 604800. All of that arithmetic was paid for once, by the compiler, before your program ever ran.

Propagation: Following a Constant Through Variables

Folding handles expressions that are already all constants. Propagation is what lets the compiler discover that a variable is constant in the first place, even when it is mixed with run-time data.

int compute(int x)
{
    int a{5};
    int b{a + 3};
    return x * b;
}

Here x is a genuine run-time argument, so the function cannot be reduced to a single constant. But a is clearly 5, and therefore b is clearly 5 + 3 = 8. A human reads this instantly. So does the optimizer.

At -O0 the compiler plays dumb, storing 5, adding 3, and reading the result back to multiply:

compute(int):
	push	rbp
	mov	rbp, rsp
	mov	DWORD PTR -20[rbp], edi
	mov	DWORD PTR -4[rbp], 5
	mov	eax, DWORD PTR -4[rbp]
	add	eax, 3
	mov	DWORD PTR -8[rbp], eax
	mov	eax, DWORD PTR -20[rbp]
	imul	eax, DWORD PTR -8[rbp]
	pop	rbp
	ret

At -O2 it propagates a = 5 into b, folds b to 8, and substitutes that constant into the multiplication:

compute(int):
	lea	eax, 0[0+rdi*8]
	ret

The function is now just "multiply x by 8". The constants 5 and 3 are gone; the optimizer carried them forward, combined them, and used the result. Even better, it multiplied by 8 not with imul but with lea eax, 0[0+rdi*8], which uses the addressing-mode scale factor to compute rdi * 8 for free. The local variables a and b left no trace whatsoever.

Why This Matters for Reading Assembly

Once you know about folding and propagation, a whole category of "where did my code go?" mysteries dissolves. When you see a bare mov eax, 604800 where your source clearly had a chain of multiplications, you are not looking at a bug or a different function. You are looking at arithmetic the compiler already finished.

This also has a practical consequence for how you write code. Naming intermediate steps with clearly-constant locals, as in secondsInWeek, costs nothing at -O2: those locals vanish and the constant is folded just as if you had written the final number. You get readable source and optimal output at the same time. (In later study you will meet constexpr, which lets you require that a computation happen at compile time rather than merely hoping the optimizer does it.)

A Note on What Stays

Propagation only works when the compiler can prove a value is constant. The moment a value comes from outside, from std::cin, a function argument it cannot see through, or anything decided at run time, it stops being foldable and the real instructions stay. In compute, x survived precisely because it is a genuine argument. The skill is learning to look at a listing and separate the part the compiler could pin down (gone, folded into constants) from the part it genuinely had to compute (still there).

Key Takeaways

  • Constant folding evaluates all-constant expressions at compile time, so 7 * 24 * 60 * 60 becomes the literal 604800.
  • Constant propagation tracks variables known to hold constants and substitutes their values, often exposing further folding.
  • At -O2, a function whose result is entirely constant can collapse to a single mov of that constant and a ret.
  • Folded constants leave no trace of the original arithmetic or the named locals that held them.
  • The optimizer only folds what it can prove constant; values from std::cin or opaque arguments stay as real run-time instructions.
  • Writing readable code with named constant locals costs nothing at -O2, because those locals are folded away.