Making the Call Disappear

Calling a function is not free. A call instruction pushes a return address, jumps to the function, runs a prologue, does the work, runs an epilogue, and returns. For a function that does real work, that overhead is negligible. For a tiny helper, the bookkeeping can cost more than the function itself.

The optimizer's answer is inlining: instead of emitting a call, it copies the body of the small function directly into the place that called it. The function still exists in your source, but at the call site the call vanishes and its work appears in line. This lesson shows exactly what that looks like.

Two Functions, One Call Chain

Here is a tiny helper and a function that uses it:

int doubleIt(int x)
{
    return x * 2;
}

int addThenDouble(int a, int b)
{
    return doubleIt(a + b);
}

addThenDouble adds its two arguments and hands the sum to doubleIt. At -O0, the compiler does this literally, with a real call between the two functions.

addThenDouble(int, int):
	push	rbp
	mov	rbp, rsp
	sub	rsp, 8
	mov	DWORD PTR -4[rbp], edi
	mov	DWORD PTR -8[rbp], esi
	mov	edx, DWORD PTR -4[rbp]
	mov	eax, DWORD PTR -8[rbp]
	add	eax, edx
	mov	edi, eax
	call	doubleIt(int)
	leave
	ret

The key line is call doubleIt(int). addThenDouble computes a + b, puts it in edi (the first-argument register), and calls the helper. Control jumps away, doubleIt runs its own prologue and imul/add, and control comes back. The call is right there in the listing.

The Same Code, Inlined

Now compile both functions at -O2:

addThenDouble(int, int):
	add	edi, esi
	lea	eax, [rdi+rdi]
	ret

The call is gone. There is no jump to doubleIt anywhere in addThenDouble. Instead:

  • add edi, esi computes a + b directly in the argument register.
  • lea eax, [rdi+rdi] doubles that sum (rdi + rdi is sum * 2), which is exactly the body of doubleIt pasted in place.
  • ret returns.

The optimizer took the one line doubleIt contained, x * 2, and dropped it straight into addThenDouble. No return address pushed, no second prologue, no jump. The two-function call chain became three straight-line instructions.

It is worth noticing what is not here: there is no separate trip through doubleIt's own prologue and epilogue, because that code never runs as a distinct function from this call site. The helper's logic survives, but its function-call shell is gone.

doubleIt Still Exists

Inlining the call does not necessarily delete the function. At -O2, doubleIt is still emitted as its own standalone function too:

doubleIt(int):
	lea	eax, [rdi+rdi]
	ret

Why keep it? Because other translation units might call doubleIt, so the compiler still provides a real, callable copy. What inlining changed was the call site inside addThenDouble, not the existence of the helper. (If the compiler could prove nothing else ever calls doubleIt, it could delete the standalone copy too. That is dead code elimination, the subject of the next lesson.)

Why Inlining Is a Big Deal

Removing the call overhead is the obvious benefit, but it is rarely the most important one. The real prize is that inlining exposes the helper's code to the rest of the optimizer. Once doubleIt's body sits inside addThenDouble, the optimizer can fold it together with the surrounding code, propagate constants across what used to be a function boundary, and eliminate work that only becomes redundant when the two are seen together.

You saw a hint of this in the constant-folding lesson: optimizations cascade. Inlining is often the first domino. A constant passed into an inlined function can be propagated through its body and folded, sometimes collapsing an entire call into a single constant. That is why aggressive inlining is one of the most impactful things -O2 does, and why small helper functions in C++ cost nothing at run time once optimized.

Reading the Clue

When you are reading a -O2 listing and a function you expected to see called is simply not called anywhere, inlining is the usual explanation. The work did not disappear; look for it duplicated inside the caller. Conversely, a call that survives at -O2 tells you the optimizer chose not to inline, usually because the callee was large, recursive, or otherwise not worth duplicating. Spotting which calls remain and which were inlined tells you a lot about where a program actually spends its instructions.

Key Takeaways

  • Inlining replaces a call with a copy of the called function's body at the call site.
  • At -O0 you see a literal call; at -O2 a small helper's body often appears directly inside its caller, with no call at all.
  • In addThenDouble, the call doubleIt became a plain lea eax, [rdi+rdi]: the helper's x * 2 pasted in place.
  • Inlining a call does not necessarily delete the helper; a standalone copy may still be emitted for other callers.
  • The biggest benefit is not avoiding call overhead but exposing the helper's code to further optimization, letting folding and propagation cross the old function boundary.
  • A call missing at -O2 usually means inlining; a call that survives means the optimizer chose not to inline it.