Legalityspec

The As-If Rule

The compiler may transform the program however it likes, provided the observable behavior of the result follows the rules of the language's abstract machine. It is not a loophole — it is the clause that makes any optimization at all legal.

The question

Where does a compiler actually get permission to rewrite my program?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Two programs held side by side: the abstract machine's execution of the source, and the real machine's execution of the emitted code. Neither is a refinement of the other in any structural sense — the instruction sequences need have nothing in common. The only relation required is that the sequences of observable events match. This is the representation the whole middle-end is justified against, and it exists to answer "is the thing I emitted still this program".

What this phase may assume or do

A transformation is permitted under the as-if rule when the emitted program produces observable behavior that one valid execution of the abstract machine could have produced, for every input on which that abstract machine's behavior is defined. Note the "one valid execution": where the language leaves an order unspecified, the compiler may pick any of the permitted ones, and need not pick the same one twice. Where the abstract machine has undefined behavior, the rule imposes no requirement at all, which is why undefined behavior is an optimization subject and not merely a safety one.

Key points

  • The as-if rule is the clause that makes optimization legal at all: match the abstract machine's observable behavior, and the mechanism is unconstrained.
  • It requires matching *one* valid execution, so where the language leaves an order unspecified the compiler may choose freely and inconsistently.
  • Where the abstract machine has undefined behavior there is nothing to match, which is the source of the aggressive inferences in C and C++.
  • Volatile is the explicit opt-out: those accesses are themselves observable and may not be removed, merged or reordered against each other.
  • Copy elision and allocation elision go the other way — they permit an implementation to change observable behavior, and are deliberate exceptions rather than applications of the rule.
  • An unanalysable call blocks optimization because the rule forces the compiler to assume it may do anything observable; attributes exist to narrow that assumption.
  • The rule is also the test for a compiler bug: defined behavior in, different observable behavior out, is a miscompilation. Anything else is a source problem.

The clause, and why it has to exist

C++ puts it plainly: a conforming implementation need only emulate the observable behavior of the abstract machine. C says the same in §5.1.2.3. That single sentence is what makes register allocation, instruction scheduling, inlining, constant folding and every other transformation in this domain legal. Without it, a compiler would be obliged to emit code that performed each abstract step in order, and the fastest conforming C implementation would be an interpreter.

The name is exactly descriptive: the program must behave *as if* the abstract machine had executed it. Not "as if, roughly". The observable events must match, and everything else is unconstrained. A compiler may replace a loop with a closed-form expression, an allocation with a stack slot, a recursive call with a jump, a division with a multiply-and-shift, a whole function body with a constant. All of those are the same permission being used at different scales.

The rule also settles arguments about what an optimizer is allowed to know. It may use anything it can establish about the program: that this function is pure, that this pointer cannot be null because it was dereferenced, that this loop runs at most 32 times, that this virtual call has exactly one possible target in this build. None of those facts is "cheating"; they are premises, and the conclusion is the same conclusion the abstract machine reached.

A loop and a formula are the same program under the as-if rule
Before
int sum(int n) {
  int total = 0;
  for (int i = 1; i <= n; i++)
    total += i;
  return total;
}
After
int sum(int n) {
  return n <= 0 ? 0 : n * (n + 1) / 2;
}
Legal only when

Only if the two agree on every input the source program has defined behavior for. Inside the loop, total += i on signed int is undefined on overflow, so the compiler is entitled to assume no overflow occurs and need only match the abstract machine where it does. The loop performs no I/O, touches no volatile object and calls nothing, so its instruction count and duration are not observable — and the loop is guaranteed to terminate for every n, so the termination clause is satisfied.

Illegal when

The accumulator is unsigned, where overflow is defined to wrap and the closed form must reproduce the wrapped value exactly on every input — which n * (n + 1) / 2 does not, because the intermediate product wraps at a different point than the repeated additions do. It is equally illegal if total is volatile, since each of the n writes is then an observable event, or if the loop body contains a call the compiler has no model for.

Where the rule stops

