Two Listings for the Same Code

Every example so far in this course has shown you assembly at one optimization level or another, usually noting in passing that -O2 would look different. This chapter is where we finally put the two side by side and study the gap between them. That gap is the optimizer's work, and learning to read it is one of the most useful things you can do with assembly.

The compiler has optimization levels, selected with a flag:

  • -O0 ("oh-zero") means no optimization. The compiler translates your code in the most literal, step-by-step way it can. This is the default for debug builds.
  • -O2 ("oh-two") turns on a large set of optimizations. This is what a typical release build uses.

The C++ source is identical at both levels. What changes is how much plumbing the compiler is willing to remove.

A Function Worth Comparing

Here is a small function that does a little arithmetic with two arguments:

int sumRange(int a, int b)
{
    int total{a + b};
    int doubled{total * 2};
    return doubled - a;
}

Three locals, three operations. Nothing clever. Let us compile it both ways.

The Unoptimized Version

At -O0:

sumRange(int, int):
	push	rbp
	mov	rbp, rsp
	mov	DWORD PTR -20[rbp], edi
	mov	DWORD PTR -24[rbp], esi
	mov	edx, DWORD PTR -20[rbp]
	mov	eax, DWORD PTR -24[rbp]
	add	eax, edx
	mov	DWORD PTR -4[rbp], eax
	mov	eax, DWORD PTR -4[rbp]
	add	eax, eax
	mov	DWORD PTR -8[rbp], eax
	mov	eax, DWORD PTR -8[rbp]
	sub	eax, DWORD PTR -20[rbp]
	pop	rbp
	ret

Fourteen instructions. Read it slowly and a pattern jumps out: almost every line touches the stack. The arguments arrive in edi and esi and are immediately stored into stack slots -20[rbp] and -24[rbp]. Each local (total, doubled) gets its own slot too, at -4[rbp] and -8[rbp]. Every time the code needs a value it has just computed, it stores it to the stack and then loads it straight back.

Look at this trio in the middle:

	add	eax, edx              ; compute total = a + b
	mov	DWORD PTR -4[rbp], eax ; store total to its stack slot
	mov	eax, DWORD PTR -4[rbp] ; ... and immediately load it back

The store and the very next load are completely redundant: the value is already in eax. But at -O0 the compiler does not try to notice this. It treats every named variable as a real location in memory and dutifully writes it out and reads it back. That literalness is exactly what makes -O0 easy to step through in a debugger, and exactly what makes it slow.

The Optimized Version

Now the same function at -O2:

sumRange(int, int):
	lea	eax, [rdi+rsi]
	add	eax, eax
	sub	eax, edi
	ret

Four instructions, and not a single one touches the stack. There is no prologue (push rbp / mov rbp, rsp) and no epilogue (pop rbp) because the function never needs a stack frame: every value lives in a register from start to finish.

Walk through it:

  • lea eax, [rdi+rsi] computes a + b in one step. (lea is normally an address calculator, but it doubles as a cheap way to add registers; you saw this trick in Chapter 2.) That is total.
  • add eax, eax doubles it. That is doubled.
  • sub eax, edi subtracts a. That is the return value, already sitting in eax.
  • ret returns.

The three locals never became memory at all. The optimizer kept them in registers, fused the work, and dropped the bookkeeping. Same result, a quarter of the instructions.

What the Optimizer Removed

Lining the two up, the differences fall into a few clear categories:

At -O0 At -O2
Full stack frame (push rbp / mov rbp, rsp / pop rbp) No stack frame at all
Each argument and local stored to its own stack slot Values kept in registers
Store-then-immediately-reload of every intermediate value No redundant memory traffic
14 instructions 4 instructions

None of this changed what the function computes. The optimizer is not allowed to change observable behavior; it is only allowed to reach the same answer with less work. Everything it removed here was pure overhead: memory traffic that existed only because -O0 translates each statement in isolation.

Why Both Levels Exist

If -O2 is so much leaner, why does -O0 exist at all? Two reasons.

First, debugging. At -O0, every variable has a real home in memory and every source line maps cleanly to a block of instructions, so a debugger can show you exactly what is happening. At -O2, variables live in registers that get reused, lines are reordered and fused, and whole computations vanish, which makes single-stepping confusing.

Second, build speed. Optimization takes time, so -O0 compiles faster, which matters during rapid development.

For reading assembly, the two levels teach different things. -O0 is where you learn the literal shape of a construct: the prologue, the stack slots, the store/reload rhythm. -O2 is where you learn what your code actually costs once the overhead is stripped away. Throughout this chapter we will keep showing both, because the contrast is where the insight lives.

Try It Yourself

In this lesson's exercise you will write a short function, run it, and then use the assembly panel to flip between -O0 and -O2. Counting the instructions yourself, and watching the stack traffic disappear, makes everything above concrete.

Key Takeaways

  • -O0 means no optimization: literal, step-by-step code with a full stack frame. -O2 enables a broad set of optimizations.
  • The C++ source is identical at both levels; only how much overhead the compiler removes changes.
  • At -O0, arguments and locals get their own stack slots, with constant store-then-reload of intermediate values.
  • At -O2, values stay in registers, the redundant memory traffic disappears, and short functions often need no stack frame at all.
  • The optimizer never changes the result; it only removes overhead to reach the same answer with fewer instructions.
  • -O0 exists for easier debugging and faster builds; -O2 is the typical release setting.