Optimizeimplementation

Constant Folding

Evaluate at build time what would otherwise be evaluated at run time — but only when the operands are literals, the operation cannot fault, and the compiler computes exactly the value the machine would have computed.

The question

If the compiler can already see that an expression is 2 * 3, why would it ever emit a multiply?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

IR instructions over values that are either literals or virtual registers. Folding exists because this representation makes "is this operand a compile-time constant?" a one-field question, which the AST could ask only awkwardly and the source text could not ask at all. After folding, an instruction has been replaced by a const — the operation is gone, and its cost with it.

What this phase may assume or do

Every operand is a literal, the operation cannot trap on those operands, and the compiler evaluates it with exactly the semantics the target would have used. All three are required. AtlasLang enforces the second with mayTrap, which is why 10 / 0 survives folding and faults at run time; and the third is enforced by a test that compares the folded constant against the value the VM computes, because a compiler whose arithmetic disagrees with its own runtime is a miscompiler.

Key points

  • Folding replaces an operation with its value; the legality condition is that the operands are literals, the operation cannot trap, and the compiler's arithmetic matches the target's.
  • A fold that moves a fault to compile time is not an optimization — AtlasLang refuses to fold 1 / 0 for exactly this reason, and a test asserts it.
  • Folding is valuable mostly because it feeds other passes: a folded constant is what makes a branch literal, which is what makes a block unreachable, which is what makes definitions dead.
  • Integer folding is nearly unconditional; floating-point folding depends on a rounding mode and on NaN and signed-zero behavior the compiler may not be entitled to assume.
  • A compiler whose compile-time arithmetic disagrees with its own run-time arithmetic produces different answers depending on whether a value was constant, which is one of the nastiest classes of [[miscompilation]].

The rewrite, and the two things it is really claiming

Constant folding is the smallest optimization there is: an instruction whose operands are all literals is replaced by a literal holding the result. It shows up in every compiler including ones that claim not to optimize, because it costs almost nothing and it is the pass that makes other passes productive — a folded constant is what [[constant-propagation]] then carries forward, which is what makes a branch condition literal, which is what makes a block unreachable.

What looks like arithmetic is actually two claims. The first is that the compiler can compute the same value the machine would have. That is not free: the compiler is usually a different program, on a different machine, with different integer widths and a different floating-point environment, and every cross-compiler has to be careful about it. The second is that evaluating the expression early does not change *when* anything observable happens — which is exactly where folding stops.

The whole optimization, in AtlasLang IR
Before
%1 = int 2 * 3
store @x, %1
After
%1 = const 6
store @x, %1
Legal only when

Both operands are literals, integer multiplication is total over the operand type so the instruction cannot fault, and the compiler evaluates 2 * 3 with the same integer semantics the VM would have used. The store is unchanged: folding removes an operation, never an effect.

Illegal when

The operation can fault on these operands. 1 / 0 is a literal expression whose value the compiler cannot compute, and folding it does not produce a constant — it produces a compile-time failure where the language promised a run-time one. AtlasLang refuses it: mayTrap returns true for / and % unless the divisor is a provably non-zero literal, so 10 / 0 is emitted as a division and faults where it should.

What the AtlasLang optimizer actually does with it

implementationThis is the AtlasLang optimizer in this build, whose integer type is a single machine-independent signed integer with truncating division. GCC and LLVM fold far more than this — they constant-fold across calls to functions they know are pure, evaluate constexpr bodies at compile time, and simplify whole expression trees through their instcombine machinery. Nothing about the *shape* of the legality argument changes; the set of expressions it applies to is vastly larger.

Run print(1 + 2 * 3); through the pass manager at /compilers/passes and the entire expression collapses to print 7 before code generation sees it. That is not one pass: folding turns 2 * 3 into 6, propagation substitutes 6 into the addition, folding runs again on 1 + 6, and propagation carries 7 to the print. The pass manager repeats the pipeline until nothing changes and reports its iteration count, which is the visible evidence that these passes feed each other — see [[phase-ordering]].

Toggle constant folding off and leave propagation on, and the arithmetic survives to the generated code. That switch is the most useful thing in the interactive: it separates "the optimizer removed my code" into "which pass removed my code, and what did it have to prove first".

The folding example from the pass manager, before and after the pipeline
After lowering
%1 = int 2 * 3
store @x, %1
%2 = load @x
%3 = int %2 + 0
store @y, %3
%4 = load @y
print %4
After the pipeline reaches a fixed point
print 6

Read it asThree passes were involved and only one of them is folding. 2 * 3 folded; propagation carried the 6; [[strength-reduction]] removed the + 0; and [[dead-code-elimination]] deleted the stores once nothing read them. Attributing the whole collapse to "constant folding" is the standard misreading, and toggling the passes individually is the cure.

