Miscompilation
The compiler turns a valid program into behavior the language does not allow it to have. It is the only bug class where reading your own source cannot find it, and it silently invalidates every test you have — including the ones that pass.
How would I ever know whether the wrong answer came from my code or from the compiler?
A pair of artifacts and a contract between them: the source program, whose set of permitted behaviors is fixed by the language specification, and the generated code, whose behavior is whatever the machine does with it. A miscompilation is a divergence between the two — so the object of study is neither artifact but the *difference*, which is exactly why nothing you can read in either one alone reveals it.
The compiler is licensed to emit any code whose observable behavior is one of the behaviors the source program is permitted to have — [[as-if-rule]] and [[observable-behaviour]] define that set. A miscompilation is precisely a violation of that precondition: the emitted code exhibits behavior outside the permitted set for a program that was well-defined. If the source program has undefined behavior, the permitted set is unconstrained, and no output can be a miscompilation. That is why the first triage question is never "which pass broke it" but "is my program well-defined".
Key points
- A miscompilation is generated code whose observable behavior lies outside the set the source program was permitted to have.
- It is the one bug class that reading your own source cannot find, because the artifact at fault is one you did not write.
- It invalidates tests, because the tests are compiled by the same compiler and the same wrong transformation applies to both.
- Most reports are undefined behavior in the reported program, and the symptom is identical: it works at
-O0and breaks at-O2. - The distinguishing question is whether the language defines the program, not whether the output is surprising.
- Reduce before reporting. An eight-line reproducer is a fix; a four-thousand-line one is a backlog item.
The bug you cannot read your way out of
Every other class of bug is, in principle, findable by reading the source. A logic error is in the source. A race is in the source. A misused API is in the source. A miscompilation is not: the source is correct, and the artifact that is wrong is one you did not write and probably cannot read. Every technique you have — code review, static analysis, reasoning about invariants, explaining it to a colleague — operates on the artifact that is not at fault.
It is worse than that. A miscompilation invalidates your tests, because your tests are compiled by the same compiler. A test that asserts a function returns 42 is a claim about the *compiled* function. If the same wrong transformation is applied to the test and to the code under test, the test passes and tells you nothing. This is not hypothetical: assertion code is often the first thing an optimizer deletes, because an assertion that the compiler has "proved" cannot fail is a dead branch.
And it does not stay local. A wrong value produced in one function propagates through data structures and across module boundaries. The crash, when it comes, is three abstraction layers and forty milliseconds away from the instruction that was wrong. This is why the domain treats compiler correctness as a first-class subject rather than as somebody else's quality problem: it is the one program whose bugs are everyone else's bugs.
- Wrong-code bug: the compiler accepts a valid program and emits code that does the wrong thing. The dangerous one.
- Crash bug: the compiler itself faults or asserts. Loud, annoying, and harmless by comparison — you cannot ship it.
- Bogus rejection: the compiler refuses a valid program. Also loud. You find a workaround the same afternoon.
- Bogus acceptance: the compiler accepts an invalid program. Portability trap rather than a correctness trap; the next compiler rejects it.
- Missed optimization: legal but slow. A performance bug, not a correctness bug, and the only one you can safely ignore.
Is it a compiler bug, or is it your program?
-fwrapv and -fno-strict-aliasing are GCC and Clang; MSVC has /volatile:ms and no -fwrapv equivalent; rustc has no such flags at all because the language defines integer overflow (panic in debug, two’s-complement wrap in release) rather than leaving it undefined. If your language has fewer undefined behaviors, this entire triage column is shorter — which is a real argument in the language-design column, not just a convenience.Most reports of "the compiler broke my code" are undefined behavior in the reported program. This is not a way of dismissing the reporter — it is a statistical fact about a mature toolchain that compiles billions of lines a day, and it is also the single most useful thing to know when you are the one reporting. An optimizer that exploits [[undefined-behavior]] produces exactly the symptom of a miscompilation: code that worked at -O0 and stops working at -O2, code that changes behavior when you add a printf, code where a check you wrote has visibly vanished from the disassembly.
The distinguishing question is whether the language defines what your program does. If it does not, the compiler has done nothing wrong however astonishing the result — [[ub-and-optimization]] is the mechanism. If it does, and the output disagrees, you have a compiler bug. The table below is the triage order, cheapest evidence first.
| Evidence | Points at undefined behavior in your program | Points at a compiler bug |
|---|---|---|
| A sanitizer fires | -fsanitize=undefined,address reports something. Fix that first; it is almost always the whole story. | Sanitizers are clean on the failing input, repeatedly, on more than one build. |
-fwrapv / -fno-strict-aliasing changes itimplementation | The symptom disappears. You were relying on signed overflow or on type-punned pointers. | No optimization-semantics flag changes anything; only the pass pipeline does. |
| Another compiler | Two independent compilers produce the same surprising behavior — they are unlikely to share a bug, likely to share a UB rule. | One compiler disagrees with two others on a program the specification defines. |
| Reduction | The reduced case reads a dangling pointer or shifts by 32. The reducer found your bug for you. | The reduced case is ten lines with no UB in it and still misbehaves. |
| Pass bisectionimplementation | Disabling any of several unrelated passes hides it — a hallmark of an unstable program, not a broken pass. | Exactly one pass, disabled, fixes it, and the IR before and after that pass are not equivalent. |
The transformation that looks like a miscompilation and is not
Here is the canonical case, and it is worth being able to read cold. A programmer writes an overflow check on a signed integer. In C and C++ signed overflow is undefined, so the compiler is entitled to assume it does not happen; under that assumption x + 1 < x is provably false, and a provably-false branch is dead code. The check disappears. The programmer sees a security check they wrote missing from the disassembly and reports a miscompilation.
It is not one. The compiler applied a legal transformation to a program that was never well-defined. The fix is on the source side — compare against the type limit rather than provoking the overflow, or use a builtin such as __builtin_add_overflow, or compile with -fwrapv and accept the optimization cost. What makes this lesson matter is that the *same symptom* — my code is gone — is also what a real dead-code-elimination bug looks like, and the only way to tell them apart is to establish whether the source was defined.
int add_checked(int x) {
if (x + 1 < x) return -1; /* "overflow check" */
return x + 1;
}int add_checked(int x) {
return x + 1; /* branch proved dead and removed */
}Only in a language where signed integer overflow is undefined behavior, as in C and C++. Under that rule the compiler may assume x + 1 does not overflow, which makes x + 1 < x provably false for every value the program is permitted to hold, which makes the branch unreachable and its removal behavior-preserving on all defined executions.
The same rewrite is a miscompilation in Rust, in Java, in C compiled with -fwrapv, or anywhere else that defines overflow as wrapping or as a trap. There x + 1 < x is reachable and true at INT_MAX, so deleting the branch changes a defined result. The identical source text, the identical IR pattern, opposite legality — decided entirely by the language, which is the whole argument of [[semantics-drive-optimization]].
What to do when it really is the compiler
Reduce, then bisect, then report. Reduction is the highest-leverage step and the one most people skip: cvise and creduce shrink a failing translation unit to a handful of lines while an interestingness script keeps checking that the symptom survives. A report with a four-thousand-line file waits months; a report with eight lines gets a fix. llvm-reduce does the same for IR, which is what you want once you have located the failing pass.
Bisection has two axes and both are worth running. Bisecting versions — git bisect over the compiler tree, or clicking through released versions on Compiler Explorer — names the commit. Bisecting passes names the transformation: Clang's -mllvm -opt-bisect-limit=N disables every optimization after the Nth and binary-searches to the one that introduces the difference. Once you have both, the bug report writes itself.
Meanwhile, in production, the honest mitigation is usually to pin the toolchain and lower the optimization level for the affected translation unit. That is a real cost — see [[optimization-levels]] — and it is the correct trade while you are waiting for a fix you do not control.
- Establish the program is well-defined first. Sanitizers, then a second compiler, then the semantics flags.
- Reduce with
cvise/creduceagainst an interestingness script that checks the *symptom*, not the exit code. - Bisect versions to find the commit; bisect passes to find the transformation.
- Save the exact command line, target triple and compiler version. A miscompilation that reproduces on nothing is not a report.
- Mitigate by pinning the version and demoting the optimization level for one file, not the whole build.
How it works
The steps, in the order the compiler takes them.
- The frontend produces IR that already means something different from the source — a lowering bug, and the rarest kind, because the frontend is the most heavily tested part of a compiler.
- A middle-end pass applies a rewrite whose legality precondition it did not actually establish: it hoists a load past a store it could not prove independent, or folds an expression using arithmetic that differs from the target’s.
- An analysis returns an unsound answer — alias analysis says two pointers cannot alias when they can — and every consumer of that answer then performs a legal transformation on a false premise.
- Register allocation assigns two simultaneously-live values the same register, and a value is overwritten while still needed.
- Instruction selection picks an encoding whose semantics differ at an edge case: a shift whose count is taken modulo the word size, a conversion that saturates rather than wraps.
- The linker resolves a symbol to the wrong definition — one definition rule violated across translation units — and the program calls a function nobody thought was reachable.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A test passes at
-O0and fails at-O2, with no change to the source. The overwhelmingly likely cause is undefined behavior, and the second-likeliest is a genuine wrong-code bug. - Adding a
printfto debug it makes the symptom disappear, because the call is an optimization barrier and forces values into memory that were living in registers. - A bounds check or null check you can point to in the source is absent from the disassembly, and the program faults later in unrelated code.
- The program produces a subtly wrong number — off by one bit, or right except at the boundary — and nothing crashes. This is the worst case, because it ships.
- A build reproduces the failure on the CI machine and not on yours, because the two are running different compiler patch versions.
- Upgrading the compiler for unrelated reasons breaks a service that has been stable for two years, and the diff contains none of your code.
When it helps
- Knowing this class exists shortens the search enormously the one time in a career you actually hit it: you stop re-reading correct source.
- It makes the case for sanitizers and for a second compiler in CI concrete — both exist mostly to answer the "is my program even defined" question quickly.
- It is the argument for pinning toolchain versions and recording them in build metadata, which is otherwise easy to dismiss as ceremony.
When it hurts
- Reaching for it first. Suspecting the compiler before you have run a sanitizer wastes days and produces bug reports that maintainers correctly close.
- Using it as a licence for superstition — sprinkling
volatile, disabling optimization globally, or adding sleeps. Each of these hides symptoms and none of them fixes a cause. - In a codebase with heavy undefined behavior, where every optimization-level change looks like a compiler bug and the real answer is a long cleanup nobody wants to fund.
What it costs
Every one of these is paid by something.
- Compiling at a lower optimization level to dodge a suspected miscompilation buys immediate stability and pays in runtime performance and, more subtly, in divergence between what you test and what you ship.
- Running sanitizer builds in CI buys a definitive answer to the undefined-behavior question and costs build time, memory and typically a two-to-twentyfold runtime slowdown, so it usually cannot be the only build you run.
- Keeping a second compiler in CI buys independent confirmation and costs a second full build plus the ongoing work of keeping the source portable across two sets of warnings and extensions.
- Pinning a toolchain version buys reproducibility and pays it back as security debt: you are also pinning every unfixed bug in that version, and the upgrade you deferred gets harder every month.
What else you could do
What a different compiler or language does instead, and when that is better.
- Use a language with fewer undefined behaviors. Rust, Java and Go define integer overflow, array indexing and null dereference, which removes most of the triage table above — and pays for it in runtime checks or in a narrower set of legal optimizations.
- Run a verified compiler for the parts that matter. CompCert’s middle-end carries a machine-checked proof and is used exactly this way in avionics — see
[[verified-compilers]]— at the cost of a smaller language subset and slower generated code. - Validate each compilation rather than trusting the compiler:
[[translation-validation]]checks that this particular translation preserved semantics, which is cheaper than proving the optimizer correct and catches the bug in the build that has it. - Compile twice at different optimization levels and compare the outputs on your own test corpus. Crude, cheap, and it is the same idea as
[[differential-testing]]applied to your own program rather than to generated ones.
See it for yourself
The flag, dump or tool that shows you this directly.
- Prove your program is defined first:
clang -fsanitize=undefined,address -fno-omit-frame-pointer,gcc -fsanitize=undefined,valgrind --tool=memcheck, and for uninitialized readsclang -fsanitize=memory. - Change the semantics, not the pipeline:
-fwrapv(defined signed overflow),-fno-strict-aliasing,-fno-delete-null-pointer-checks. If any of these fixes it, the bug is in the source. - Reduce:
cviseorcreducewith an interestingness script;llvm-reduce --test=./check.sh bad.llonce you are working in IR. - Bisect the pipeline:
clang -mllvm -opt-bisect-limit=Nbinary-searches to the first pass that changes the result;opt -print-after-alldumps the IR after every pass so you can diff the two sides of it. - Compare compilers and versions side by side on Compiler Explorer (godbolt.org), which keeps dozens of GCC, Clang, MSVC and rustc releases for exactly this.
- Our own compiler asserts the absence of this bug class directly:
scripts/compilers-sim.test.tsruns every example with optimization off and on and requires identical output.
Plausible wrong readings
Stated the way a confident engineer states them.
- "My code broke when I turned on optimization, so the optimizer is buggy." Far more often the optimizer started relying on an assumption the language gave it and your program violated. Run the sanitizers before forming the sentence.
- "The tests pass, so there is no miscompilation." The tests were compiled by the same compiler. A wrong transformation applied to both the assertion and the code under test is invisible.
- "Compiler bugs are so rare I can ignore them." Rare per compilation, common per ecosystem. The reason your compiler has few of them is that people run fuzzers at it continuously.
- "Adding
volatilefixed it, so it was a compiler bug."volatilesuppresses optimization on that object. It hides races and undefined behavior just as effectively as it hides compiler bugs, and it is not a synchronization primitive.
Misconceptions
The claim, and what is actually true.
[[compiler-fuzzing]] is a subject rather than a hobby.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A miscompilation is when the compiler turns correct source into a program that does the wrong thing. It matters more than an ordinary bug because you cannot find it by reading your own code, and because your tests are compiled by the same compiler and so can be wrong in exactly the same way. If your program starts failing when you turn optimization on, the first suspect is not the compiler — it is undefined behavior in your program, which lets the compiler assume things that are not true.
practical
Triage in this order. Run the sanitizers. Try -fwrapv and -fno-strict-aliasing. Try a different compiler. If all three are clean and the failure persists, reduce the case with cvise until it is under twenty lines, then bisect the pass with -mllvm -opt-bisect-limit=N. Report it with the exact version and target triple. While you wait, pin the toolchain and drop the optimization level for the one file, and write the reason down in the build file so the next person does not silently undo it.
advanced
The interesting structural point is that miscompilation is a *composition* failure, not usually a single wrong rewrite. Individually correct passes fail together when one of them establishes a fact and another invalidates it without updating the metadata that recorded it — stale alias information, an unmaintained dominator tree, a loop-invariance claim that survives a transformation that moved the store. This is why [[ir-verification]] after every pass is worth its compile-time cost, and why translation validation, which re-establishes equivalence rather than trusting accumulated facts, catches bugs that pass-local unit tests structurally cannot.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
-O2 and above, and the exploitation grows version over version as analyses improve. This is a description of GCC, Clang, MSVC and rustc as they are, not a requirement: a conforming compiler is free to define more than the standard does, and several embedded compilers deliberately do.-mllvm -opt-bisect-limit is Clang-specific; GCC has -fdisable-tree-<pass> and -fdump-tree-all instead; MSVC exposes far less and reduction is correspondingly harder. The technique transfers; the flags do not.If you were asked this in an interview
- A service works at
-O0and produces wrong results at-O2. Walk me through your triage. - Why can a passing test suite fail to detect a miscompilation?
- Give me a transformation that is legal in C and a miscompilation in Rust, and say what decides the difference.
Connections
- Testing & Reliability Engineering — Delta debugging and automated test-case reduction as general techniquesReduction is what turns an unreportable failure into a fixable one, and the algorithm behind
cviseis not compiler-specific — it applies to any failing input with a checkable interestingness predicate. The general technique is owned there; applying it to translation units is ours.