IR Lowering
After doing the translations described thus far, we arrive at an
IR version of the program code. However, this code is still not
very assembly-like in various respects: it contains complex
expressions and complex statements (because of ), and statements
inside expressions (because of ). Statements inside expressions
means that an expression can cause side effects, and statements can
cause multiple side effects. Another difference is the
statement can jump to two different places, whereas in assembly,
a conditional branch instruction falls through to the next instruction
if the condition is false.
To bring the IR closer to assembly we can flatten statements and
expressions, resulting in a lower-level IR in which:
- There are no nested s; just one top-level sequence of lowering statements.
- There are no s.
- Each statement contains at most one side effect (or call).
- All nodes appear at the top of the tree as IR statements.
We achieve this by lifting side effects in the IR syntax tree up
to the top level. For example, consider the IR statement
.
Note that it contains a side effect on the variable
. If it is lowered, a reasonable result would be
. Viewed as trees, the transformation
lifts side effects to the top of the tree while ordering them
in sequence:
We will also want the “false” target of a to alway go to the very
next statement, but that will be done in a separate transformation.
Canonical IR
We'll express IR lowering as yet another syntax-directed
translation. Unlike the previous translations, the source and target
of the translation are both varieties of intermediate representation. We can describe
the target language with a grammar that captures the restrictions
listed above. First, since there are no nested
nodes, the code becomes a linear sequence of other kinds nodes
of nodes. For brevity, we will write this sequence as
, essentially meaning the same thing as the input
node . The grammar for top-level statements is then:
Expressions are the same as before but may not include
or nodes. nodes have become a statement form, with
a side effect of updating special tempories with
the results of the call.
Translation functions
We express the lowering transformation using two syntax-directed
translation functions, and :
translates an IR statement to a sequence
of canonical IR statements that have the same effect.
We write , or as a shorthand, .
translates an IR expression to a sequence of canonical
IR statements that have the same effect, and an expression
that has the same value if evaluated after the whole sequence of
statements . We write to denote
this. Notice that this semicolon is not a “real” semicolon in the
language; it is merely there to separate the two results of the
translation, and the semicolon symbol is chosen to emphasize the
sequential ordering of the two parts.
Given these translation functions, we can apply to the
IR for each function body to obtain a linear sequence of IR statements
representing the function code. This will get us much closer to
assembly code for each function.
Note that these translations will not ensure the fall-though property
for 's. That property will be enforced in a subsequent phase.
Lowering expressions
Our goal is to convert an expression into one that has no side
effects, the side effects being factored out into a sequence of
statements that are hoisted to the top level of the generated code.
If we use to represent an empty sequence of statements,
expressions that already have no side effects are trivial to lower.
We can write these translations as an inference rule:
For other simple expressions, we just hoist the statements out of
subexpressions:
Since we can hoist statements, we can eliminate nodes
completely:
Call nodes must be hoisted too because they can cause side effects.
And the side effects of computing
arguments must be prevented from changing the computation of other arguments:
Binary operations are surprisingly tricky. A naive first cut at a lowering
translation would be to simply hoist the side effects of both
expressions to the top level:
This naive translation has some nice features: beyond its simplicity, it
also keeps the expressions and together as part of a
larger expression, which helps with the quality of later code
generation.
However, there's a problem with this naive translation: it reorders
the evaluation of and . Let us assume that the language,
like Java, dictates that expressions are evaluated in left-to-right
order. If executing statements
changes the value that computes, the lowering
translation will change the behavior of the code. For example,
might use a function call that changes the contents of an array that
reads from.
Therefore, the rule above can only be used if the result of evaluating
the expression does not depend on the order in which
and are evaluated. In this case, we say that and
commute:
If they don't commute, the solution is to evaluate first,
store its result into a fresh temporary, and only then evaluate :
We usually prefer the first rule when it is safe to use it, because
it keeps the subexpressions and together. Breaking code
code into more and smaller statements may eliminate opportunities to
generate efficient code. For example, keeping expressions may enable
the constant-folding optimization. As we'll see later when we do
instruction selection, big expression trees are helpful for generating
efficient assembly code, because it enables choosing more powerful
assembly-language instructions that compute more of the IR tree at
once.
Lowering statements
Recall that the lowering translation lifts all statements up into
a single top-level sequence of statements.
Hence, we translate a sequence of statements by lowering each
statement in the sequence to its own sequence of statements, and
concatenating the resulting sequences into one big sequence:
For statements such as and , we flatten the expression
to obtain a sequence of statements that are done before the jump:
Some statements we can leave alone:
The statement is another tricky case. It is useful to consider
the different kinds of destinations separately. The translation is
simple when the destination is a :
However, what about a memory location used as a destination; that is,
a statement . Assuming that the side
effects of the subexpressions and are to be performed in
left-to-right order, we'd like the following translation:
However, this translation moves the effects of computing before
the computation of the location of the memory to be updated, so it is
only correct if the computation of does not affect the
destination of the . In fact, the reason why the rule for a
into a was correct was the same: the destination couldn't be
affected by the expression being moved. (Note that it's fine for
the expression to affect the contents of the destination; we are
only concerned whether it changes the location of the destination.)
We can exploit this insight to
write a single rule that combines the two previous rules for :
Note that we have not defined what it means to lower a destination.
That is because IR expressions have been crafted in such a way that
we can reuse the translation for lowering expressions.
Of course, this translation is not safe in general. The problem is the same
as with the translation of binary expressions: if
the statements change the location of , the translated
program does something different from the original. In this case, we
need a (generally less efficient) rule that uses a temporary to
save the address into which the is going to place the result
of :
Examples
To see that the lowering translation works, let's consider the example
from above, which we can write a bit more compactly by representing
nodes as their names and names as their values:
. The lowering of
this expression is computed using rule (Move-Commuting):
where
Putting the pieces together, we have , as expected.
What if the original statement had updated instead of
? In that case, the computation of the expression
would have changed the destination of the , so we'd use
the (Move-General) rule:
Commuting statements and expressions
The rules for and both rely on interchanging the order
of a lowered statement and a pure expression . This can be done safely when the
statement cannot alter the value of the expression. There are two ways
in which this could happen: the statement could change the value of
a temporary variable used by the expression, and the statement could
change the value of a memory location used by the expression. It is
easy to determine whether the statement updates a temporary used by
the expression, because temporaries have unique names. Memory is
harder because two memory locations can be aliases. If the statement
uses a memory location as a destination, and the
expression reads from the memory location , and
might have the same value as at run time, we cannot
safely interchange the operations.
A simple, conservative approach is to assume that all nodes are
potentially aliases, so a statement and an expression that both use
memory are never reordered. We can do better by exploiting the
observation that some expressions cannot be equal. For example,
two nodes of the form
and cannot be aliases if
. (We also need to know that the language run-time
system will never map two virtual addresses to the same physical
address.)
To do a good job of reordering memory accesses, we want more help
from analysis of the program at source level. Otherwise there is not
enough information available at the IR level for alias analysis to
be tractable.
A simple observation we can exploit is that if the source language is
strongly typed, two nodes with different types cannot be
aliases. If we keep around source-level type information for each
node, which we might denote as , then these type
annotations can help identify opportunities to reorder operations.
Accesses to and to cannot conflict if
is not compatible with .
Sophisticated compilers incorporate some form of pointer analysis
to determine which memory locations might be aliases. The typical
output of pointer analysis is a label for each distinct node,
such that accesses to differently labeled nodes cannot
interfere with each other. The label could be as simple as an integer
index, where and cannot be aliases unless .
We'll see how to do such a pointer analysis in a later lecture.