specCopy elision and allocation elision are C++ clauses with no equivalent in C, and their status changed at C++17: elision of a prvalue initialisation became mandatory rather than optional, so the number of observable constructor calls for the same source differs between standard revisions. The forward-progress rule also differs — C++ permits assuming any side-effect-free loop terminates, while C11 exempts loops with a constant controlling expression, which is why while (1) {} is defined in C and undefined in C++.

Three boundaries, and confusing them is the source of most arguments about what a compiler "should" have done.

Volatile. An access to a volatile object is itself an observable event, so it may not be removed, duplicated, merged with an adjacent access, or reordered relative to another volatile access. This is the language's single explicit opt-out from the as-if rule, and it is why device drivers are written the way they are.

Undefined behavior. The rule requires matching behavior *where the abstract machine has behavior*. Where it does not, there is nothing to match, and the compiler owes that execution nothing. That is not an abuse of the clause — it follows directly from its wording, and [[ub-and-optimization]] is where the consequences are worked through.

The exceptions that go the other way. C++ contains a small number of places where an implementation is permitted to change observable behavior. Copy elision is the famous one: an implementation may omit a copy or move constructor even when it has visible side effects, so a program can observe a different number of constructor calls depending on the compiler — and since C++17 some elisions are mandatory rather than permitted. Allocation elision is another: new expressions may be omitted or merged under stated conditions. Both are deliberate carve-outs, added because the alternative was forbidding an optimization everyone wanted.

What the rule permits, forbids and explicitly carves outspec
SituationUnder the as-if ruleWhy
Replace a loop with a formulaPermittedInstruction count and duration are not observable; the returned value is.
Delete a store to a dead localPermittedNo observer can reach the storage, so the write is not an event.
Delete a volatile storespecForbiddenThe access is itself observable behavior — the explicit opt-out.
Reorder two printf callsForbiddenBoth are observable, and the language sequences them.
Reorder two non-aliasing storesPermittedNo single-threaded observer can tell; a second thread is a different question.
Assume a side-effect-free loop terminatesspecPermitted in C++, restricted in CThe forward-progress guarantee — an infinite empty loop is UB in C++.
Omit a copy constructor with side effectsspecPermitted, and sometimes requiredCopy elision is an explicit exception to the rule, not an application of it.
Merge two new expressionsspecPermitted under stated conditionsAllocation elision, added so that abstractions built on allocation can be free.
Evaluate f(g(), h()) right to leftspecPermitted in CThe order is unspecified, so any of the permitted executions is a valid target.

Reading it as an engineer rather than a lawyer

The as-if rule is usually encountered as a rebuttal — "the compiler is allowed to do that" — and that framing makes it sound like a technicality. It is more useful read forward, as a design statement: the language deliberately declined to specify the mechanism so that implementations could compete on it. Every implementation detail the specification leaves open is an optimization someone eventually wrote.

It also explains a pattern that otherwise looks inconsistent. Compilers are extremely aggressive about things that are not observable and extremely conservative about things that might be. A single unanalysable call in the middle of a function blocks a startling amount of optimization around it, because the compiler must assume that call could do anything observable — read any reachable memory, write any reachable memory, not return, perform I/O. Adding one attribute that narrows what the call may do can unlock a large amount of previously blocked work, which is the whole reason __attribute__((pure)), noalias, restrict and LLVM's readnone exist.

Finally, it sets the standard for what counts as a compiler bug. If a program has defined behavior and the emitted code produces different observable behavior, the compiler is wrong and the report is a [[miscompilation]]. If the program has undefined behavior, or relied on an unspecified order, or expected an unobservable effect to survive, the compiler is within the rule and the fix is in the source. Being able to sort your own bug into those two bins before filing it is the practical value of this lesson.

How it works

