CS 4120 Spring 2023
Introduction to Compilers

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 SEQ), and statements inside expressions (because of ESEQ). Statements inside expressions means that an expression can cause side effects, and statements can cause multiple side effects. Another difference is the CJUMP 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:

We achieve this by lifting side effects in the IR syntax tree up to the top level. For example, consider the IR statement MOVE(MEM(TEMP(y)),ESEQ(MOVE(TEMP(x),TEMP(y)),ADD(TEMP(x),CONST(1))). Note that it contains a side effect on the variable TEMP(x). If it is lowered, a reasonable result would be MOVE(TEMP(x),TEMP(y));MOVE(MEM(TEMP(y)),ADD(TEMP(x),CONST(1))). 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 CJUMP 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 SEQ nodes, the code becomes a linear sequence of other kinds nodes of nodes. For brevity, we will write this sequence as s1;s2;;sn, essentially meaning the same thing as the input node SEQ(s1,,sn). The grammar for top-level statements is then:

s::=MOVE(dest,e)CALLm(f,e1,,en)JUMP(e)CJUMP(e,l1,l2)LABEL(l)RETURN(e1,,en)

Expressions e are the same as before but may not include ESEQ or CALL nodes. CALL nodes have become a statement form, with a side effect of updating special tempories RV1,...,RVm with the results of the call.

Translation functions

We express the lowering transformation using two syntax-directed translation functions, L[[s]] and L[[e]]:

L[[s]] translates an IR statement s to a sequence s1;;sn of canonical IR statements that have the same effect. We write L[[s]]=s1;;sn, or as a shorthand, L[[s]]=s.

L[[e]] translates an IR expression e to a sequence of canonical IR statements s that have the same effect, and an expression e that has the same value if evaluated after the whole sequence of statements s. We write L[[e]]=s ; e 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 L[[s]] 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 CJUMP'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:

e=CONST(i)e=NAME(l)e=TEMP(t)
L[[e]]=;e

For other simple expressions, we just hoist the statements out of subexpressions:

L[[e]]=s;e
L[[MEM(e)]]=s;MEM(e)
L[[e]]=s;e
L[[JUMP(e)]]=s;JUMP(e)
L[[e]]=s;e
L[[CJUMP(e,l1,l2)]]=s;CJUMP(e,l1,l2)

Since we can hoist statements, we can eliminate ESEQ nodes completely:

L[[s]]=s L[[e]]=s;e
L[[ESEQ(s,e)]]=s;s;e

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:

L[[ei]]=si;ei  i0n
L[[CALL(e0,e1,,en)]]=s0;MOVE(TEMP(t0),e0);s1;MOVE(TEMP(t1),e1);sn;MOVE(TEMP(tn),en);CALL1(t0,t1,,tn));MOVE(TEMP(t),RV1);TEMP(t)

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:

L[[e1]]=s1;e1 L[[e2]]=s2;e2
L[[OP(e1,e2)]]=s1;s2;OP(e1,e2)

This naive translation has some nice features: beyond its simplicity, it also keeps the expressions e1 and e2 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 s2 and e1. Let us assume that the language, like Java, dictates that expressions are evaluated in left-to-right order. If executing statements s2 changes the value that e1 computes, the lowering translation will change the behavior of the code. For example, e2 might use a function call that changes the contents of an array that e1 reads from. Therefore, the rule above can only be used if the result of evaluating the expression OP(e1,e2) does not depend on the order in which e1 and e2 are evaluated. In this case, we say that e1 and e2 commute:

L[[e1]]=s1;e1 L[[e2]]=s2;e2 e1 and e2 commute
L[[OP(e1,e2)]]=s1;s2;OP(e1,e2)

If they don't commute, the solution is to evaluate e1 first, store its result into a fresh temporary, and only then evaluate e2:

L[[e1]]=s1;e1 L[[e2]]=s2;e2
L[[OP(e1,e2)]]=s1;MOVE(TEMP(t1),e1);s2;OP(TEMP(t1),e2)

