The Compiler Reordered It Before the CPU Did
Between the line you wrote and the work the machine performs sit two independent reordering layers: a compiler that transforms code under the language's rules, and a processor that executes the result out of order under the architecture's rules. Each preserves its own notion of observable behaviour, and neither preserves the order you wrote.
Software view, hardware view
The gap between what you wrote and what the machine does is where this whole domain lives.
Two layers, two different rulebooks
The compiler operates under an *as-if* rule: it may perform any transformation provided the program behaves as if it had executed according to the language's abstract machine. Crucially, "behaves as if" is defined in terms of a specific list of observable effects — typically I/O, volatile accesses and synchronisation — and *not* in terms of which instructions exist or what order they appear in. Everything not on that list is fair game.
The processor then operates under the architecture's memory model, which is a different and generally weaker contract. It guarantees that a *single* thread observes its own operations as if in program order, and makes much narrower promises about what other threads see and when (Hardware Memory Models Are Not Language Memory Models). Within a thread it will happily execute instructions as their operands become ready rather than in sequence (Out-of-Order Execution).
These layers are independent. A barrier that constrains the CPU does not constrain the compiler, and a compiler barrier does not constrain the CPU. This is precisely why language-level atomics exist: a memory_order annotation has to emit both the right machine instruction *and* prevent the compiler from moving things across it. Getting one and not the other produces code that works under -O0 and breaks under -O2, or works on x86-64 and breaks on AArch64.
What the compiler is allowed to do to your loop
The list is longer than most people expect. It may hoist a loop-invariant computation out entirely, keep a variable in a register and never write it to memory, unroll the loop, vectorize it (Auto-Vectorization: Verify, Do Not Assume), fuse two loops into one or split one into two, replace a multiply with shifts, evaluate a constant expression at compile time — and delete code whose result is never observed, which is the transformation that silently destroys naive microbenchmarks (Every Way a CPU Microbenchmark Lies).
What it may *not* do is change observable behaviour as the language defines it. The subtlety is that the language's definition is narrower than intuition: the time a loop takes is not observable, so a loop with no observable effect may be removed. Reading an uninitialised value or overflowing a signed integer may be undefined, in which case the compiler is entitled to assume it never happens and optimise on that basis — which is how a bounds check can legally vanish.
The practical consequence is that source-level operation counting predicts neither the instruction count nor the cost. The only reliable way to know what the machine will run is to look at what the compiler emitted, which is a routine and underused activity: modern toolchains make the generated assembly easy to inspect, and reading it settles arguments that speculation cannot.
1// As written2sum = 03for i in 0..n:4 sum += a[i] * 25 6// Legal: strength reduction, invariant hoisting, unrolling7sum = 08for i in 0..n step 4:9 sum += (a[i] << 1) + (a[i+1] << 1)10 + (a[i+2] << 1) + (a[i+3] << 1)11 12// Legal: vectorized - four lanes per instruction13vsum = [0,0,0,0]14for i in 0..n step 4:15 vsum += load4(a + i) << 116sum = horizontal_add(vsum)17 18// Legal if `sum` is never observed afterwards:19// (the entire loop is removed)Where this bites in practice
The three recurring failures are worth naming. Microbenchmarks that measure nothing: the timed loop computes a value the program never uses, the compiler deletes it, and the benchmark reports an impossibly fast result. Concurrency bugs that appear only under optimisation: a flag polled in a loop without an atomic or volatile annotation gets hoisted into a register, and the thread never sees the update. Portability failures: code that relies on x86-64's relatively strong ordering breaks on AArch64, which permits more reordering — the bug was always there, and the stronger hardware was hiding it (Why Your Loads and Stores Happen Out of Order).
The unifying diagnosis is that in each case the programmer had a contract in mind that the language did not actually provide. The fix is never to guess harder; it is to state the requirement in terms the compiler understands — an atomic with the ordering you need, a volatile for a memory-mapped register, a compiler barrier, or consuming the benchmark result so it cannot be deleted.
None of this is an argument against optimisation, and it is emphatically not an argument for writing assembly. The compiler is better at instruction selection and scheduling than almost anyone, and its transformations are why high-level code performs at all. The argument is only that it is a *transformation layer with rules*, and knowing the rules is what lets you predict its output and debug its results.
| Symptom | Layer responsible | What constrains it | What does not |
|---|---|---|---|
| Benchmark loop runs impossibly fast | Compiler removed dead code | Consuming the result; a compiler-barrier-style sink | A CPU memory barrier |
| Polled flag never observed by a thread | Compiler hoisted the load into a register | Atomic or volatile access with suitable ordering | Adding a sleep, which usually just hides it |
| Works on x86-64, fails on AArch64 | CPU reordered stores or loads | Language atomics emitting the right barriers | Compiler-only barriers |
| Debug build correct, release build wrong | Compiler optimisation exposed undefined behaviour | Fixing the undefined behaviour itself | Lowering the optimisation level, which only hides it |
| Profiler blames a line that looks trivial | Compiler merged or moved surrounding work | Reading the emitted assembly to see what merged | Trusting the source-to-line mapping |
Key points
- Two independent layers reorder your program: the compiler under the language's as-if rule, then the CPU under the architecture's memory model.
- The as-if rule preserves a narrow list of observable effects; execution time and instruction order are not on it.
- A compiler barrier does not constrain the CPU and a CPU barrier does not constrain the compiler — language atomics exist to address both at once.
- Source-level reasoning cannot predict instruction count or cost; reading the emitted assembly can, and is easier than most engineers assume.
- Bugs that appear only at higher optimisation levels or only on weaker-ordered hardware are almost always a missing contract, not a compiler bug.
Follow the mechanism
The path through the machine, hop by hop — and the conclusions it invites that are wrong.
- 1Source → abstract machine: the language defines which effects are observable, and everything else becomes negotiable.
- 2Abstract machine → optimizer: the compiler applies transformations that preserve those effects, freely reordering, merging and deleting the rest.
- 3Optimizer → machine instructions: what is emitted may bear little resemblance to the source structure, having been unrolled, vectorized or elided.
- 4Machine instructions → CPU front end: the processor fetches them in order and then schedules execution by operand readiness, not by position (Out-of-Order Execution).
- 5CPU → observed behavior: results retire in program order within a thread, while other threads may observe a weaker ordering (Hardware Memory Models Are Not Language Memory Models).
- • "The compiler has a bug" — overwhelmingly it is undefined behaviour in the source being exploited exactly as the standard permits.
- • "Lowering the optimisation level fixed it" — that hides the defect; the contract is still missing and will resurface.
- • "volatile makes it thread-safe" — volatile constrains the compiler, not the CPU, and provides no inter-thread ordering guarantees in most languages.
- • "The assembly will look like my source" — after inlining, unrolling and vectorization the correspondence is frequently unrecognisable.
Consequences, controls and cost
- • Timing a loop whose result is unused measures the empty loop the compiler left behind, or nothing at all.
- • Concurrency code without explicit atomics can work in debug builds and fail in release builds, on the same machine.
- • Code tested only on strongly-ordered hardware can carry latent ordering bugs that surface on a different architecture.
- • Profiler attribution to specific source lines is approximate, because the instructions at that address may have come from several lines.
- • State ordering requirements explicitly with language atomics rather than relying on incidental hardware behaviour.
- • Read the emitted assembly when performance or codegen is in question — it is the ground truth and it is one command away.
- • Prevent dead-code elimination in benchmarks by consuming results in a way the compiler cannot see through.
- • Test concurrency on weakly-ordered hardware, or under a tool that models weak ordering, rather than trusting a strong-ordered machine.
- • Treat "only breaks with optimisations on" as a signal of undefined behaviour in your code, not of a compiler defect.
- • Inspect the generated assembly for the function in question and confirm the instructions you expected actually exist.
- • Compare instruction counts across optimisation levels; a loop that vanished shows up as a step change.
- • Use a thread sanitizer or a weak-memory model checker to expose ordering assumptions that a strong machine masks.
- • Diff generated code before and after a source change to confirm the change reached the machine at all.
- • Reading assembly is a real skill with a real learning cost, and it is architecture-specific.
- • Constraining the compiler with barriers and atomics gives up optimisation opportunities and can measurably slow hot paths.
- • Writing code defensively against aggressive optimisation can make it less idiomatic and harder for the next reader.
- • Testing on multiple architectures costs infrastructure that small projects often cannot justify.
Scope
§224 — what these claims are specific to.
- GENERALThe two-layer reordering model applies to every optimising compiler and every out-of-order processor, though the precise permissions differ by language and by architecture.
- ISA-SPECIFICHow much the CPU may reorder is defined by the architecture: x86-64 is comparatively strong, while AArch64 and POWER permit substantially more, so identical source can behave differently across them.
- SIMPLIFIEDThe pseudocode transformations are illustrative of legal optimisations rather than a description of any particular compiler's pass ordering or output.
Misconceptions
Apply it
Where the rest of this lives
How a compiler represents your program internally, which passes run in what order, and how each language defines its abstract machine and its observable effects — this domain assumes the transformation happens and reasons about its consequences for the hardware.
Which orderings a language guarantees between threads, and how to reason about interleavings — here we cover only the hardware's contribution to the reordering, not the correctness argument built on top of it.