The steps, in the order the compiler takes them.

  • The specification defines an abstract machine and the observable events its execution produces.
  • A conforming implementation must produce a sequence of events matching some valid execution of that machine, and nothing more is required.
  • The optimizer establishes facts about the source program — purity, aliasing, ranges, dominance, reachability — and uses them as premises.
  • A transformation is applied when its precondition, together with those premises, implies the observable events are unchanged.
  • Volatile and atomic accesses are excluded from the analysis by construction, because their occurrence is the event.
  • Executions with undefined behavior impose no constraint, so the compiler may assume they do not occur and propagate that assumption backwards through the function.
  • The named carve-outs — copy and allocation elision — are applied by dedicated logic in the frontend rather than by the general machinery, since they are exceptions to the rule rather than consequences of it.

How it breaks

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

  • An engineer counts constructor calls in a test and the count changes when the compiler is upgraded, because copy elision became mandatory in a standard revision they did not read.
  • A program relying on left-to-right evaluation of function arguments produces different results under two compilers, and the difference is attributed to a bug in one of them.
  • A hardware initialisation sequence is reordered because the accesses were plain loads and stores rather than volatile, and the device is configured in the wrong order on some builds.
  • while (1) {} used as a deliberate hang compiles to a fall-through in C++ and the program continues into whatever follows, because an empty loop may be assumed to terminate.
  • A team spends a week arguing that the compiler is wrong, when the program had undefined behavior and the rule imposed no obligation on that execution at all.

When it helps

  • Triaging a suspected compiler bug: the rule gives a precise test that separates a genuine miscompilation from a source-level mistake.
  • Reading standards discussion, where "as-if" is the shorthand that most proposals about optimization are argued in.
  • Understanding why one attribute or one restrict can change generated code dramatically — it supplies a premise the rule then lets the compiler act on.
  • Explaining to a team why lowering the optimization level is not a fix: it hides the consequence of a source problem on one build configuration only.

When it hurts

  • Using it as a conversation-ender. "The as-if rule permits it" is true and unhelpful; the useful follow-up is which premise the compiler used, and whether the source should have supplied a different one.
  • Applying C++'s formulation to a language that does not have it. Java, C#, Rust and JavaScript each constrain their implementations differently, and several of the inferences the rule permits in C++ are forbidden in all of them.

What it costs

Every one of these is paid by something.

  • A permissive as-if rule buys implementations the freedom to compete on code quality and pays predictability: two conforming compilers may produce observably different programs from the same source where the language left something unspecified.
  • Explicit carve-outs like copy elision buy a specific optimization everyone wanted and pay the principle — once the specification permits changing observable behavior in one place, "conforming" no longer implies "observably identical".
  • Volatile buys a guaranteed, ordered access and pays every optimization on that object, including register promotion and access merging, for the lifetime of the declaration.
  • Leaving evaluation order unspecified buys scheduling freedom and pays a category of portability bug that is invisible under any single compiler, which is why C++17 spent some of that freedom back.

What else you could do

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

  • Specify the mechanism rather than the behavior, as some safety-critical subsets effectively do by forbidding optimization: predictable, auditable, and much slower.
  • Define everything the C standard leaves undefined, as Java largely does. The compiler loses several classes of inference and gains a language where the same program means the same thing everywhere.
  • Prove the transformation instead of relying on the rule: CompCert states its correctness theorem in terms of observable event traces, which is the as-if rule turned into a machine-checked proposition — [[verified-compilers]].
  • Let the programmer opt in per region: Rust's unsafe blocks and black_box, and C's #pragma STDC FENV_ACCESS, are all ways of locally changing what the implementation is permitted to assume.

See it for yourself

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

  • Compile a summation loop at -O2 in Compiler Explorer and look for the multiply — GCC and Clang both recognise the closed form for the signed case, and the assembly is the rule applied.
  • Repeat it with unsigned and with volatile int total and diff the three: the transformation that was legal in the first case is absent in the other two, for two different reasons.
  • C++: build a small class with a logging copy constructor, return it by value, and count the log lines at -O0 and -O2 and under -std=c++14 versus -std=c++17. The count is not stable, and that is conforming.
  • clang -O2 on while (1) {} with no side effects in the body, in C and in C++ modes: the two differ, and the difference is the forward-progress clause.
  • Read C++ [intro.abstract] and C §5.1.2.3 directly. They are each about a page, and they are the primary source for every argument in this module.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The as-if rule is a loophole compilers exploit." It is the enabling clause for all optimization. Without it, nothing in this domain past the parser would be legal.
  • "As-if means the generated code corresponds to my source." It means the observable events correspond. The instruction sequence need have no relationship to the source structure whatsoever.
  • "If two compilers disagree, one of them is broken." Not where the language left something unspecified. Both may be conforming, and the program is the thing that is wrong.
  • "Copy elision is the as-if rule in action." It is the opposite: an explicit exception permitting an implementation to change observable behavior, which the general rule would forbid.