Where folding is not allowed, and why floating point is its own subject

Integer folding is nearly unconditional because integer arithmetic is total: for a fixed width, every pair of operands has a defined result for every operator except division and remainder by zero. Floating point is not like this, and folding it is genuinely harder than it looks.

IEEE-754 arithmetic depends on a rounding mode that a program can change at run time, and it has values that carry information a naive fold destroys — signed zeros, quiet and signalling NaNs with payloads, and exception flags that accumulate in a status register a program may read. A compiler that folds 0.1 + 0.2 must do so under the rounding mode the program will actually be running with, which in the general case it cannot know. Most compilers fold under round-to-nearest-even and stop folding entirely inside a region where the program has declared it manipulates the floating-point environment.

This is the same distinction [[strength-reduction]] runs into with x + 0.0, and the reason both live under [[semantics-drive-optimization]] rather than under cleverness: the answer is a fact about the number system, not about the compiler.

Fold or not, and the reason in each rowtypical
ExpressionFolded at compile time?What decides it
2 * 3 (int)YesLiteral operands, total operation, compiler arithmetic matches the target.
10 / 0 (int)specNoThe operation faults. Folding moves a run-time fault into the build.
10 / b, b unknownNoAn operand is not a literal. This is propagation's job first, if b has a constant definition.
INT_MAX + 1 (signed C)specCompiler-dependentSigned overflow is undefined in C, so the compiler may fold it to anything, warn, or refuse. Several do all three depending on context — [[undefined-behavior]].
0.1 + 0.2 (double)specUsually, under round-to-nearestOnly if the compiler can assume the run-time rounding mode. Inside an FENV_ACCESS ON region it must not.
sqrt(2.0)targetOftenRequires the compiler to know the function is pure and to reproduce the target library's result bit-for-bit. Cross-compilers are conservative here.
strlen("hello")implementationUsuallyOnly because the compiler has built-in knowledge that this specific library function is pure and what it computes — not because it analysed the body.

How it works

The steps, in the order the compiler takes them.

  • Walk each basic block in order, looking at one instruction at a time.
  • For a binary instruction, test whether both operand values are of kind const.
  • Ask mayTrap: for division and remainder, a divisor that is not a provably non-zero literal disqualifies the fold.
  • Evaluate the operation with the compiler-side implementation that is shared with the interpreter, so the two cannot disagree.
  • Replace the instruction in place with a const instruction holding the result, keeping the same destination register so no use has to be rewritten.
  • Leave everything else — stores, prints, calls — untouched: folding removes operations, never effects.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A program that faulted with a division by zero at run time now fails to build, with an error pointing at an expression the developer considered obviously dead. The compiler folded something it should have left alone.
  • A constant expression and the identical computed expression produce different results in the same program — 1 << 40 folded with 64-bit compiler arithmetic while the target shifts a 32-bit register. The symptom is a value that is right in a test with variables and wrong in a test with literals.
  • A floating-point result changes when a value becomes constant after inlining, because the fold used the compiler's rounding and the run-time path used the process's. The engineer sees a numeric difference that appears and disappears with unrelated code changes.
  • A cross-compiler folds a library call using the *host* library, and a target with a different libm returns a different last bit. The difference survives into a checksum comparison and looks like data corruption.

When it helps

  • Anywhere constants flow through arithmetic — configuration values, array sizes, unit conversions, sizes computed from sizeof — which after inlining and propagation is far more code than it appears to be in the source.
  • Enabling the passes downstream of it: a literal branch condition is what lets [[dead-code-elimination]] remove an entire arm.
  • Compile-time programming generally, where the fold is not an optimization but a language feature the program depends on — [[compile-time-evaluation]].

When it hurts

  • Where the developer wanted the operation to happen at run time: benchmarks that measure an operation the compiler folded away measure nothing, which is the oldest microbenchmarking mistake there is.
  • Where folding hides an error until the operands stop being constant. Code that overflows at compile time may be diagnosed; the same code with run-time operands is silent.

What it costs

Every one of these is paid by something.

  • Folding buys removed instructions and pays a small amount of compile time on every instruction examined — negligible per instruction, real over a million-instruction translation unit, and the reason folding is done as part of a combined simplification pass rather than as its own traversal in production compilers.
  • Matching the target's arithmetic exactly buys correctness and costs implementation surface: a cross-compiler needs its own arbitrary-precision integer and IEEE-754 layer rather than using the host's, which is thousands of lines that exist only to avoid being subtly wrong.
  • Folding away a computation costs debuggability: there is no instruction left to breakpoint, and a variable whose value was folded reads as unavailable in a debugger — see [[debugging-optimized-code]].

What else you could do

