Part 3 · Reading compiler output
`-O0` is readable, and a lie
Every tutorial teaches unoptimised output because it maps to the source. That mapping is exactly what makes it useless for understanding real code.
What this lesson is trying to break (2)
- Unoptimised assembly shows what the code really does.
- Optimisation is a small polish on top of the -O0 shape.
Almost every assembly tutorial you will find uses -O0 output. There is a good
reason and it is also the reason you should be suspicious of it.
The contrast
int add3(int a, int b, int c) {
int t = a + b;
return t + c;
}
Compile it twice — once with the default -O2, then edit the flags to -O0 in
Compiler Explorer and compile again.
Varies: only the optimisation flag — the source is byte-identical
-O0 | -O2 | |
|---|---|---|
| Instructions | ~10 | ~3 |
Where do a, b, c live? | Stack slots. Stored on entry, reloaded before each use. | Registers, never written to memory |
Does t exist? | Yes — its own stack slot | No — folded away entirely |
| Frame pointer | Always | Omitted |
| Maps to source lines? | Almost one-to-one | Loosely at best |
| Good for | Debugging, reading the ABI | Everything you will actually ship |
-O0 stores every argument to the stack and reads it back before use. That is not inefficiency by accident — it is the point: a debugger can then find any variable at any moment.
At -O0 the compiler makes a promise: every variable you wrote has a home in
memory, and its value there is current at every statement boundary. That is what
makes single-stepping and inspecting variables work reliably. Paying for it means
a store and a reload around every use.
At -O2 that promise is dropped. Variables live in registers, or in several
registers at different times, or nowhere at all. This is why debugging optimised
code shows you <optimized out>: the variable genuinely has no location.
For add3 above, count the loads and stores in each version.
Roughly six at -O0, and zero at -O2.
At -O0: three stores (one per argument, on entry), then a load of a, a load of
b, a store of t, a load of t, a load of c… the exact count varies by
compiler but it is always “several”, and every one is provably unnecessary.
At -O2: none. Three registers in, one lea or two adds, one ret.
The instructive part is why the -O0 version looks so bad. It is not that the
compiler is stupid with optimisation off — it is that it has been asked to
maintain a debugging guarantee, and that guarantee costs exactly this. The
inefficiency is the feature.
Why teaching with -O0 backfires
The pedagogical argument for -O0 is obvious: each source line becomes a small
group of instructions, so you can see the correspondence and build confidence.
The problem is that the correspondence is the thing real code does not have.
Learn to read -O0 and you learn to expect:
- one storage location per variable → wrong, they live in registers and move
- source order preserved → wrong, instructions are reordered freely
- one function per function → wrong, small ones are inlined away
- loops that look like loops → wrong, they are unrolled, vectorised, and their counters replaced by pointer walks
Every one of those expectations has to be unlearned before you can read -O2,
which is where all the code you care about lives. So this course started at -O2
and will stay there.
When -O0 is the right tool
Not never — just not by default.
To see the ABI plainly. Arguments are visibly stored, locals visibly
allocated. If you want to understand 2.2 concretely,
-O0 shows the machinery instead of optimising it away.
To see what the source literally asked for. Occasionally you want to confirm that a construct means what you think before the optimiser has an opinion — useful when investigating whether an optimisation is legal.
To debug. Reliable variable inspection is worth the slowdown, which is why debug builds exist.
There is also a middle option worth knowing: -Og optimises while trying to
preserve debuggability. It is the sensible default for a debug build that still
has to run at a tolerable speed.
A tutorial you are reading shows a loop at -O0 and concludes that “each
iteration costs about 8 instructions, so 1000 iterations costs 8000”.
Write down every assumption in that sentence that fails at -O2. There are at
least three, and one of them is not about optimisation at all — it is fiction 2
from 0.2.
What you should now be able to say
-O0gives every variable a stack slot and reloads it before every use, to keep a debugging guarantee.- Its readability comes from a source correspondence that real code lacks, so the reading skill does not transfer.
- At
-O2variables live in registers or nowhere — hence<optimized out>. -O0is the right tool for seeing the ABI, checking what was literally requested, and debugging;-Ogis the compromise.