Misconceptions

The claim, and what is actually true.

The as-if rule lets the compiler do whatever it wants.
It lets the compiler do anything that leaves observable behavior matching some valid execution of the abstract machine. That is a strong constraint on I/O, volatile access and termination, and no constraint at all on mechanism.
A conforming compiler produces observably identical programs to any other conforming compiler.
Only where the language specifies the behavior. Unspecified evaluation order, copy elision and undefined behavior each break that expectation while both compilers remain conforming.
The rule is about optimization.
It is about conformance. Optimization is the main thing it enables, but the same clause is what permits an implementation to interpret, JIT, transpile or partially evaluate the program instead of compiling it in the obvious way.

Go deeper

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

overview

The language describes an idealised machine that runs your program step by step. A real compiler does not have to do those steps — it only has to produce a program whose visible effects are the same ones that idealised machine would have produced. That is the as-if rule, and it is the permission slip for every optimization there is.

practical

Use the rule as a triage tool. If your program has fully defined behavior and the optimized build produces different output, that is a compiler bug worth reporting with a reduced test case. If it relied on evaluation order, on an unobservable effect surviving, or on something the language left undefined, the compiler was inside the rule and the source needs to change. And when you want the compiler to do more, look for the premise it is missing — an attribute, a restrict, a const, a narrower type — rather than a bigger -O number.

advanced

The subtlety that catches experienced people is the "some valid execution" quantifier. Where the language admits several executions — unspecified evaluation order, unspecified layout, unspecified size_t width — the implementation may pick any of them, need not document the choice, and need not be consistent between two occurrences in the same translation unit. That is genuinely different from being deterministic-but-unknown, and it means a program relying on such an order is not merely unportable across compilers but potentially inconsistent within one build. The carve-outs are the mirror image: places where the committee decided the general rule forbade something valuable, so it wrote an exception instead of weakening the rule. Reading a standard well means being able to tell those two structures apart on sight.

How much this depends on

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

specThe as-if rule as stated here is C++ [intro.abstract] and C §5.1.2.3. Java has no equivalent licence: the JLS specifies evaluation order, integer overflow and a memory model, so a JVM's freedom is bounded far more tightly and several inferences described here are forbidden. Rust defines overflow behavior in both profiles and has no unspecified argument evaluation order. Do not port the phrase, or the reasoning, to another language without checking what that language actually promises.
specCopy elision changed status in C++17: elision of a prvalue used to initialise an object became mandatory rather than merely permitted, so the number of observable constructor and destructor calls for identical source differs between C++14 and C++17 compilations. Allocation elision under [expr.new] remains permissive and implementations differ in how much of it they perform.
typicalRecognising a summation loop and emitting a closed form is something GCC and Clang do at -O2 for the signed case through scalar-evolution analysis; MSVC has historically been less willing, and neither guarantees it for any particular loop shape. Treat the example as an illustration of what the rule permits rather than as a prediction about your compiler.

If you were asked this in an interview

  • Where does a C++ compiler get permission to replace a loop with a multiplication?
  • Is copy elision an example of the as-if rule? Defend your answer.
  • A program behaves differently at -O2 than at -O0. Walk me through how you decide whether that is a compiler bug.

Connections

Concurrencymemory-model
Domains that do not exist yet
  • Testing & Reliability Engineering — Oracle problems in testing
    The as-if rule states the oracle for compiler testing exactly — same observable behavior for defined inputs — and the hard part in practice is generating programs whose behavior is known to be defined. That generation problem is owned there; [[compiler-fuzzing]] is the compiler-specific instance.