What a different compiler or language does instead, and when that is better.

  • Do the evaluation in the language instead of hoping for it: constexpr in C++, const fn in Rust and comptime in Zig make compile-time evaluation a guarantee with a diagnostic, rather than an optimization that may or may not fire — [[compile-time-evaluation]].
  • Sparse conditional constant propagation folds and propagates simultaneously over the CFG, discovering constants that neither pass finds alone because it treats unreachable edges as contributing nothing. It costs more implementation than the two separate passes AtlasLang uses.
  • A partial evaluator generalises the idea to whole functions rather than single instructions, specializing code for the arguments that are known — [[partial-evaluation]].

See it for yourself

The flag, dump or tool that shows you this directly.

  • Toggle the pass on and off at /compilers/passes, on print(1 + 2 * 3);, and watch the fixed-point iteration count change with it.
  • clang -S -emit-llvm -O1 -o - file.c and look for the arithmetic: if the multiply is absent and a literal appears at the use, it folded.
  • GCC: -fdump-tree-ccp-details dumps the conditional-constant-propagation pass, which does folding and propagation together and prints what it proved.
  • To see a refusal rather than a fold, compile int f(void){ return 1/0; } with clang -O2 -S: the division is not replaced by a constant, and clang warns instead.
  • Compiler Explorer with two panes at -O0 and -O1 on the same source is the fastest way to see which arithmetic survives.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The compiler evaluates any expression it can see the inputs of." Only if it can also prove the evaluation cannot fault and that its own arithmetic matches the target's. Division, floating point and anything calling into a library are all places it declines.
  • "Folding and constant propagation are the same pass." They are separable, and separating them is what makes the pass manager legible: folding evaluates operations on literals, propagation carries a literal definition to its uses. AtlasLang keeps them apart on purpose.
  • "If it folded here it will fold there." Folding is enabled by whatever made the operands constant — inlining, propagation, template instantiation — so the same source expression folds in one call site and not in another.

Misconceptions

The claim, and what is actually true.

Any expression made of literals is computed at compile time.
Only when the operation cannot fault and the compiler is entitled to reproduce the target's arithmetic. 10 / 0 is entirely literal and is deliberately left alone.
Folding is what makes constant-heavy code fast.
Folding removes work that was already cheap. What makes constant-heavy code fast is what folding *enables*: propagation, branch simplification, unreachable-code removal and the dead definitions that follow.
The compiler and the runtime obviously agree about arithmetic.
They agree only if someone made them. AtlasLang shares one evalBinary between the folder and the VM, and a test compares them, precisely because a divergence would be invisible until a program mixed constant and computed values.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

If the compiler can see all the inputs to an operation, it can do the operation itself and put the answer in the program instead. That is constant folding. The catch is that some operations can fail, and doing a failing operation early turns a run-time error into a build error, so those are left alone.

practical

When a benchmark shows an operation costing nothing, check whether it folded before concluding the hardware is fast. When a build fails on an expression you thought was unreachable, check whether the compiler folded it. And when a floating-point result changes after an unrelated refactor, check whether inlining made an operand constant and moved the arithmetic into the compiler.

advanced

The deep issue is that folding requires the compiler to be an interpreter for the target's semantics, and the two implementations must not diverge. Production compilers handle this with an arbitrary-precision integer layer (LLVM's APInt) and a software IEEE-754 implementation (APFloat) rather than host arithmetic, so a compiler running on x86-64 folds 32-bit ARM arithmetic correctly. The same requirement is what makes compile-time evaluation of user code — constexpr, const fn — an interpreter living inside the frontend, and why its supported subset grows one standard revision at a time.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationAtlasLang folds binary and unary operations on literals only, and refuses anything mayTrap reports. LLVM folds through its instcombine and SCCP passes, across pure library calls it has built-in models for, and through constexpr evaluation in the Clang frontend — a much larger set of expressions with the same underlying legality argument.
specWhether INT_MAX + 1 may be folded at all is a language question, not a compiler one. In C and C++ signed overflow is undefined, so an implementation may fold it to any value; in Rust the same expression panics in a debug build and wraps in a release build, both defined; in Java it is defined to wrap. The compiler is not choosing here — the language chose for it.
typicalMainstream compilers fold floating-point arithmetic under round-to-nearest-even and stop inside regions where the program declares access to the floating-point environment (#pragma STDC FENV_ACCESS ON). Compilers built for numerical work are more conservative by default, and -ffast-math makes them dramatically less so — a flag that changes what the program means, not how fast it runs.

If you were asked this in an interview

  • Why would a compiler decline to fold 1 / 0 when both operands are right there?
  • You are writing a cross-compiler on x86-64 targeting 32-bit ARM. What goes wrong if you fold using host arithmetic?
  • Constant folding and constant propagation — what does each one actually do, and which one needs SSA?

Connections