Hello, and welcome to C++ Weekly. I'm your host, Jason Turner. In this episode I am going
to try to show you what link time optimization or inter-procedural optimization, also known as LTO
and IPO, can actually give you and why they can have a huge performance benefit in your code.
Okay, so I've set up a somewhat realistic example here, and please bear with me - I
am using a relatively new feature of Compiler Explorer that lets you set up an entire project
for your code base, so this might take a little bit of getting used to navigate everything here.
I have this project set up that is a CMake project in Compiler Explorer, and I've got a handful of
files. Now I am actually creating three different C++ files, and this is intentional because I need
to separate them in more kind of a real world scenario that you might see in your actual code.
So I've got this declarations header file and I'm just declaring a single function. It's called
"add". It adds two numbers and it returns the result. Then in my implementation file I have this
"add" function and it looks exactly like we would expect it to - it adds two numbers and returns the
result. Then in file1 I've got some function called "get_value", and in file2 I've just got
this function called "get_value_2". So I've got just multiple ways of calling this same thing,
and we can see here that "get_value_2" is being called from main and it calls the "add" function,
which has an "add", which has an "add"; and really we're just adding x + x + y + z here.
This should be pretty straightforward, and we have the values known at compile time, so that
should be 1 + 1 + 2 + 3, which should be 7, but if we look at our fully optimized build output that
I have here with the -O3 compilation and I look at main, I can see that main is actually calling
"get_value_2", and in "get_value_2" I am calling "add". I am calling "add". I am calling "add",
and this becomes a surprisingly expensive function to call, and in fact if we look at the other
simple "get_value" function it is having to call "add" as well, and that effectively is because
we have declared these things in different translation units. They're in different .cpp
files, so the compiler links these things together and we can actually look at this a little bit.
Okay. So I have here disassembled the "file1.cpp.o". Now it's not giving me the actual
name of the function that it's calling here, it's giving me, like, a placeholder, and it's saying,
"From your link table I want you to call this "get_value" function." We don't get
full visibility into what exactly is happening here, but we know that "get_value" is jumping to
the function "add", and if I switch over to file2 then we can see that we're getting "add" called
three times, which is exactly what we told it to do. You know, this is probably a little bit
frustrating when you look at it from this very simple perspective, but we gave it three different
.cpp files. It compiles them separately; it links them together. It has no way of doing cross
.cpp file optimizations here. Well it doesn't by default,
and that's where inter-procedural optimization, or link time optimization, comes into play. So
we actually have this feature, and you can see it's currently commented out,
from CMake called "CheckIPOSupported", so they're referring to it by this inter-procedural
optimization name here, and I have checked to see if it's supported and everything, and then I have
the option here to say, "For the target called "test" I want you to enable the property called
"interprocedural_optimization". I want you to set this to true." Now I meant to start this video by
saying this is not going to be a very technical, in-depth view of what IPO actually does,
this is an overview of what it can give you. So we see here that we simply can't inline this very,
very simple function call. Now when I un-comment the "set_property" line we're going to get
inter-procedural optimization in our code. What's going to happen is the compiler is actually not
going to compile three separate object files in the traditional sense anymore, it's going
to make an intermediate representation of each of these object files and then at link time is
when it's going to actually generate our x86 code that we would want to execute. So the end result
is that I'm not really going to be able to see so much, and you can see here four lines filtered
from this "file2.cbp.o" and there's just simply nothing for it to tell us,
but I can go back to what I had originally, which was the binary. We can disassemble the binary,
and I can now see that main simply returns seven. That's what we knew
the value should have been all along, and since we did inter-procedural optimization
and we have this -O3 build, we're not even seeing the rest of the functions right now.
That's it. Our entire program becomes "return 7 from main," which, again, is really what we
wanted to see in the first place; and just so you know - so if I throw in an unknown here
the compiler is still going to do all of its normal magic that it can do, and now it says,
"Take this first parameter, whatever "argc" is, and add 5 to it." So it knows that that was 1
plus 1 is 2, plus 3 is 5, plus whatever "argc" happens to be is going to be the return value
for main now. So that in a very small nutshell is what inter-procedural optimization can give you,
and it's going to be a trade-off. You're going to get probably faster compile times because
the optimizer doesn't run on each .o file, and then you're going to get probably slower
link times because now it has to try to find all of the optimizations that can go across function
call boundaries and then do the full optimization that needs to occur to actually generate your
binary. So yeah, I hope this episode of C++ Weekly was interesting. We will be using this
concept again very soon in an upcoming episode so I really wanted to explain the concept here first
so that we could take advantage of it in a follow-up episode. Make sure you subscribe.