Now we are ready to begin talking about
actual program optimizations and we begin
with local optimizations. Local
optimization is the simplest form of
program optimization because it focuses on
optimizing just a single basic block, so
just one basic block and, in particular,
there is no need worry about complicated
control flow, we are not going to be
looking at the entire method or procedure
body. Let's dive right in and take a look
at a couple of simple local optimizations.
If x is an integer valued variable And
from here on, we'll assume that x has
type-ins. So let me just write that down.
We're going to assume that x has type-ins
in all of our examples on this slide. Then
the statement x=x+0, well that doesn't
change the value of x. Zero is the
additive identity for +. We're just going
to assign x the value it currently has.
And so this statement is actually useless.
It can just be deleted from the program.
Similarly, for x=x1. Multiplying by one
will not change the value of X, and so
that statement can also be removed. And in
this case these are great optimizations
because we actually save an entire
instruction. Now, some statements can't be
deleted, but they can be simplified. A
simple example of that is if we have
x=x0. So that can be replaced by the
assignment, x=0, And again, we have, we
still have a statement here. We still have
to execute a statement. But This statement
may execute more quickly because it
doesn't involve actually running the, the,
the times operator. It doesn't involve
referencing the value of X. Presumably X
is registered, that doesn't really cost
anything. But you know, it's possible that
this instruction over here will execute
faster than this instruction over here.
Now, on many machines that's not the case.
In fact, this assignment of this, this
assignment on the right will take the same
amount of time as the multiplication on
the left, but as we will see. Having a
assignment of a constant to a variable
will actually enable other optimization,
so this is still a very worthwhile
transformation to do. An example that's
almost certainly an optimization is
replacing, the exponentiation operator,
Raising a value to the power of two by an
explicit multiply. So here, we're
computing y^2, And over here, we just
replace that by yy. Why is this a good
idea? Well this explanation operator here
is almost certain not a built in machine
instructions. Probably this is gonna wind
in our generated code being a call into to
some built in math library. And there will
involve a functioning call overhead. And
then there will be some kind of general
loop in there to do the right number of
multiplies. Depending on what the exponent
is. So in the special case where we know
that the exponent is two. It's much, much
more efficient. To just replace that, call
to [inaudible] by an explicit multiply.
Another example of, substituting one kind
of operation for another, In a in a
special situation, Is if we have, a
multiplication by a power of two. We can
replace that by a left bit shift, So here,
multiplying by eight. That's the same as
shifting the, binary representation of x
over by three bits, And, I and, That will,
you know, in fact compute the same thing.
And it doesn't even have to be a power of
two. If we had a [inaudible] location by
some other number that is not a power of
two, that can be replaced by some
combination of shifting and, and
subtractions. Okay? So we can replace the
multiply by some combination of shifts
and, and arithmetic operations, Simpler
arithmetic operations. Now these last two
here I should point out, you know, these
are interesting transformations. On modern
machines generally this will not result in
any kind of speed-up because on modern
machines the integer multiply operation is
just as fast as any other single
instruction. Now, on historical machines
these were actually significant
optimizations. So all of these,
instructions together are examples of
algebraic simplifications. So, that just
means exploiting properties of the
mathematical operators, to replace more
complex, instruc tions or more complex
operations by simpler ones. One of the
most important and useful local
optimizations is to compute the results of
operations at compile time rather than at
run time if the arguments are known at
compile time. So for example, let's say we
have a three-address instruction x=y op z.
And it happens that y and z are both
constants. These are both immediate
values. These are, you know, literals in
the instruction. Then we can actually
compute the results of the right hand side
at compile time, and replace this by an
assignment to a constant. So, for example,
if we have the instruction x=2+2, that can
be replaced by the assignment x=4, And
another example which is a very common and
important one, is if the predicate of a
conditional consists only of immediate
values. Then we can pre-compute the result
of that conditional, And, and decide what
the target of the conditional will be.
What the next instruction will be at
compile time. So, in this case, we have a
predicate, which is going to be false,
because two is not less than zero And so
we will not take the jump And so this
instruction can just be deleted from the
program. If we had the, Otherwise if two
is greater than zero, so if this is some
predicate to valuate true Then we would
replace this conditional by the jump.
Okay, this would become an unconditional
jump. Alright, And this class of
optimization's is called constant folding,
And as I said this is one of the most
common and most important optimizations
that compilers perform. Now, there is one
situation that you should be aware of and
which can be very dangerous, and this
situation is actually very instructive as
well. And so while it isn't that common,
I, I wanted to mention it, because it
really illustrates some of the subtleties
of program optimization and programming
language semantics. So what is this
dangerous situation? So let's consider the
scenario where we have two machines. We
have a machine X And we have a machine.
Why? Okay and now the compiler is being
run on machine X. And the compiler is
producing code. Generated code this is the
generated code produced as the output of
the compiler over here. That's gonna be
run on machine Y. So this is a cross
compiler. Okay, So you are running the
compiler On one machine, but you're
generating code for a different machine,
and why would you want to do that? Well.
The, the common situation in which you
want to do this is that this machine Y
over here is a very weak machine. So weak
in the sense that it's very slow and has
very limited memory. Maybe very limited
power then it's beneficial to develop your
program and even compile it on a much more
powerful machine. So many embedded Systems
codes are developed in exactly this way.
Code is developed on some powerful
workstations that are actually compiling
it for some small embedded device that
well, executes the code. Now, the problem
comes If x and y are different. So
consider the situation where x and y are
different machines, different
architectures. Alright, And I've been
implying that they are, but they don't
have to be. I mean, I mean, you could
compile on, one kind of architecture and
run the same code on the same
architecture. But the interesting
situation is when x and y are different
architectures. And so let's consider
something like, you know, in, in, you
know, machine X, let's say we have the
instruction, A=1.5+3.7. Mm-kay, And you
would like to constant fold that down to
a=5.2 Alright? Now the problem is that if
you simply execute this as a floating
point operation, on, architecture x, the
round off and you know the floating point
semantics in architecture x maybe slightly
different, from these semantics on
architecture y. It could be that if you do
that in architecture y, directly, that you
might get something like a.5, you know,
a=5.19. There might be a small difference
in the floating point result, depending on
whether you execute the instruction here
or here. And this becomes significant in
the case of constant folding and, and
cross compilation. Because some al
gorithms really depend on the floating
point numbers being treated very, very
consistently. So if you're going to round
off the operation one way, you need to do
it that way for every time you do that
particular operation, And by shifting the
computation from comp, from run time when
it would have executed an architecture y,
back into the compiler winds of executing
architecture x. You can change the results
of the program. So how do cross compilers
actually deal with this? So, so compilers
that want to be careful about this kind of
thing, what they will do is, they will
represent the floating point numbers as
strings inside the compiler and they will,
do the obvious, long form addition, and
multiplication, division operations are
the floating operations directly on the
strings. Keep the full precision Inside
the compiler And then, in the generated
code, produced the literal, that is the
full precision flowing point number And
then let the architecture, of the
architecture y decide how it wants to
round that off, okay? So that's the really
careful way to do constant folding of
floating point numbers if you're worried
about cross compilation. Continuing on
with local optimizations, another
important one is to eliminate unreachable
basic blocks. So what's an unreachable
basic block? That is one that is not the
target of any jump or fall through. So if
I have a piece of code, that can never
execute, and it might never execute
because there's no jump that jumps to the
beginning of that piece of code and it's
not, it doesn't follow after another
instruction that can fall through to it.
Well than that piece of code, that basic
block is just not gonna be used, it's
unreachable and it can be deleted from the
program. This has the advantage of making
the code smaller. So obviously, since the
basic block is unreachable, it's not
contributing to the execution costs of the
program in terms of the instruction count.
So the code is never executed. So it's not
really slowing down the code because, you
know, extra instructions are being
executed, But making the program smaller
can actually make it run faster because of
cache effects. So the instructions have to
fit into memory just like, just like the
data. And if you make the program smaller,
it makes it easier to fit the program in
memory, and you may increase the spacial
locality of the program. Instructions that
are used together may now be closer to
each other. And that can make the program
run more quickly. Before continuing on I
want to say a word or two about why
unreachable basic blocks occur. So why
would a programmer, in their right mind,
ever write a program that had code in it
that wasn't going to be executed? And
there's several actually ways in which
unreachable code can arise, and it's
actually quite common. So this is an
important optimization, getting rid of the
unreachable code is actually fairly
important. Perhaps the most common
situation Is that the code is actually
parameterized with, code that is only
compiled and used in certain situations.
So, for example, in C, It would be sorta
typical to see some code that looks like
this. If debug, then, you know, executes
something, where debug is a pound defying
constant. So in C, you can define names
for literals. So you say something like
this. You might define debug. To be zero,
and so you might see a program that had
this piece of code in it, and what this
literally means is that this piece of code
is equivalent to if zero, then blah, blah,
blah. Alright, so, so when you're
compiling without debugging, you have
debug to find the zero, when you're
compiling with debugging, you would change
this line to define debug to be some non
zero constant. So in this case we are
compiling without debugging. What will
happen? Well we'll see that this predicate
is guaranteed to be zero the constant
folding will take care of that. And that
will result in an unreachable basic block
on the [inaudible] branch and then that
code can be deleted And so essentially the
compiler is able to go through using the
optimizer and strip out all o f the
debugging code. That isn't going to be
used since your compiler [inaudible].
Another case where unreachable code comes
up is with libraries. So, very frequently,
programs are written, to use generic
libraries. But the program might only use
a very small part of the interface. So,
the library might supply 100 methods, to
cover all the situations that various
programmers are interested in. But for
your program, you might only be using
three of those methods. And the rest of
those methods could potentially be
removed, from the final binary, to make
the code smaller. And, finally another way
that unreachable basic blocks occur, is as
the results of other optimizations. So as
we will see optimizations frequently lead
to other to more optimizations. And it
could be that just through other
rearrangements of the code that the
compiler makes some basic block redundant
and, and able to be deleted. Now some
optimizations are simpler to express if
each register occurs only once on the
left-hand side of an assignment. So that
means if each register is assigned, at
most, once then some of these
optimizations are easier to talk about. So
we're gonna rewrite our intermediate code,
always to so that it's in single
assignment form. So this is called single
assignment form. And all that means is
that if we see a register being reused,
like over here, we have two assignments to
the register X. Okay. We're just going to
introduce another register name, for one
of those assignments. So in this case I'm
just gonna rename the first, use of X
here, definition of X here to be some new
register B. I'll replace the uses of that
X, by the name B, and now I have an
equivalent piece of code that satisfies
single assignment form. Every register is
assigned at most, once. Let's take a look
at an optimization that depends on single
assignment form. So we're going to assume
the basic blocks are in single assignment
form, and if they are, then we're going to
know That a definition of a register is
the first use of that register in th e
block, And so, in particular, we're also
ruling out things like this. So there
could be something like this, where X is
read. And then later on, X is used. Okay.
Sorry, X is read and then later on, X is
defined. So we're not going to allow this.
This register here would have to be
renamed to something else, say Y, And then
uses of X later on here, are renamed to Y.
Alright, so we're going to insist that
whenever we have a definition Of a
register in a basic block. That is the
first use of that register in the block.
Alright, and if, if that's true, if we
main, if we put things in that form, and
that's, that's easy to do as we've seen.
Then when two assignments have the same
right hand side, they're guaranteed to
compute the same value. So, take a look
here, This example. So let's say we have
an assignment, x=y+z. And then later on we
have another assignment, w=y+z. And we
said that there could only be one
assignment to x in any basic blocks. So,
all of these instructions that are
[inaudible] here, they can't be assigning
to X. And they also can't be assigning to
y and z. Y and z already have their
definitions. So, y and z can't be changed.
And that means that x and w here actually
compute the same value. And so we can
replace the second computation Y plus C by
just the name that we already have for it
X. Okay, and this saves us having to
recompute values. Alright so this is
called common sub expression elimination.
Common it's a rather long name. Sub
expression. The elimination. And this is
another one of the, more important
compiler optimizations. This is actually
something that comes up surprisingly
often. And saves quite a bit of work if,
if you perform this optimization. So,
another use of single assignment form is
that if we see the assignment w equals x
in a block. So here, the register w is
being just copied from the register x.
Then all subsequent uses of w can be
replaced by uses of x. So, for example,
Here we have an assignment to b And then
we have a copy, a, is=to b. And then, down
here, w e have a use of a in the last
instruction. Well, that use of a in the
last instruction can be replaced by a use
of B. And this is called copy propagation,
okay? Propagating copies through the code
And by itself, notice, that this makes
absolute no improvement in the code it's
only useful in conjunction with some of
the other optimizations. So, for example,
in this case after we do the copy
propagation, it might be the case that
this instruction can be deleted. If A is
not used any place else in the code, then
this instruction can be removed. Now let's
do a little more complex example and use
some of the optimizations that we've
discussed so far On a slightly bigger
piece of code. So we are starting with
this piece of code here on the left and we
are going to wind up with this piece of
code here on the right. And how does that
work? Well, first we have a copy
propagation, so we have A is assigned the
value five. And, so we can propagate that
value forward. And replace the use of a
later on by five, and I should say. That
when the value is propagated is a constant
rather than a registered name is called
Constant propagation instead of Copy
propagation, but it's exactly the same
thing. We, we, we have a single value
assigned on the right hand side, either a
register name or constant and we are
replacing uses of that in later
instructions by that register name or
constant. Okay? So once we have replaced a
here by five now we can do constant
folding, and now we have two constant
arguments for this instruction. So this
two times five can be replaced by the
constant ten. Now notice we have another
assignment of a constant to a register and
so we can propagate that constant forward.
We can replace the subsequent uses of X by
the number ten. And now we have more
opportunities for constant folding ten
plus six can be replaced by the value
sixteen. Alright now we have another,
another value here which is a, a constant
assignment so another instruction here
which is just an assignment of a constant
to a register so we can p ropagate that
constant forward. Alright then we wind up
down here with ten times sixteen And I see
over here in my final example here I
didn't bother to propagate the ten to x.
But we can do that, and this So we can
either do this optimization. So x times
sixteen if we didn't do the propagation,
would be equivalent to x left shift four.
Or we can just replace this by ten times
sixteen. That'd be even better. We wind up
achieving the value 160. Returning to an
idea I mentioned a couple of slides ago.
Let's say there is an assignment in a
basic block. Some registered W is assigned
some value that's computed on the right
hand side. Let's say that W, the
registered name is not used anywhere else
in the program. It doesn't appear
anywhere, not only in this basic block but
in any other part of the procedure in
which this statement appears. Well then,
the statement is dead and can be just
deleted from the program And dead here
means it does not contribute to the
programs result. Since the value that we
write into W is never referenced anywhere,
W is never used, doing the computation, of
W in the first place was a waste of time,
so we can just delete that computation.
Here's a simple example. Let's assume that
the register a is not used any place else,
in the program. And, the first thing we
have to do, so here's our initial piece of
code. The first thing we do is we put it
in single assignment form. And so I've
renamed here, this register x, to be,
register b. Okay, and once we do that, let
me do that, so we'll say that B=Z+Y and
A=B, and then we propagate this forward.
Alright, so we've now replaced this use of
A by B, so this takes us to this state
where we have this piece of code. Now we
can see that we have an assignment to A. A
is not used in the subsequent instruction.
We already said that A is not used
anywhere outside of the basic block, and
so the assignment a=b can be deleted, and
we wind up with this shorter basic block.
Now each local optimization actually does
very little by itself. And some of these
optim izations, some of these
transformations that are presented
actually don't make the program run faster
at all. They don't make it run slower
either but by themselves they don't
actually make any improvement to the
program. But, Typically, the optimizations
will interact. So performing one
optimization will enable another. And we
saw this in the little example that I did,
a few slides ago. So the way to think
about an optimizing compiler is that it
has a big bag of tricks. It has a lot of.
Individual program transformations that it
knows And what it is going to do when
faced with a program's optimize, it's
going to rummage around in its bag looking
for an optimization, that applies to some
part of the code. If it finds one, it will
do the optimization, it will do the
transformation and then it will repeat.
It'll go back and look at the program
again, and see if there's another
optimization that reapplies. Then it will
just keep doing this until it reaches a
point where none of the optimization's it
knows about can be applied to the
programming. Next, we'll take a look at a
bigger example and try applying some of
the optimizations that we've discussed, to
it, and see how far we get. And of course
this example has been constructed to
illustrate, many of the optimizations that
we discussed. So, the first thing we can
do. There are a couple of opportunities
for algebraic simplifications. So, we can
replace the squaring up here, by a
multiply. And down here we had a multiply
by two, which we can replace by a left
shift of one. Next we can observe that we
have some copies and constants. So we have
a constant assignment to b and a copy
assignment to c And those can be
propagated forward to the uses of b and c.
Once we've done that, we can do constant
folding. So here, the assignment to e, The
opera-, the arguments to the shift are all
constants, And so that can be replaced by
an assignment, that e gets the value six.
Next we could observe that we have a
common sub expression that we could
eliminate that both a and d have the value
x times x. So the assignment to d could be
replaced by a copy that d now gets the
value of a. Now we have two opportunities
again for copying constant propagation the
assignment to D and the assignment to E
can be propagated forward. And finally we
can do a bunch of dead code elimination.
So, assuming that, none of these values,
B, C, D, or E is used anyplace else in the
program, all four of these statements can
be deleted. And this is where we actually
get some real performance improvement. So
here we actually are now saving entire
instructions, and that's the best kind of
savings that we can have And so we wind up
with this as our final form. So notice
that a is assigned the value xx. F is
then assigned, the value a+a, And then g
is assigned the value sixf. Now, this is
not quite as fast as it could be, alright?
There's actually one more algebraic
optimization that could be done. We can
notice here that f is actually=to twoa,
And then we could do some rearrangement
here to discover that g=12f. Sorry, sorry
twelve x a. Alright, And then this
statement assignment to F might become
dead code, and we could delete it from the
program. I think some compilers would
actually find this, but I believe that
even current state of the art compilers,
many of them, would not discover this last
rearrangement to the program.