We usually prefer the first rule when it is safe to use it, because it keeps the subexpressions e1 and e2 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:

L[[SEQ(s1,,sn)]]=L[[s1]];;L[[sn]] For statements such as JUMP and CJUMP, we flatten the expression to obtain a sequence of statements that are done before the jump:
L[[e]]=s;e
L[[JUMP(e)]]=s;JUMP(e)
L[[e]]=s;e
L[[CJUMP(e,l1,l2)]]=s;CJUMP(e,l1,l2)
Some statements we can leave alone: L[[LABEL(l)]]=LABEL(l) The MOVE 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 TEMP:
L[[e]]=s;e
L[[MOVE(TEMP(x),e)]]=s;MOVE(TEMP(x),e)

However, what about a memory location used as a destination; that is, a statement MOVE(MEM(e1),e2). Assuming that the side effects of the subexpressions e1 and e2 are to be performed in left-to-right order, we'd like the following translation:

L[[e1]]=s1;e1 L[[e2]]=s2;e2
L[[MOVE(MEM(e1),e2)]]=s1;s2;MOVE(MEM(e1),e) (Move-Naive)

However, this translation moves the effects of computing e2 before the computation of the location of the memory to be updated, so it is only correct if the computation of e2 does not affect the destination of the MOVE. In fact, the reason why the rule for a MOVE into a TEMP 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 MOVE:

L[[dest]]=s1;dest L[[e2]]=s2;e2 e2 does not affect the contents of dest
L[[MOVE(dest,e2)]]=s1;s2;MOVE(dest,e) (Move-Commuting)

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 s change the location of dest, 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 MOVE is going to place the result of e2:

L[[e1]]=s1;e1 L[[e2]]=s2;e2 t is fresh
L[[MOVE(MEM(e1),e2)]]=s1;MOVE(TEMP(t),e1);s2;MOVE(MEM(TEMP(t)),e2); (Move-General)

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 TEMP nodes as their names and CONST names as their values: MOVE(MEM(y),ESEQ(MOVE(x,y),ADD(x,1))). The lowering of this expression is computed using rule (Move-Commuting): L[[MOVE(MEM(y),ESEQ(MOVE(x,y),ADD(x,1)))]]=s1;s2;MOVE(dest,e) where L[[MEM(y)]]=s1;dest=;MEM(y)L[[ESEQ(MOVE(x,y),ADD(x,1))]]=s2;e2=MOVE(x,y);ADD(x,1)

Putting the pieces together, we have MOVE(x,y);MOVE(MEM(y),ADD(x,1)), as expected.

What if the original statement had updated MEM(x) instead of MEM(y)? In that case, the computation of the MOVE expression would have changed the destination of the MOVE, so we'd use the (Move-General) rule: L[[MOVE(MEM(x),ESEQ(MOVE(x,y),ADD(x,1)))]]=s1;MOVE(TEMP(t),e1);s2;MOVE(MEM(TEMP(t)),e2);=MOVE(t,x);MOVE(x,y);MOVE(MEM(t),ADD(x,1))

Commuting statements and expressions

The rules for OP and MOVE both rely on interchanging the order of a lowered statement s and a pure expression e. 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 s uses a memory location MEM(e1) as a destination, and the expression reads from the memory location MEM(e2), and e1 might have the same value as e2 at run time, we cannot safely interchange the operations.

A simple, conservative approach is to assume that all MEM 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 MEM(TEMP(t)+CONST(k1)) and MEM(TEMP(t)+CONST(k2)) cannot be aliases if k1k2. (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 MEM nodes with different types cannot be aliases. If we keep around source-level type information for each MEM node, which we might denote as MEMt(e), then these type annotations t can help identify opportunities to reorder operations. Accesses to MEMt(e1) and to MEMt(e2) cannot conflict if t is not compatible with t.

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 MEM node, such that accesses to differently labeled MEM nodes cannot interfere with each other. The label could be as simple as an integer index, where MEMi and MEMj cannot be aliases unless i=j. We'll see how to do such a pointer analysis in a later lecture.