What a Compiler Actually Does to Your Code

Contents
For years I assumed a compiler was a very thorough translator — that it read my C code top to bottom and produced roughly equivalent machine instructions in the same order, the way translating a paragraph from one language to another keeps the sentences in the same sequence. That mental model survives right up until the first time you compile something with optimisations enabled, look at the disassembly, and find that half your function has vanished, a loop you wrote has been unrolled into straight-line code, and a variable you were certain would exist at runtime simply isn’t there anymore. None of that is the compiler malfunctioning. It’s the compiler doing exactly what it’s designed to do, which has much less to do with “translation” than most programmers assume, and much more to do with building a model of what your program means and then generating whatever machine code produces that same meaning fastest.
The pipeline, and why each stage exists
A compiler’s job splits into distinct phases, and the reason it’s structured this way rather than as one big pass is that each phase answers a genuinely different question about your code.
| |
The lexer and parser exist to turn text — which computers have no native concept of “meaning” for — into a tree structure that captures the actual logical relationships in your code, independent of how you happened to format it. This is the first and most important thing a compiler does that a translator doesn’t: it discards your original text entirely, immediately, and works from that point forward purely on the structure it extracted. Your variable names, your comments, your whitespace — none of it exists past this stage in any form the rest of the pipeline can see, which is exactly why a compiled binary can’t be turned back into readable source code without a decompiler doing a lot of very lossy guesswork.
Why optimisation looks like it’s rewriting your program
The intermediate representation is where the real transformation happens, and it’s worth understanding that “optimisation” here doesn’t mean one clever pass that makes code faster — it means dozens of narrow, independent transformations, each one recognising a specific pattern and replacing it with an equivalent but cheaper one. A few of the most consequential:
- Dead code elimination removes any computation whose result is never used, which is why a variable you assign and never read can disappear from the output entirely — the compiler proved it has no effect on the program’s observable behaviour, so keeping it would just waste a CPU cycle for nothing.
- Constant folding computes anything that can be determined at compile time —
const int x = 2 + 2;never involves an addition at runtime, because the compiler already knows the answer is 4 before your program ever executes. - Loop unrolling duplicates a loop’s body several times to reduce the overhead of the loop condition check and branch, trading a larger binary for fewer conditional jumps at runtime — a real speed win on hot loops, at the cost of instruction cache pressure if taken too far.
- Inlining replaces a function call with a copy of the function’s actual body at the call site, eliminating call overhead entirely and, crucially, opening the door for further optimisations across what used to be a function boundary, since the compiler can now see both sides at once.
Each of these is individually simple and provably correct — the compiler only applies a transformation it can prove preserves the program’s observable behaviour under the language’s specific rules. What looks, from the outside, like the compiler “rewriting your program” is really dozens of small, locally-justified rewrites compounding, and the reason the final result can look so unrecognisable is that these passes feed into each other: inlining exposes new opportunities for dead code elimination, which exposes new opportunities for constant folding, and so on until nothing further can be simplified.
Undefined behaviour is where this gets dangerous
This is the part that trips up experienced programmers, not beginners: a compiler is entitled to assume your program never invokes undefined behaviour, and it will use that assumption aggressively during optimisation, sometimes in ways that feel like it’s punishing you for a bug rather than just failing to catch it. Signed integer overflow in C is undefined behaviour, for instance, which means a compiler is legally permitted to assume it never happens, and can use that assumption to eliminate a bounds check that only existed to guard against it — the check simply vanishes from the optimised binary, because from the compiler’s perspective, a program relying on overflow to make that check meaningful was already broken before optimisation touched it.
| |
This isn’t a compiler bug and reporting it as one to a maintainer will get you a polite explanation of the language standard instead of a fix. It’s the direct, documented consequence of a language specification leaving a behaviour undefined and a compiler taking that as licence to optimise on the assumption it never occurs. The fix is always in the source, not the compiler flags: use unsigned arithmetic where wraparound is actually intended, or an explicit overflow-checking builtin where it isn’t.
Why the same source produces different binaries on different compilers
Two compilers given identical, standard-conforming source code are not obligated to produce identical machine code, and this surprises people who think of a language standard as a precise specification of behaviour rather than what it actually is: a specification of observable behaviour, with enormous latitude left for how that behaviour gets achieved. GCC and Clang, compiling the same C++ file with the same optimisation level, will routinely choose different inlining thresholds, different loop unrolling factors, and different register allocations, because the standard says what the program must do, not how efficiently or in what internal order it must do it. This is why “it works with GCC but crashes with Clang” is almost never a compiler bug in either compiler — it’s undefined or unspecified behaviour in the source that happens to produce different, both-legal results under each compiler’s own set of heuristics.
This latitude is also where compiler version upgrades quietly change program behaviour without anyone touching a line of source. A newer version of the same compiler, with an improved optimisation pass, can suddenly start applying a transformation to a pattern it previously left alone, and if that pattern relied on undefined behaviour to work “by accident” on the older compiler, the newer one can legitimately break it while introducing nothing that violates the language standard. This is the practical reason serious codebases pin their compiler version in CI rather than always building against “latest”: the compiler’s internal heuristics are explicitly not part of its guaranteed contract with your code, and only its observable output is.
The register allocation problem, briefly
One specific piece of code generation is worth understanding on its own because of how much it explains about generated assembly looking unfamiliar: register allocation, the process of deciding which of a CPU’s small number of physical registers holds which of your program’s (potentially enormous number of) variables at any given point in execution. Source code can declare as many variables as it likes; a CPU has a fixed, small set of registers — commonly somewhere between eight and thirty-two general-purpose ones depending on architecture — and the compiler has to map an arbitrarily large set of logical variables onto that small physical set, reusing registers for different variables at different points in the program whenever their live ranges don’t overlap.
This is a genuinely hard combinatorial problem (formally related to graph colouring), and it’s why the same variable in your source can end up in a completely different register, or spilled to the stack and back, depending on what else is alive in the same region of code at the same time. Reading generated assembly without knowing this makes the register choices look arbitrary or even buggy; knowing it explains why a seemingly unrelated change elsewhere in a function — adding one more local variable, say — can shift register assignments and therefore the exact instruction sequence for code you didn’t touch at all.
Troubleshooting the “my code behaves differently after optimisation” moment
Debug build works, release build crashes or behaves differently. This is almost always undefined behaviour that debug builds happen not to expose — an uninitialised variable that debug builds zero-fill by convention, or a data race that debug builds’ extra timing overhead happens to mask. Building with sanitizers enabled (-fsanitize=address,undefined) will usually surface the actual bug far faster than staring at the optimised assembly.
A variable seems to disappear when inspecting the binary in a debugger. If it’s never read after being written, dead code elimination removed it — check whether the value was actually supposed to be used somewhere, because the compiler proving it unused is often the compiler correctly spotting a real logic bug.
Adding a printf for debugging changes whether the bug reproduces. This is a strong signal of either a data race or undefined behaviour whose effects depend on register allocation and instruction scheduling, both of which a stray function call can perturb. Reach for a thread sanitizer or undefined-behaviour sanitizer rather than more print statements at this point — the print statement is a symptom-mover, not a fix.
Compiler warnings you ignored turn into runtime bugs at higher optimisation levels. Treat warnings as load-bearing, especially anything about implicit conversions, uninitialised values or signed comparison — many of the “the optimiser broke my code” reports online trace back to a warning that correctly identified the undefined behaviour weeks before optimisation exposed it.
What “-O2 versus -O3” is actually choosing between
Optimisation levels are frequently treated as a single dial from “slow” to “fast,” and that framing hides what’s actually being traded. -O2 enables a broad set of optimisation passes that are considered safe and reliably beneficial across almost any codebase — inlining small functions, eliminating dead code, folding constants. -O3 adds more aggressive passes on top, particularly around loop vectorisation and more expansive inlining heuristics, which can produce meaningfully faster code on numerically-heavy workloads and can just as easily produce a larger binary with worse instruction cache behaviour on code that was never bottlenecked on the computations -O3 targets in the first place. Neither level is unconditionally “more correct” than the other with respect to a well-defined program; what changes is purely how much compile time the compiler spends searching for transformations, and how willing it is to trade code size for raw throughput on the patterns it recognises.
This is why blindly reaching for the highest optimisation level isn’t a free win in the way it’s sometimes treated as being. A larger binary from aggressive inlining and loop unrolling can spill more of a hot loop out of the CPU’s instruction cache than a more conservative build would, turning a theoretical speedup into a measured slowdown on the actual hardware the code ends up running on. Profiling the specific workload, rather than assuming the highest number on the optimisation dial is automatically best, is the only way to know which one actually wins for a given program.
Is understanding this actually useful, day to day
For most application-level programming, no — you’ll never need to reason about intermediate representations to ship a working feature, and the abstraction exists precisely so you don’t have to. But the moment you’re debugging a release-only crash, chasing a performance regression, or reading how a language like Rust makes certain classes of undefined behaviour impossible to write in the first place, this model is the difference between guessing and actually understanding what changed. The compiler was never translating your code. It was building a proof of what your code means, and then generating the cheapest machine instructions that satisfy that proof — which is also, not coincidentally, exactly the same content-addressed, structure-over-text discipline that makes Git’s storage model work: both tools care about what your code actually represents, not the text you happened to type to express it.




