The following content is provided under a Creative
Commons license.
Your support will help MIT OpenCourseWare
continue to offer high-quality educational resources for free.
To make a donation or to view additional materials
from hundreds of MIT courses, visit MIT OpenCourseWare
at ocw.mit.edu.
CHARLES E. LEISERSON: Hi, it's my great pleasure
to introduce, again, TB Schardl.
TB is not only a fabulous, world-class performance
engineer, he is a world-class performance meta engineer.
In other words, building the tools and such to make it
so that people can engineer fast code.
And he's the author of the technology
that we're using in our compiler, the taper
technology that's in the open compiler for parallelism.
So he implemented all of that, and all the optimizations,
and so forth, which has greatly improved the quality
of the programming environment.
So today, he's going to talk about something near and dear
to his heart, which is compilers,
and what they can and cannot do.
TAO B. SCHARDL: Great, thank you very
much for that introduction.
Can everyone hear me in the back?
Yes, great.
All right, so as I understand it,
last lecture you talked about multi-threaded algorithms.
And you spent the lecture studying those algorithms,
analyzing them in a theoretical sense,
essentially analyzing their asymptotic running times, work
and span complexity.
This lecture is not that at all.
We're not going to do that kind of math
anywhere in the course of this lecture.
Instead, this lecture is going to take a look at compilers,
as professor mentioned, and what compilers can and cannot do.
So the last time, you saw me standing up here
was back in lecture five.
And during that lecture we talked
about LLVM IR and x8664 assembly,
and how C code got translated into assembly code via LLVM IR.
In this lecture, we're going to talk
more about what happens between the LLVM IR and assembly
stages.
And, essentially, that's what happens when the compiler is
allowed to edit and optimize the code in its IR representation,
while it's producing the assembly.
So last time, we were talking about this IR,
and the assembly.
And this time, they called the compiler guy back,
I suppose, to tell you about the boxes in the middle.
Now, even though you're predominately dealing with C
code within this class, I hope that some of the lessons from
today's lecture you will be able to take away into any job that
you pursue in the future, because there are a lot
of languages today that do end up being compiled, C and C++,
Rust, Swift, even Haskell, Julia, Halide,
the list goes on and on.
And those languages all get compiled
for a variety of different what we
call backends, different machine architectures, not just x86-64.
And, in fact, a lot of those languages
get compiled using very similar compilation technology
to what you have in the Clang LLVM compiler
that you're using in this class.
In fact, many of those languages today
are optimized by LLVM itself.
LLVM is the internal engine within the compiler
that actually does all of the optimization.
So that's my hope, that the lessons you'll learn here today
don't just apply to 172.
They'll, in fact, apply to software
that you use and develop for many years on the road.
But let's take a step back, and ask ourselves,
why bother studying the compiler optimizations at all?
Why should we take a look at what's
going on within this, up to this point, black box of software?
Any ideas?
Any suggestions?
In the back?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: You can avoid manually
trying to optimize things that the compiler will
do for you, great answer.
Great, great answer.
Any other answers?
AUDIENCE: You learn how to best write
your code to take advantages of the compiler optimizations.
TAO B. SCHARDL: You can learn how
to write your code to take advantage of the compiler
optimizations, how to suggest to the compiler what it should
or should not do as you're constructing
your program, great answer as well.
Very good, in the front.
AUDIENCE: It might help for debugging
if the compiler has bugs.
TAO B. SCHARDL: It can absolutely
help for debugging when the compiler itself has bugs.
The compiler is a big piece of software.
And you may have noticed that a lot of software contains bugs.
The compiler is no exception.
And it helps to understand where the compiler might have made
a mistake, or where the compiler simply just
didn't do what you thought it should be able to do.
Understanding more of what happens in the compiler
can demystify some of those oddities.
Good answer.
Any other thoughts?
AUDIENCE: It's fun.
TAO B. SCHARDL: It's fun.
Well, OK, so in my completely biased opinion,
I would agree that it's fun to understand
what the compiler does.
You may have different opinions.
That's OK.
I won't judge.
So I put together a list of reasons
why, in general, we may care about what
goes on inside the compiler.
I highlighted that last point from this list, my bad.
Compilers can have a really big impact on software.
It's kind of like this.
Imagine that you're working on some software project.
And you have a teammate on your team
he's pretty quiet but extremely smart.
And what that teammate does is whenever that teammate gets
access to some code, they jump in
and immediately start trying to make that code work faster.
And that's really cool, because that teammate does good work.
And, oftentimes, you see that what the teammate produces
is, indeed, much faster code than what you wrote.
Now, in other industries, you might just sit back
and say, this teammate does fantastic work.
Maybe they don't talk very often.
But that's OK.
Teammate, you do you.
But in this class, we're performance engineers.
We want to understand what that teammate did to the software.
How did that teammate get so much performance out
of the code?
The compiler is kind of like that teammate.
And so understanding what the compiler does
is valuable in that sense.
As mentioned before, compilers can save you
performance engineering work.
If you understand that the compiler can
do some optimization for you, then you
don't have to do it yourself.
And that means that you can continue
writing simple, and readable, and maintainable code
without sacrificing performance.
You can also understand the differences between the source
code and whatever you might see show up in either the LLVM
IR or the assembly, if you have to look
at the assembly language produced for your executable.
And compilers can make mistakes.
Sometimes, that's because of a genuine bug in the compiler.
And other times, it's because the compiler just
couldn't understand something about what was going on.
And having some insight into how the compiler reasons about code
can help you understand why those mistakes were made,
or figure out ways to work around those mistakes,
or let you write meaningful bug reports to the compiler
developers.
And, of course, understanding computers
can help you use them more effectively.
Plus, I think it's fun.
So the first thing to understand about a compiler
is a basic anatomy of how the compiler works.
The compiler takes as input LLVM IR.
And up until this point, we thought of it
as just a big black box.
That does stuff to the IR, and out pops more LLVM IR,
but it's somehow optimized.
In fact, what's going on within that black box
the compiler is executing a sequence
of what we call transformation passes on the code.
Each transformation pass takes a look at its input,
and analyzes that code, and then tries
to edit the code in an effort to optimize
the code's performance.
Now, a transformation pass might end up running multiple times.
And those passes run in some order.
That order ends up being a predetermined order
that the compiler writers found to work
pretty well on their tests.
That's about the level of insight that
went into picking the order.
It seems to work well.
Now, some good news, in terms of trying
to understand what the compiler does,
you can actually just ask the compiler, what did you do?
And you've already used this functionality,
as I understand, in some of your assignments.
You've already asked the compiler
to give you a report specifically
about whether or not it could vectorize some code.
But, in fact, LLVM, the compiler you have access to,
can produce reports not just for factorization,
but for a lot of the different transformation
passes that it tries to perform.
And there's some syntax that you have
to pass to the compiler, some compiler flags
that you have to specify in order to get those reports.
Those are described on the slide.
I won't walk you through that text.
You can look at the slides afterwards.
At the end of the day, the string that you're passing
is actually a regular expression.
If you know what regular expressions are,
great, then you can use that to narrow down
the search for your report.
If you don't, and you just want to see the whole report,
just provide dot star as a string and you're good to go.
That's the good news.
You can get the compiler to tell you exactly what it did.
The bad news is that when you ask the compiler what it did,
it will give you a report.
And the report looks something like this.
In fact, I've highlighted most of the report
for this particular piece of code,
because the report ends up being very long.
And as you might have noticed just
from reading some of the texts, there are definitely
English words in this text.
And there are pointers to pieces of code that you've compiled.
But it is very jargon, and hard to understand.
This isn't the easiest report to make sense of.
OK, so that's some good news and some bad news
about these compiler reports.
The good news is, you can ask the compiler.
And it'll happily tell you all about the things that it did.
It can tell you about which transformation passes were
successfully able to transform the code.
It can tell you conclusions that it drew
about its analysis of the code.
But the bad news is, these reports
are kind of complicated.
They can be long.
They use a lot of internal compiler jargon, which
if you're not familiar with that jargon,
it makes it hard to understand.
It also turns out that not all of the transformation
passes in the compiler give you these nice reports.
So you don't get to see the whole picture.
And, in general, the reports don't really
tell you the whole story about what the compiler did
or did not do.
And we'll see another example of that later on.
So part of the goal of today's lecture
is to get some context for understanding the reports
that you might see if you pass those flags to the compiler.
And the structure of today's lecture
is basically divided up into two parts.
First, I want to give you some examples
of compiler optimizations, just simple examples
so you get a sense as to how a compiler mechanically reasons
about the code it's given, and tries to optimize that code.
We'll take a look at how the compiler optimizes
a single scalar value, how it can optimize a structure,
how it can optimize function calls,
and how it can optimize loops, just simple examples
to give some flavor.
And then the second half of lecture,
I have a few case studies for you
which get into diagnosing ways in which the compiler failed,
not due to bugs, per se, but simply
didn't do an optimization you might have expected it to do.
But, to be frank, I think all those case
studies are really cool.
But it's not totally crucial that we
get through every single case study during today's lecture.
The slides will be available afterwards.
So when we get to that part, we'll
just see how many case studies we can cover.
Sound good?
Any questions so far?
All right, let's get to it.
Let's start with a quick overview
of compiler optimizations.
So here is a summary of the various--
oh, I forgot that I just copied this slide
from a previous lecture given in this class.
You might recognize this slide I think from lecture two.
Sorry about that.
That's OK.
We can fix this.
We'll just go ahead and add this slide right now.
We need to change the title.
So let's cross that out and put in our new title.
OK, so, great, and now we should double check these lists
and make sure that they're accurate.
Data structures, we'll come back to data structures.
Loops, hoisting, yeah, the compiler can do hoisting.
Sentinels, not really, the compiler
is not good at sentinels.
Loop unrolling, yeah, it absolutely does loop unrolling.
Loop fusion, yeah, it can, but there are
some restrictions that apply.
Your mileage might vary.
Eliminate waste iterations, some restrictions might apply.
OK, logic, constant folding and propagation, yeah,
it's good on that.
Common subexpression elimination, yeah, I
can find common subexpressions, you're fin there.
It knows algebra, yeah good.
Short circuiting, yes, absolutely.
Ordering tests, depends on the tests--
I'll give it to the compiler.
But I'll say, restrictions apply.
Creating a fast path, compilers aren't
that smart about fast paths.
They come up with really boring fast paths.
I'm going to take that off the list.
Combining tests, again, it kind of depends on the tests.
Functions, compilers are pretty good at functions.
So inling, it can do that.
Tail recursion elimination, yes, absolutely.
Coarsening, not so much.
OK, great.
Let's come back to data structures,
which we skipped before.
Packing, augmentation-- OK, honestly, the compiler
does a lot with data structures but really
none of those things.
The compiler isn't smart about data structures
in that particular way.
Really, the way that the compiler
is smart about data structures is shown here,
if we expand this list to include even more compiler
optimizations.
Bottom line with data structures, the compiler
knows a lot about architecture.
And it really has put a lot of effort
into figuring out how to use registers really effectively.
Reading and writing and register is super fast.
Touching memory is not so fast.
And so the compiler works really hard to allocate registers, put
anything that lives in memory ordinarily into registers,
manipulate aggregate types to use registers,
as we'll see in a couple of slides, align data
that has to live in memory.
Compilers are good at that.
Compilers are also good at loops.
We already saw some example optimization
on the previous slide.
It can vectorize.
It does a lot of other cool stuff.
Unswitching is a cool optimization
that I won't cover here.
Idiom replacement, it finds common patterns,
and does something smart with those.
Vision, skewing, tiling, interchange, those all
try to process the iterations of the loop in some clever way
to make stuff go fast.
And some restrictions apply.
Those are really in development in LLVM.
Logic, it does a lot more with logic than what we saw before.
It can eliminate instructions that aren't necessary.
It can do strength reduction, and other cool optimization.
I think we saw that one in the Bentley slides.
It gets rid of dead code.
It can do more idiom replacement.
Branch reordering is kind like reordering tests.
Global value numbering, another cool optimization
that we won't talk about today.
Functions, it can do more on switching.
It can eliminate arguments that aren't necessary.
So the compiler can do a lot of stuff for you.
And at the end the day, writing down this whole list
is kind of a futile activity because it changes over time.
Compilers are a moving target.
Compiler developers, they're software engineers
like you and me.
And they're clever.
And they're trying to apply all their clever software
engineering practice to this compiler code base
to make it do more stuff.
And so they are constantly adding new optimizations
to the compiler, new clever analyses, all the time.
So, really, what we're going to look at today
is just a couple examples to get a flavor for what
the compiler does internally.
Now, if you want to follow along with how the compiler works,
the good news is, by and large, you
can take a look at the LLVM IR to see
what happens as the compiler processes your code.
You don't need to look out the assembly.
That's generally true.
But there are some exceptions.
So, for example, if we have these three snippets of C code
on the left, and we look at what your LLVM compiler generates,
in terms of the IR, we can see that there
are some optimizations reflected, but not
too many interesting ones.
The multiply by 8 turns into a shift left operation by 3,
because 8 is a power of 2.
That's straightforward.
Good, we can see that in the IR.
The multiply by 15 still looks like a multiply by 15.
No changes there.
The divide by 71 looks like a divide by 71.
Again, no changes there.
Now, with arithmetic ops, the difference
between what you see in the LLVM IR
and what you see in the assembly,
this is where it's most pronounced,
at least in my experience, because if we
take a look at these same snippets of C code,
and we look at the corresponding x86 assembly for it,
we get the stuff on the right.
And this looks different.
Let's pick through what this assembly
code does one line at a time.
So the first one in the C code, takes the argument n,
and multiplies it by 8.
And then the assembly, we have this LEA instruction.
Anyone remember what the LEA instruction does?
I see one person shaking their head.
That's a perfectly reasonable response.
Yeah, go for it?
Load effective address, what does that mean?
Load the address, but don't actually access memory.
Another way to phrase that, do this address calculation.
And give me the result of the address calculation.
Don't read or write memory at that address.
Just do the calculation.
That's what loading an effective address means, essentially.
But you're exactly right.
The LEA instruction does an address calculation,
and stores the result in the register on the right.
Anyone remember enough about x86 address calculations
to tell me how that LEA in particular works, the first LEA
on the slide?
Yeah?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: But before the first comma, in this case
nothing, gets added to the product of the second two
arguments in those parens.
You're exactly right.
So this LEA takes the value 8, multiplies it by whatever
is in register RDI, which holds the value n.
And it stores the result into AX.
So, perfect, it does a multiply by 8.
The address calculator is only capable of a small range
of operations.
It can do additions.
And it can multiply by 1, 2, 4, or 8.
That's it.
So it's a really simple circuit in the hardware.
But it's fast.
It's optimized heavily by modern processors.
And so if the compiler can use it,
they tend to try to use these LEA instructions.
So good job.
How about the next one?
Multiply by 15 turns into these two LEA instructions.
Can anyone tell me how these work?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: You're basically multiplying by 5
and multiplying by 3, exactly right.
We can step through this as well.
If we look at the first LEA instruction,
we take RDI, which stores the value n.
We multiply that by 4.
We add it to the original value of RDI.
And so that computes 4 times n, plus n, which is five times n.
And that result gets stored into AX.
Could, we've effectively multiplied by 5.
The next instruction takes whatever
is in REX, which is now 5n, multiplies that by 2, adds it
to whatever is currently in REX, which is once again 5n.
So that computes 2 times 5n, plus 5n, which
is 3 times 5n, which is 15n.
So just like that, we've done our multiply
with two LEA instructions.
How about the last one?
In this last piece of code, we take the arguments in RDI.
We move it into EX.
We then move the value 3,871,519,817,
and put that into ECX, as you do.
We multiply those two values together.
And then we shift the product right by 38.
So, obviously, this divides by 71.
Any guesses as to how this performs
the division operation we want?
Both of you answered.
I might still call on you.
give a little more time for someone else
to raise their hand.
Go for it.
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: It has a lot to do with 2 to the 38, very good.
Yeah, all right, any further guesses
before I give the answer away?
Yeah, in the back?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: Kind of.
So this is what's technically called a magic number.
And, yes, it's technically called a magic number.
And this magic number is equal to 2
to the 38, divided by 71, plus 1 to deal with some rounding
effects.
What this code does is it says, let's
compute n divided by 71, by first computing n divided
by 71, times 2 to the 38, and then shifting off the lower 38
bits with that shift right operation.
And by converting the operation into this,
it's able to replace the division operation
with a multiply.
And if you remember, hopefully, from the architecture lecture,
multiply operations, they're not the cheapest things
in the world.
But they're not too bad.
Division is really expensive.
If you want fast code, never divide.
Also, never compute modulus, or access memory.
Yeah, question?
AUDIENCE: Why did you choose 38?
TAO B. SCHARDL: Why did I choose 38?
I think it shows 38 because 38 works.
There's actually a formula for--
pretty much it doesn't want to choose
a value that's too large, or else it'll overflow.
And it doesn't want to choose a value that's too small,
or else you lose precision.
So it's able to find a balancing point.
If you want to know more about magic numbers,
I recommend checking out this book called Hackers Delight.
For any of you who are familiar with this book,
it is a book full of bit tricks.
Seriously, that's the entire book.
It's just a book full of bit tricks.
And there's a whole section in there describing
how you do division by various constants using multiplication,
either signed or unsigned.
It's very cool.
But magic number to convert a division
into a multiply, that's the kind of thing
that you might see from the assembly.
That's one of these examples of arithmetic operations
that are really optimized at the very last step.
But for the rest of the optimizations,
fortunately we can focus on the IR.
Any questions about that so far?
Cool.
OK, so for the next part of the lecture,
I want to show you a couple example optimizations in terms
of the LLVM IR.
And to show you these optimizations,
we'll have a little bit of code that we'll work through,
a running example, if you will.
And this running example will be some code
that I stole from I think it was a serial program that simulates
the behavior of n massive bodies in 2D space
under the law of gravitation.
So we've got a whole bunch of point masses.
Those point masses have varying masses.
And we just want to simulate what
happens due to gravity as these masses interact in the plane.
At a high level, the n body code is pretty simple.
We have a top level simulate routine,
which just loops over all the time
steps, during which we want to perform this simulation.
And at each time step, it calculates the various forces
acting on those different bodies.
And then it updates the position of each body,
based on those forces.
In order to do that calculation.
It has some internal data structures,
one to represent each body, which contains
a couple of vector types.
And we define our own vector type
to store to double precision floating point values.
Now, we don't need to see the entire code in order
to look at some compiler optimizations.
The one routine that we will take a look at
is this one to update the positions.
This is a simple loop that takes each body, one at a time,
computes the new velocity on that body,
based on the forces acting on that body,
and uses vector operations to do that.
Then it updates the position of that body,
again using these vector operations that we've defined.
And then it stores the results into the data structure
for that body.
So all these methods with this code
make use of these basic routines on 2D vectors, points in x, y,
or points in 2D space.
And these routines are pretty simple.
There is one to add two vectors.
There's another to scale a vector by a scalar value.
And there's a third to compute the length, which we won't
actually look at too much.
Everyone good so far?
OK, so let's try to start simple.
Let's take a look at just one of these one line vector
operations, vec scale.
All vec scale does is it takes one of these vector inputs
at a scalar value a.
And it multiplies x by a, and y by a, and stores the results
into a vector type, and return to it.
Great, couldn't be simpler.
If we compile this with no optimizations whatsoever,
and we take a look at the LLVM IR,
we get that, which is a little more complicated
than you might imagine.
The good news, though, is that if you turn on optimizations,
and you just turn on the first level of optimization, just 01,
whereas we got this code before, now we get this, which is far,
far simpler, and so simple I can blow up the font size so you
can actually read the code on the slide.
So to see, again, no optimizations, optimizations.
So a lot of stuff happened to optimize this simple function.
We're going to see what those optimizations actually were.
But, first, let's pick apart what's
going on in this function.
We have our vec scale routine in LLVM IR.
It takes a structure as its first argument.
And that's represented using two doubles.
It takes a scalar as the second argument.
And what the operation does is it multiplies those two
fields by the third argument, the double A.
It then packs those values into a struct that'll return.
And, finally, it returns that struct.
So that's what the optimized code does.
Let's see actually how we get to this optimized code.
And we'll do this one step at a time.
Let's start by optimizing the operations on a single scalar
value.
That's why I picked this example.
So we go back to the 00 code.
And we just pick out the operations that
dealt with that scalar value.
We our scope down to just these lines.
So the argument double A is the final argument in the list.
And what we see is that within the vector scale
routine, compiler to 0, we allocate some local storage.
We store that double A into the local storage.
And then later on, we'll load the value out
of the local storage before the multiply.
And then we load it again before the other multiply.
OK, any ideas how we could make this code faster?
Don't store in memory, what a great idea.
How do we get around not storing it in memory?
Saving a register.
In particular, what property of LLVM IR makes that really easy?
There are infinite registers.
And, in fact, the argument is already in a register.
It's already in the register percent two, if I recall.
So we don't need to move it into a register.
It's already there.
So how do we go about optimizing that code in this case?
Well, let's find the places where we're using the value.
And we're using the value loaded from memory.
And what we're going to do is just replace those loads
from memory with the original argument.
We know exactly what operation we're trying to do.
We know we're trying to do a multiply
by the original parameter.
So we just find those two uses.
We cross them out.
And we put in the input parameter in its place.
That make sense?
Questions so far?
Cool.
So now, those multipliers aren't using the values
returned by the loads.
How further can we optimize this code?
Delete the loads.
What else can we delete?
So there's no address calculation here
just because the code is so simple, but good insight.
The allocation and the store, great.
So those loads are dead code.
The store is dead code.
The allocation is dead code.
We eliminate all that dead code.
We got rid of those loads.
We just used the value living in the register.
And we've already eliminated a bunch of instructions.
So the net effect of that was to turn the code optimizer at 00
that we had in the background into the code we have
in the foreground, which is slightly shorter,
but not that much.
So it's a little bit faster, but not that much faster.
How do we optimize this function further?
Do it for every variable we have.
In particular, the only other variable we have
is a structure that we're passing in.
So we want to do this kind of optimization on the structure.
Make sense?
So let's see how we optimize this structure.
Now, the problem is that structures
are harder to handle than individual scalar values,
because, in general, you can't store the whole structure
in just a single register.
It's more complicated to juggle all the data
within a structure.
But, nevertheless, let's take a look
at the code that operates on the structure,
or at least operates on the structure
that we pass in to the function.
So when we eliminate all the other code,
we see that we've got an allocation.
See if I animations here, yeah, I do.
We have an allocation.
So we can store the structure onto the stack.
Then we have an address calculation
that lets us store the first part of the structure
onto the stack.
We have a second address calculation
to store the second field on the stack.
And later on, when we need those values,
we load the first field out of memory.
And we load the second field out of memory.
It's a very similar pattern to what we had before,
except we've got more going on in this case.
So how do we go about optimizing this structure?
Any ideas, high level ideas?
Ultimately, we want to get rid of all of the memory
references and all that storage for the structure.
How do we reason through eliminating all that stuff
in a mechanical fashion, based on what we've seen so far?
Go for it.
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: They are passed in using separate parameters,
separate registers if you will, as a quirk of how LLVM does it.
So given that insight, how would you optimize it?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: Cross out percent 12, percent 6,
and put in the relevant field.
Cool.
Let me phrase that a little bit differently.
Let's do this one field at a time.
We've got a structure, which has multiple fields.
Let's just take it one step at a time.
All right, so let's look at the first field.
And let's look at the operations that deal with the first field.
We have, in our code, in our LLVM IR, some address
calculations that refer to the same field of the structure.
In this case, I believe it's the first field, yes.
And, ultimately, we end up loading from this location
in local memory.
So what value is this load going to retrieve?
How do we know that both address calculations
refer to the same field?
Good question.
What we do in this case is very careful analysis
of the math that's going on.
We know that the alga, the location in local memory,
that's just a fixed location.
And from that, we can interpret what each of the instructions
does in terms of an address calculation.
And we can determine that they're the same value.
So we have this location in memory that we operate on.
And before you do a multiply, we end up
loading from that location in memory.
So what value do we know is going to be loaded by that load
instruction?
Go for it.
AUDIENCE: So what we're doing right now is taking some value,
and then storing it, and then getting it back out,
and putting it back.
TAO B. SCHARDL: Not putting it back, but we don't you
worry about putting it back.
AUDIENCE: So we don't need to put it somewhere just
to take it back out?
TAO B. SCHARDL: Correct.
Correct.
So what are we multiplying in that multiply, which value?
First element of the struct.
It's percent zero.
It's the value that we stored right there.
That makes sense?
Everyone see that?
Any questions about that?
All right, so we're storing the first element of the struct
into this location.
Later, we load it out of that same location.
Nothing else happened to that location.
So let's go ahead and optimize it just the same way
we optimize the scalar.
We see that we use the result of the load right there.
But we know that load is going to return the first field
of our struct input.
So we'll just cross it out, and replace it with that field.
So now we're not using the result of that load.
What do we get to do as the compiler?
I can tell you know the answer.
Delete the dead code, delete all of it.
Remove the now dead code, which is all those address
calculations, as well as the load operation, and the store
operation.
And that's pretty much it.
Yeah, good.
So we replace that operation.
And we got rid of a bunch of other code from our function.
We've now optimized one of the two fields in our struct.
What do we do next?
Optimize the next one.
That happened similarly.
I won't walk you through that a second time.
We find where we're using the result of that load.
We can cross it out, and replace it with the appropriate input,
and then delete all the relevant dead code.
And now, we get to delete the original allocation
because nothing's getting stored to that memory.
That make sense?
Any questions about that?
Yeah?
AUDIENCE: So when we first compile it to LLVM IR,
does it unpack the struct and just
put in separate parameters?
TAO B. SCHARDL: When we first compiled LLVM IR,
do we unpack the struct and pass in the separate parameters?
AUDIENCE: Like, how we have three parameters here
that are doubled.
Wasn't our original C code just a struct of vectors in
the double?
TAO B. SCHARDL: So LLVM IR in this case, when we compiled it
as zero, decided to pass it as separate parameters,
just as it's representation.
So in that sense, yes.
But it was still doing the standard,
create some local storage, store the parameters
on to local storage, and then all operations just
read out of local storage.
It's the standard thing that the compiler generates when
it's asked to compile C code.
And with no other optimizations, that's what you get.
That makes sense?
Yeah?
AUDIENCE: What are all the align eights?
TAO B. SCHARDL: What are all the aligned eights doing?
The align eights are attributes that
specify the alignment of that location in memory.
This is alignment information that the compiler
either determines by analysis, or implements
as part of a standard.
So they're specifying how values are aligned in memory.
That matters a lot more for ultimate code generation,
unless we're able to just delete the memory
references altogether.
Make sense?
Cool.
Any other questions?
All right, so we optimized the first field.
We optimize the second field in a similar way.
Turns out, there's additional optimizations
that need to happen in order to return
a structure from this function.
Those operations can be optimized in a similar way.
They're shown here.
We're not going to go through exactly how that works.
But at the end of the day, after we've
optimized all of that code we end up with this.
We end up with our function compiled at 01.
And it's far simpler.
I think it's far more intuitive.
This is what I would imagine the code should
look like when I wrote the C code in the first place.
Take your input.
Do a couple of multiplications.
And then it does them operations to create the return value,
and ultimately return that value.
So, in summary, the compiler works
hard to transform data structures and scalar
values to store as much as it possibly
can purely within registers, and avoid using
any local storage, if possible.
Everyone good with that so far?
Cool.
Let's move on to another optimization.
Let's talk about function calls.
Let's take a look at how the compiler
can optimize function calls.
By and large, these optimizations
will occur if you pass optimization level 2 or higher,
just FYI.
So from our original C code, we had
some lines that performed a bunch of vector operations.
We had a vec add that added two vectors together, one of which
was the result of a vec scale, which
scaled the result of a vec add by some scalar value.
So we had this chain of calls in our code.
And if we take a look at the code compile that
was 0, what we end up with is this snippet shown
on the bottom, which performs some operations on these vector
structures, does this multiply operation,
and then calls this vector scale routine,
the vector scale routine that we decide to focus on first.
So any ideas for how we go about optimizing this?
So to give you a little bit of a hint, what the compiler sees
when it looks at that call is it sees a snippet containing
the call instruction.
And in our example, it also sees the code for the vec scale
function that we were just looking at.
And we're going to suppose that it's already optimized
vec scale as best as it can.
It's produced this code for the vec scale routine.
And so it sees that call instruction.
And it sees this code for the function that's being called.
So what could the compiler do at this point
to try to make the code above even faster?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: You're exactly right.
Remove the call, and just put the body of the vec scale code
right there in place of the call.
It takes a little bit of effort to pull that off.
But, roughly speaking, yeah, we're
just going to copy and paste this code in our function
into the place where we're calling the function.
And so if we do that simple copy paste,
we end up with some garbage code as an intermediate.
We had to do a little bit of renaming
to make everything work out.
But at this point, we have the code
from our function in the place of that call.
And now, we can observe that to restore correctness,
we don't want to do the call.
And we don't want to do the return that we just
pasted in place.
So we'll just go ahead and remove
both that call and the return.
That is called function inlining.
We identify some function call, or the compiler
identifies some function call.
And it takes the body of the function,
and just pastes it right in place of that call.
Sound good?
Make sense?
Anyone confused?
Raise your hand if you're confused.
Now, once you've done some amount of function inlining,
we can actually do some more optimizations.
So here, we have the code after we got rid
of the unnecessary call and return.
And we have a couple multiply operations sitting in place.
That looks fine.
But if we expand our scope just a little bit,
what we see, so we have some operations
happening that were sitting there already
after the original call.
What the compiler can do is it can take
a look at these instructions.
And long story short, it realizes
that all these instructions do is
pack some data into a structure, and then immediately unpack
the structure.
So it's like you put a bunch of stuff into a bag,
and then immediately dump out the bag.
That was kind of a waste of time.
That's kind of a waste of code.
Let's get rid of it.
Those operations are useless.
Let's delete them.
The compiler has a great time deleting dead code.
It's like it's what it lives to do.
All right, now, in fact, in the original code,
we didn't just have one function call.
We had a whole sequence of function calls.
And if we expand our LLVM IR snippet even a little further,
we can include those two function
calls, the original call to vec ad, followed by the code
that we've now optimized by inlining,
ultimately followed by yet another call to vec add.
Minor spoiler, the vec add routine, once it's optimized,
looks pretty similar to the vec scalar routine.
And, in particular, it has comparable size
to the vector scale routine.
So what's the compiler is going to do to those to call sites?
Inline it, do more inlining, inlining is great.
We'll inline these functions as well,
and then remove all of the additional, now-useless
instructions.
We'll walk through that process.
The result of that process looks something like this.
So in the original C code, we had this vec
add, which called the vec scale as one
of its arguments, which called the vec add
is one of its arguments.
And what we end up with in the optimized IR
is just a bunch of straight line code that performs
floating point operations.
It's almost as if the compiler took the original C code,
and transformed it into the equivalency code shown
on the bottom, where it just operates
on a whole bunch of doubles, and just does primitive operations.
So function inlining, as well as the additional transformations
it was able to perform as a result,
together those were able to eliminate
all of those function calls.
It was able to completely eliminate
any costs associated with the function call abstraction,
at least in this code.
Make sense?
I think that's pretty cool.
You write code that has a bunch of function calls,
because that's how you've constructed your interfaces.
But you're not really paying for those function calls.
Function calls aren't the cheapest operation
in the world, especially if you think
about everything that goes on in terms
of the registers and the stack.
But the compiler is able to avoid all of that overhead,
and just perform the floating point operations we care about.
OK, well, if function inlining is so great,
and it enables so many great optimizations,
why doesn't the compiler just inline every function call?
Go for it.
Recursion, it's really hard to inline a recursive call.
In general, you can't inline a function into itself,
although it turns out there are some exceptions.
So, yes, recursion creates problems
with function inlining.
Any other thoughts?
In the back.
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: You're definitely on to something.
So we had to do a bunch of this renaming stuff
when we inlined the first time, and when
we inlined every single time.
And even though LLVM IR has an infinite number of registers,
the machine doesn't.
And so all of that renaming does create a problem.
But there are other problems as well of
a similar nature when you start inlining all those functions.
For example, you copy pasted a bunch of code.
And that made the original call site even bigger, and bigger,
and bigger, and bigger.
And programs, we generally don't think about the space
they take in memory.
But they do take space in memory.
And that does have an impact on performance.
So great answer, any other thoughts?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: If your function becomes too long,
then it may not fit in instruction cache.
And that can increase the amount of time
it takes just to execute the function.
Right, because you're now not getting hash hits,
exactly right.
That's one of the problems with this code size blow
up from inlining everything.
Any other thoughts?
Any final thoughts?
So there are three main reasons why the compiler
won't inline every function.
I think we touched on two of them here.
For some function calls, like recursive calls,
it's impossible to inline them, because you can't
inline a function into itself.
But there are exceptions to that, namely
recursive tail calls.
If the last thing in a function is a function call,
then it turns out you can effectively
inline that function call as an optimization.
We're not going to talk too much about how that works.
But there are corner cases.
But, in general, you can't inline a recursive call.
The compiler has another problem.
Namely, if the function that you're calling
is in a different castle, if it's in a different compilation
unit, literally in a different file
that's compiled independently, then the compiler
can't very well inline that function,
because it doesn't know about the function.
It doesn't have access to that function's code.
There is a way to get around that problem
with modern compiler technology that involves whole program
optimization.
And I think there's some backup slides that will tell you
how to do that with LLVM.
But, in general, if it's in a different compilation unit,
it can't be inline.
And, finally, as touched on, function inlining
can increase code size, which can hurt performance.
OK, so some functions are OK to inline.
Other functions could create this performance problem,
because you've increased code size.
So how does the compiler know whether or not
inlining any particular function at a call site
could hurt performance?
Any guesses?
Yeah?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: Yeah.
So the compiler has some cost model, which gives it
some information about, how much will it
cost to inline that function?
Is the cost model always right?
It is not.
So the answer, how does the compiler know,
is, really, it doesn't know.
It makes a best guess using that cost model,
and other heuristics, to determine,
when does it make sense to try to inline a function?
And because it's making a best guess,
sometimes the compiler guesses wrong.
So to wrap up this part, here are just
a couple of tips for controlling function inlining
in your own programs.
If there's a function that you know must always be inlined,
no matter what happens, you can mark that function
with a special attribute, namely the always inline attribute.
For example, if you have a function that
does some complex address calculation,
and it should be inlined rather than called,
you may want to mark that with an always inline attribute.
Similarly, if you have a function that really should
never be inlined, it's never cost effective
to inline that function, you can mark that function
with the no inline attribute.
And, finally, if you want to enable more function inlining
in the compiler, you can use link time optimization, or LTO,
to enable whole program optimization.
Won't go into that during these slides.
Let's move on, and talk about loop optimizations.
Any questions so far, before continue?
Yeah?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: Sorry?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: Does static inline
guarantee you the compiler will always inline it?
It actually doesn't.
The inline keyword will provide a hint to the compiler
that it should think about inlining the function.
But it doesn't provide any guarantees.
If you want a strong guarantee, use the always inline
attribute.
Good question, though.
All right, loop optimizations--
you've already seen some loop optimizations.
You've seen vectorization, for example.
It turns out, the compiler does a lot of work
to try to optimize loops.
So first, why is that?
Why would the compiler engineers invest so much effort
into optimizing loops?
Why loops in particular?
They're extremely common control structure
that also has a branch.
Both things are true.
I think there's a higher level reason, though,
or more fundamental reason, if you will.
Yeah?
AUDIENCE: Most of the time, the loop takes up the most time.
TAO B. SCHARDL: Most of the time the loop takes up
the most time.
You got it.
Loops account for a lot of the execution time of programs.
The way I like to think about this
is with a really simple thought experiment.
Let's imagine that you've got a machine with a two gigahertz
processor.
We've chosen these values to be easier
to think about using mental math.
Suppose you've got a two gigahertz
processor with 16 cores.
Each core executes one instruction per cycle.
And suppose you've got a program which
contains a trillion instructions and ample parallelism
for those 16 cores.
But all of those instructions are simple, straight line code.
There are no branches.
There are no loops.
There no complicated operations like IO.
It's just a bunch of really simple straight line code.
Each instruction takes a cycle to execute.
The processor executes one instruction per cycle.
How long does it take to run this code, to execute
the entire terabyte binary?
2 to the 40th cycles for 2 to the 40 instructions.
But you're using a two gigahertz processor and 16 cores.
And you've got ample parallelism in the program
to keep them all saturated.
So how much time?
AUDIENCE: 32 seconds.
TAO B. SCHARDL: 32 seconds, nice job.
This one has mastered power of 2 arithmetic in one's head.
It's a good skill to have, especially in core six.
Yeah, so if you have just a bunch of simple,
straight line code, and you have a terabyte of it.
That's a lot of code.
That is a big binary.
And, yet, the program, this processor,
this relatively simple processor,
can execute the whole thing in just about 30 seconds.
Now, in your experience working with software,
you might have noticed that there
are some programs that take longer than 30 seconds to run.
And some of those programs don't have terabyte size binaries.
The reason that those programs take longer to run,
by and large, is loops.
So loops account for a lot of the execution
time in real programs.
Now, you've already seen some loop optimizations.
We're just going to take a look at one other loop
optimization today, namely code hoisting, also known
as loop invariant code motion.
To look at that, we're going to take
a look at a different snippet of code
from the end body simulation.
This code calculates the forces going
on each of the end bodies.
And it does it with a doubly nested loop.
For all the zero to number of bodies,
for all body zero number bodies, as long
as you're not looking at the same body,
call this add force routine, which calculates to--
calculate the force between those two bodies.
And add that force to one of the bodies.
That's all that's going on in this code.
If we translate this code into LLVM IR,
we end up with, hopefully unsurprisingly,
a doubly nested loop.
It looks something like this.
The body of the code, the body of the innermost loop,
has been lighted, just so things can fit on the slide.
But we can see the overall structure.
On the outside, we have some outer loop control.
This should look familiar from lecture five, hopefully.
Inside of that outer loop, we have an inner loop.
And at the top and the bottom of that inner loop,
we have the inner loop control.
And within that inner loop, we do
have one branch, which can skip a bunch of code
if you're looking at the same body for i and j.
But, otherwise, we have the loop body of the inner most loop,
basic structure.
Now, if we just zoom in on the top part
of this doubly-nested loop, just the topmost three basic blocks,
take a look at more of the code that's going on here,
we end up with something that looks like this.
And if you remember some of the discussion
from lecture five about the loop induction variables,
and what that looks like in LLVM IR, what you find
is that for the outer loop we have an induction variable
at the very top.
It's that weird fee instruction, once again.
Inside that outer loop, we have the loop control
for the inner loop, which has its own induction variable.
Once again, we have another fee node.
That's how we can spot it.
And then we have the body of the innermost loop.
And this is just the start of.
It it's just a couple address calculations.
But can anyone tell me some interesting property
about just a couple of these address calculations
that could lead to an optimization?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: The first two address calculations only
depend on the outermost loop variable, the iteration
variable for the outer loop, exactly right.
So what can we do with those instructions?
Bring them out of the inner loop.
Why should we keep computing these addresses
in the innermost loop when we could just compute them once
in the outer loop?
That optimization is called code hoisting, or loop invariant
code motion.
Those instructions are invariant to the code
in the innermost loop.
So you hoist them out.
And once you hoist them out, you end up
with a transformed loop that looks something like this.
What we have is the same outer loop control at the very top.
But now, we're doing some address calculations there.
And we no longer have those address calculations
on the inside.
And as a result, those hoisted calculations
are performed just once per iteration of the outer loop,
rather than once per iteration of the inner loop.
And so those instructions are run far fewer times.
You get to save a lot of running time.
So the effect of this optimization
in terms of C code, because it can
be a little tedious to look at LLVM IR,
is essentially like this.
We took this doubly-nested loop in C.
We're calling add force of blah, blah, blah, calculate force,
blah, blah, blah.
And now, we just move the address calculation
to get the ith body that we care about.
We move that to the outer.
Now, this was an example of loop invariant code motion on just
a couple address calculations.
In general, the compiler will try
to prove that some calculation is invariant across all
the iterations of a loop.
And whenever it can prove that, it
will try to hoist that code out of the loop.
If it can get code out of the body of a loop,
that reduces the running time of the loop,
saves a lot of execution time.
Huge bang for the buck.
Make sense?
Any questions about that so far?
All right, so just to summarize this part,
what can the compiler do?
The compiler optimizes code by performing a sequence
of transformation passes.
All those passes are pretty mechanical.
The compiler goes through the code.
It tries to find some property, like this address calculation
is the same as that address calculation.
And so this load will return the same value as that store,
and so on, and so forth.
And based on that analysis, it tries
to get rid of some dead code, and replace certain register
values with other register values,
replace things that live in memory with things
that just live in registers.
A lot of the transformations resemble Bentley-rule work
optimizations that you've seen in lecture two.
So as you're studying for your upcoming quiz,
you can kind of get two for one by looking
at those Bentley-rule optimizations.
And one transformation pass, in particular function inlining,
was a good example of this.
One transformation can enable other transformations.
And those together can compound to give you fast code.
In general, compilers perform a lot more transformations
than just the ones we saw today.
But there are things that the compiler can't do.
Here's one very simple example.
In this case, we're taking another look
at this calculate forces routine.
Although the compiler can optimize the code
by moving address calculations out of loop,
one thing that I can't do is exploit symmetry
in the problem.
So in this problem, what's going on
is we're computing the forces on any pair of bodies
using the law of gravitation.
And it turns out that the force acting on one body by another
is exactly the opposite the force acting on the other body
by the one.
So F of 12 is equal to minus F of 21.
The compiler will not figure that out.
The compiler knows algebra.
It doesn't know physics.
So it won't be able to figure out
that there's symmetry in this problem,
and it can avoid wasted operations.
Make sense?
All right, so that was an overview
of some simple compiler optimizations.
We now have some examples of some case studies
to see where the compiler can get tripped up.
And it doesn't matter if we get through all of these or not.
You'll have access to the slides afterwards.
But I think these are kind of cool.
So shall we take a look?
Simple question-- does the compiler vectorize this loop?
So just to go over what this loop does, it's a simple loop.
The function takes two vectors as inputs,
or two arrays as inputs, I should say--
an array called y, of like then, and an array x of like then,
and some scalar value a.
And all that this function does is
it loops over each element of the vector, multiplies x of i
by the input scalar, adds the product into y's of i.
So does the loop vectorize?
Yes?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: y and x could overlap.
And there is no information about whether they overlap.
So do they vectorize?
We have a vote for no.
Anyone think that it does vectorize?
You made a very convincing argument.
So everyone believes that this loop does not vectorize.
Is that true?
Anyone uncertain?
Anyone unwilling to commit to yes or no right here?
All right, a bunch of people are unwilling to commit
to yes or no.
All right, let's resolve this question.
Let's first ask for the report.
Let's look at the vectorization report.
We compile it.
We pass the flags to get the vectorization report.
And the vectorization report says, yes, it
does vectorize this loop, which is interesting,
because we have this great argument that says,
but you don't know how these addresses fit in memory.
You don't know if x and y overlap with each other.
How can you possibly vectorize?
Kind of a mystery.
Well, if we take a look at the actual compiled code when we
optimize this at 02, turns out you can pass certain flags
to the compiler, and get it to print out not just the LLVM IR,
but the LLVM IR formatted as a control flow graph.
And the control flow graph for this simple two line function
is the thing on the right, which you obviously
can't, read because it's a little bit
small, in terms of its text.
And it seems have a lot going on.
So I took the liberty of redrawing that control flow
graph with none of the code inside,
just get a picture of what the structure looks
like for this compiled function.
And, structurally speaking, it looks like this.
And with a bit of practice staring at control flow graphs,
which you might get if you spend way too much time working
on compilers, you might look at this control flow graph,
and think, this graph looks a little too complicated
for the two line function that we gave as input.
So what's going on here?
Well, we've got three different loops in this code.
And it turns out that one of those loops
is full of vector operations.
OK, the other two loops are not full of vector operations.
That's unvectorized code.
And then there's this basic block right
at the top that has a conditional branch
at the end of it, branching to either the vectorized loop
or the unvectorized loop.
And, yeah, there's a lot of other control flow going on
as well.
But we can focus on just these components for the time being.
So what's that conditional branch doing?
Well, we can zoom in on just this one basic block,
and actually show it to be readable on the slide.
And the basic block looks like this.
So let's just study this LLVM IR code.
In this case, we have got the address y stored in register 0.
The address of x is stored in register 2.
And register 3 stores the value of n.
So one instruction at a time, who
can tell me what the first instruction in this code does?
Yes?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: Gets the address of y.
Is that what you said?
So it does use the address of y.
It's an address calculation that operates on register 0, which
stores the address of y.
But it's not just computing the address of y.
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: It's getting me the address
of the nth element of y.
It's adding in whatever is in register 3, which is the value
n, into the address of y.
So that computes the address y plus n.
This is testing your memory of pointer arithmetic
in C just a little bit but.
Don't worry.
It won't be too rough.
So that's what the first address calculation does.
What does the next instruction do?
AUDIENCE: It does x plus n.
TAO B. SCHARDL: That computes x plus, very good.
How about the next one?
AUDIENCE: It compares whether x plus n and y plus n
are the same.
TAO B. SCHARDL: It compares x plus n, versus y plus n.
AUDIENCE: [INAUDIBLE] compares the 33, which is x plus n,
and compares it to y.
So if x plus n is bigger than y, there's overlap.
TAO B. SCHARDL: Right, so it does a comparison.
We'll take that a little more slowly.
It does a comparison of x plus n, versus y in checks.
Is x plus n greater than y?
Perfect.
How about the next instruction?
Yeah?
AUDIENCE: It compares y plus n versus x.
TAO B. SCHARDL: It compares y plus n,
versus x, is y plus n even greater than x.
How would the last instruction before the branch?
Yep, go for it?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: [INAUDIBLE] one of the results.
So this computes the comparison, is x plus n
greater than y, bit-wise?
And is y plus n greater than x.
Fair enough.
So what does the result of that condition mean?
I think we've pretty much already spoiled the answer.
Anyone want to hear it one last time?
We had this whole setup.
Go for it.
AUDIENCE: They overlap.
TAO B. SCHARDL: Checks if they overlap.
So let's look at this condition in a couple
of different situations.
If we have x living in one place in memory,
and y living in another place in memory,
then no matter how we resolve this condition,
if we check is both y plus n greater than x,
and x plus n greater than y, the results will be false.
But if we have this situation, where
x and y overlap in memory some portion of memory,
then it turns out that regardless of whether x or y is
first, x plus n will be greater than y. y
plus n will be greater than x.
And the condition will return true.
In other words, the condition returns true,
if and only if these portions of memory pointed by x and y
alias.
So going back to our original looping code,
we have a situation where we have a branch based on
whether or not they alias.
And in one case, it executes the vectorized loop.
And in another case, it executes a non-vectorized code.
So returning to our original question, in particular
is a vectorized loop if they don't alias.
So returning to our original question,
does this code get vectorized?
The answer is yes and no.
So if you voted yes, you're actually right.
If you voted no, and you were persuaded, you were right.
And if you didn't commit to an answer, I can't help you.
But that's interesting.
The compiler actually generated multiple versions of this loop,
due to uncertainty about memory aliasing.
Yeah, question?
AUDIENCE: [INAUDIBLE]
TAO B. SCHARDL: So the question is, could the compiler
figure out this condition statically
while it's compiling the function?
Because we know the function is going to get
called from somewhere.
The answer is, sometimes it can.
A lot of times it can't.
If it's not capable of inlining this function,
for example, then it probably doesn't have enough information
to tell whether or not these two pointers will alias.
For example, you're just building a library
with a bunch of vector routines.
You don't know the code that's going to call
this routine eventually.
Now, in general, memory aliasing,
this will be the last point before we wrap up, in general,
memory aliasing can cause a lot of issues
when it comes to compiler optimization.
It can cause the compiler to act very conservatively.
In this example, we have a simple serial base case
for a matrix multiply routine.
But we don't know anything about the pointers
to the C, A, or B matrices.
And when we try to compile this and optimize it,
the compiler complains that it can't do loop invariant code
motion, because it doesn't know anything about these pointers.
It could be that the pointer changes
within the innermost loop.
So it can't move some calculation out
to an outer loop.
Compilers try to deal with this statically using an analysis
technique called alias analysis.
And they do try very hard to figure out,
when are these pointers going to alias?
Or when are they guaranteed to not alias?
Now, in general, it turns out that alias analysis
isn't just hard.
It's undecidable.
If only it were hard, maybe we'd have some hope.
But compilers, in practice, are faced
with this undecidable question.
And they try a variety of tricks to get useful alias analysis
results in practice.
For example, based on information in the source code,
the compiler might annotate instructions
with various metadata to track this aliasing information.
For example, TBAA is aliasing information based on types.
There's some scoping information for aliasing.
There is some information that says
it's guaranteed not to alias with this other operation,
all kinds of metadata.
Now, what can you do as a programmer
to avoid these issues of memory aliasing?
Always annotate your pointers, kids.
Always annotate your pointers.
The restrict keyword you've seen before.
It tells the compiler, address calculations based off
this pointer won't alias with address calculations
based off other pointers.
The const keyword provides a little more information.
It says, these addresses will only be read from.
They won't be written to.
And that can enable a lot more compiler optimizations.
Now, that's all the time that we have.
There are a couple of other cool case studies in the slides.
You're welcome to peruse the slides afterwards.
Thanks for listening.