Optimization without Inlining
Inlining is one of the most important compiler optimizations. We can often write abstractions and thin wrapper functions without incurring any performance penalty, because the compiler will expand the method for us at call site.
If a function is not inlined, conventional wisdom says that the compiler has to assume that the method can modify any global state and change the memory behind any pointer or reference that might have “escaped”.
In this short post, I’ll demonstrate exactly this effect. Furthermore, we will see that even if a function is not inlined, as long as the implementation is visible, some optimizations are still performed and sometimes to great effect.
Example
Let us consider this simple test
function,
// implemented in different TU or otherwise linked in
int foo(int n);
int test(int const& n)
{
auto sum = 0;
sum += foo(n);
sum += foo(n);
return sum;
}
which results in the following assembly:
test(int const&):
push rbp
push rbx
push rax
mov rbx, rdi
mov edi, dword ptr [rdi] // load n from memory
call foo(int) // call foo
mov ebp, eax
mov edi, dword ptr [rbx] // load n from memory _again_
call foo(int) // call foo
add eax, ebp
add rsp, 8
pop rbx
pop rbp
ret
Unsurprising, foo
is called twice.
For all we know, it has important side effects that have to be executed twice.
Maybe a bit more subtle: n
had to be reloaded from memory, because the first call to foo
might have changed the memory.
(For example, n
might be a reference to a global int
that foo
happens to increment.)
This is really the worst case for the compiler.
Nothing about the inner workings of foo
is known.
On the other end of the spectrum, we have a small, known foo
:
int foo(int n)
{
return n * n;
}
int test(int const& n)
{
auto sum = 0;
sum += foo(n);
sum += foo(n);
return sum;
}
which results in the following assembly:
test(int const&):
mov eax, dword ptr [rdi] // load n from memory
imul eax, eax // tmp = n*n
add eax, eax // return tmp + tmp
ret
Not only is the call to foo
inlined, further optimizations made sure that n * n
is not computed twice.
To Inline or not to Inline
If foo
is part of a different TU, the compiler can obviously not inline the call.
Should you have enough patience, link-time optimization might come to the rescue.
(It’s usually really expensive, so I can only really recommend it for CI building release versions.)
However, there are many other reasons why foo
is not inlined.
In general, inlining is a double-edged sword.
Each inlined function increases the pressure on the instruction cache and makes the parent function harder to analyze.
More local variables mean more register spills.
All these can negatively affect performance and compilers have various heuristic to try to make good decisions.
Most small functions will get inlined by default. As the complexity of the function body rises, this will stop at some point. Recursion usually prevents inlining, though compilers are definitely able to inline recursive functions via tail-call elimination.
We can artificially prevent inlining by declaring the function __declspec(noinline)
(for msvc) or __attribute__((noinline))
(for gcc/clang):
__attribute__((noinline)) int foo(int n)
{
return n * n;
}
int test(int const& n)
{
auto sum = 0;
sum += foo(n);
sum += foo(n);
return sum;
}
which results in the following assembly:
test(int const&):
mov edi, dword ptr [rdi] // load n from memory
call foo(int) // tmp = foo(n)
add eax, eax // return tmp + tmp
ret
So, obviously foo
was not inlined.
However, the compiler could still see and analyze it.
It came to the conclusion that foo
does not read or modify global state.
foo
was flagged as a pure function.
Pure functions are nice.
Pure functions return the same result if given the same input.
Pure functions do not modify global state, they work solely on their input values.
Thus, foo(n) + foo(n)
was simplified to tmp = foo(n); tmp + tmp
, even without inlining.
Fun with Loops
The difference becomes even larger with loops:
// different TU
int foo(int n);
int test(int const& n)
{
auto sum = 0;
for (auto i = 0; i <= n; ++i)
sum += foo(n);
return sum;
}
which results in the following assembly:
test(int const&):
push rbp
push r14
push rbx
mov r14, rdi
mov edi, dword ptr [rdi]
xor ebp, ebp
test edi, edi // n < 0 case
js .LBB0_3
mov ebx, -1
.LBB0_2: // loop body begin
call foo(int) // call foo
add ebp, eax
mov edi, dword ptr [r14] // reload n from memory
inc ebx
cmp ebx, edi
jl .LBB0_2 // loop body end
.LBB0_3:
mov eax, ebp
pop rbx
pop r14
pop rbp
ret
With no information, the compiler has to call foo
(and load n
from memory) in each iteration.
Contrast this with:
__attribute__((noinline)) int foo(int n)
{
return n * n;
}
int test(int const& n)
{
auto sum = 0;
for (auto i = 0; i <= n; ++i)
sum += foo(n);
return sum;
}
which results in the following assembly:
test(int const&):
mov edx, dword ptr [rdi]
test edx, edx
js .L5 // special case n < 0
mov edi, edx
call foo(int) // tmp1 = foo(n)
imul edx, eax // tmp2 = tmp1 * n
add eax, edx // return tmp2 + n
ret
.L5:
xor eax, eax // return 0
ret
So gcc is able to optimize the whole thing to basically foo(n) * (n + 1)
, without inlining.
Funnily enough, clang tries (and fails) to be clever with lots of SIMD.
Conclusion
This is not a long post, but it shows that while inlining is a very important optimization, a non-inlined function is not the end of the world optimization.
As long as the function implementation is visible, compilers can and will analyze them so that they don’t have to assume the worst case.
This, in turn, re-enables many optimizations such as value numbering, common subexpression elimination, and loop-invariant code motion.
PS: gcc and clang have a variety of function attributes that can be used to enable optimizations, even if the implementation is in a different TU.
The two most important ones are __attribute__((const))
and __attribute__((pure))
.
(Title image from unsplash)