Inlining
How small functions are absorbed into their callers and the call disappears.
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, esicomputesa + bdirectly in the argument register.lea eax, [rdi+rdi]doubles that sum (rdi + rdiissum * 2), which is exactly the body ofdoubleItpasted in place.retreturns.
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
callwith a copy of the called function's body at the call site. - At
-O0you see a literalcall; at-O2a small helper's body often appears directly inside its caller, with no call at all. - In
addThenDouble, thecall doubleItbecame a plainlea eax, [rdi+rdi]: the helper'sx * 2pasted 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
callmissing at-O2usually means inlining; acallthat survives means the optimizer chose not to inline it.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Inlining - Quiz
Test your understanding of the lesson.
Practice Exercises
Watch the Call Vanish
Complete the triple helper so it returns three times its argument. Once the output is correct, read the assembly panel and switch between optimization levels: at -O0, tripleThenAdd contains a literal 'call triple(int)' that jumps to the helper; at -O2 the call is gone because the optimizer inlined the helper's body straight into tripleThenAdd. Verify both.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!