









Study with the several resources on Docsity
Earn points by helping other students or get them with a premium plan
Prepare for your exams
Study with the several resources on Docsity
Earn points to download
Earn points by helping other students or get them with a premium plan
These lecture notes discuss the concept of Static Single Assignment (SSA) form, which is a de facto standard intermediate form in many compilers and compiler tools such as LLVM. The notes cover basic blocks, SSA for straight-line code, and SSA for code containing loops and conditionals. The notes also provide examples of how to put basic blocks into SSA form and how to convert each block into SSA form using a global generation counter throughout.
Typology: Lecture notes
1 / 15
This page cannot be seen from the preview
Don't miss anything!










In abstract machine code of the kind we have discussed so far, a variable of a given name can refer to different values even in straight-line code. For example, in a code fragment such as
1 : i ← 0
... k : if (i < 0) then error else continue
we can apply constant propagation of 0 to the condition (turning into a goto continue) only if we know that the definition of i in line 1 is the only one that reaches line k. It is possible that i is redefined either in the region from 1 to k, or somewhere in the rest of the program followed by a backwards jump. It was the purpose of the reaching definitions analysis in to determine whether this is the case. If lines 1 - k are part of a single basic block, then our task is simpler: we only need to check whether there is an intervening definition of i within the basic block. An alternative is to relabel variables in the code so that each variable is defined only once in the program text. If the program has this form, called static single assignment (SSA), then we can perform constant propagation immediately in the example above without further checks. There are other program analyses and op- timizations for which it is convenient to have this property, so it has become a de facto standard intermediate form in many compilers and compiler tools such as LLVM. In this lecture we develop SSA, first for straight-line code and then for code containing loops and conditionals. Our approach to SSA is not entirely standard, although the results are the same on control flow graphs that can arise from source programs in the language we compile.
2 Basic Blocks
As before, a basic block is a sequence of instructions with one entry point and one exit point. In particular, from nowhere in the program do we jump into the middle of the basic block, nor do we exit the block from the middle. In our language, the last instruction in a basic block should therefore be a return, goto, or if. On the inside of a basic block we have what is called straight-line code, namely, a sequence of moves or binary operations. It is easy to put basic blocks into SSA form. For each variable, we keep a genera- tion counter to track which definition of a variable is currently in effect. We initialize this to 0 for any variable live at the beginning of a block. Then we traverse the block forward, replacing every use of a variable with its current generation. When we see a redefinition of variable we increment its generation and proceed. As an example, we consider the following C0 program on the left and its trans- lation into a single basic block on the right.
int dist(int x, int y) { dist(x,y): x = x * x; x <- x * x y = y * y; y <- y * y return isqrt(x+y); t0 <- x + y } t1 <- isqrt(t0) return t
Here isqrt is an integer square root function previously defined. We have as- sumed a new form of instruction
d ← f (s 1 ,... , sn)
where each of the sources si is a constant or variable, and the destination d is an- other variable. We have also marked the beginning of the function with a parame- terized label that tracks the variables that may be live in the body of the function. The parameters x and y start at generation 0. They are defined implicitly because they obtain a value from the arguments to the call of dist.
dist(x0,y0): ------------- x/0, y/ x <- x * x y <- y * y t0 <- x + y t1 <- isqrt(t0) return t
We mark where we are in the traversal with a line, and indicate there the current generation of each variable. The next line uses x, which becomes x 0 , but is also defines x, which therefore becomes the next generation of x, namely x 1.
3 Loops
To appreciate the difficulty and solution of how to handle more complex programs, we consider the example of the exponential function, where pow(b, e) = be^ for e ≥
int pow(int b, int e) //@requires e >= 0; { int r = 1; while (e > 0) //@loop_invariant e >= 0; // r*b^e remains invariant { r = r * b; e = e - 1; } return r; }
We translate this to the following abstract machine code, which is comprised of basic blocks:
pow(b,e): r <- 1 loop: if (e > 0) then done else body body: r <- r * b e <- e - 1 goto loop done: return r
There are two ways to reach the label loop: when we first enter the loop, or from the end of the loop body. This means the variable e in the conditional branch really could refer to either the procedure argument, or the value of e after the decrement operation in the loop body. Therefore, our straightforward idea for SSA conversion of straight line code no longer works. The key idea is to parameterized labels (the jump targets) with the variables that are live in the block that follows. The variant of liveness necessary here can be calculated with respect to the block-structured AST – it is not necessary to use the more potentially expensive liveness analysis based on dataflow. One can also safely, but redundantly, just use all variables, or all variables that are declared and
defined at that point in the program. We use this approach in the following exam- ple. Labels l occurring as targets in goto l or if (−) then l else l′^ are then given matching arguments.
pow(b,e): r <- 1 goto loop(b,e,r)
loop(b,e,r): if (e > 0) then body(b,e,r) else done(b,e,r)
body(b,e,r): r <- r * b e <- e - 1 goto loop(b,e,r)
done(b,e,r): return r
Next, we convert each block into SSA form with the previous algorithm, but us- ing a global generation counter throughout. An occurrence in a label in a jump goto l(... , x,.. .) is seen as a use of x, while an occurrence of a variable in in a jump target l(... , x,.. .) is seen as a definition of x. Applying this to the first block we obtain
pow(b0,e0): r0 <- 1 goto loop(b0,e0,r0) -------------------- b/0, e/0, r/ loop(b,e,r): if (e > 0) then body(b,e,r) else done(b,e,r)
body(b,e,r): r <- r * b e <- e - 1 goto loop(b,e,r)
done(b,e,r): return r
into variables b 1 , e 1 , and r 1. That fact that labeled jumps correspond to moving values from arguments to label parameters will be the essence of how to generate assembly code from the SSA intermediate form in Section 7.
4 SSA and Functional Programs
We can notice that at this point the program above can be easily interpreted as a functional program if we read assignments as bindings and labeled jumps as function calls. We show the functional program below on the right in ML-like form.
pow(b0,e0): fun pow (b0, e0) = r0 <- 1 let val r0 = 1 goto loop(b0,e0,r0) in loop (b0, e0, r0) end
loop(b1,e1,r1): and loop (b1, e1, r1) = if (e1 > 0) if e1 > 0 then body(b1,e1,r1) then body (b1, e1, r1) else done(b1,e1,r1) else done (b1, e1, r1)
body(b2,e2,r2): and body (b2, e2, r2) = r3 <- r2 * b2 let val r3 = r2 * b e3 <- e2 - 1 val e3 = e2 - 1 goto loop(b2,e3,r3) in loop (b2, e3, r3) end
done(b3,e4,r4): and done (b3, e4, r4) = return r4 r
There are several reasons this works in general. First, in SSA form each variable is defined only once, which means it can be modeled by a let binding in a functional language. Second, each goto is at the end of a block, which translates into a tail call in the functional language. Third, because all jumps become tail calls, a return instruction can be modeled simply be returning the corresponding value. We conclude that translation into SSA form is just translating abstract machine code to a functional program! Because our language does not have first-class func- tions, the target of this translation also does not have higher-order functions. Inter- estingly, this observation also works in reverse: a (first-order) functional program with tail calls can be translated into abstract machine code where tail calls become jumps. While this is clearly an interesting observation, it does not directly help our compiler construction effort (although it might if we were interested in compiling a functional language).
5 Optimization and Minimal SSA Form
At this point we have constructed clean and simple abstract machine code with parameterized labels. But are all the parameters really necessary? Let’s reconsider:
pow(b0,e0): r0 <- 1 goto loop(b0,e0,r0)
loop(b1,e1,r1): if (e1 > 0) then body(b1,e1,r1) else done(b1,e1,r1)
body(b2,e2,r2): r3 <- r2 * b e3 <- e2 - 1 goto loop(b2,e3,r3)
done(b3,e4,r4): return r
There is no need to pass b 1 , e 1 , and r 1 to body and assign their values to b 2 , e 2 , and r 2 (respectively). Instead, we could remove these arguments and instead substitute b 1 for b 2 , e 1 for e 2 , and r 1 for r 2. The same goes for the arguments to done, though we could also conclude that b 3 and e 4 are unnecessary because those temps are never even live. This yields:
pow(b0,e0): r0 <- 1 goto loop(b0,e0,r0)
loop(b1,e1,r1): if (e1 > 0) then body() else done()
body(): r3 <- r1 * b e3 <- e1 - 1 goto loop(b1,e3,r3)
done(): return r
6 φ Functions
Let’s take a look at the pre-minimization version of our program again. Every use of a parameterized label has been labeled with an uppercase letter:
pow(b0,e0): // A r0 <- 1 goto loop(b0,e0,r0) // B
loop(b1,e1,r1): // C if (e1 > 0) then body(b1,e1,r1) // D else done(b1,e1,r1) // E
body(b2,e2,r2): // F r3 <- r2 * b e3 <- e2 - 1 goto loop(b2,e3,r3) // G
done(b3,e4,r4): return r
The information we need to minimize this SSA program is, unfortunately, rather scattered about. In order to check whether we can remove the b 1 argument to loop, we have to check lines B, C, and F. But this is just a problem of how information is organized in the program: first, we have to look at a line (example: E), then we can see which label we’re jumping to (example: goto), then we can look and see which source temps (example: b 1 , e 1 , r 1 ) will get substituted for which destination temps (example: b 3 , e 4 , r 4 ). Put differently, the SSA part of the program is representable as a bunch of facts of the form jump(line, label, src, dst), and in the example program above these facts are arranged in the following order:
jump(B, loop, b0, b1) jump(E, done, b1, b3) jump(B, loop, e0, e1) jump(E, done, e1, e4) jump(B, loop, r0, r1) jump(E, done, r1, r4) jump(D, body, b1, b2) jump(G, loop, b2, b1) jump(D, body, e1, e2) jump(G, loop, e3, e1) jump(D, body, r1, r2) jump(G, loop, r3, r1)
But to perform SSA minimization, we want to arrange all these facts in a differ- ent way. Specifically, we want to gather all the facts with the same parameterized label and the same destination together, because if there is only one src that is asso- ciated with a given dst, or if there are two sources but one of them is equal to dst, then the parameter is unnecessary.
Rearranged for minimization, our program facts look like this, and it is imme- diately apparent that we can get rid of all the labels to body and done.
jump(B, loop, b0, b1) jump(D, body, b1, b2) <-- unneeded, b1=b jump(G, loop, b2, b1) jump(D, body, e1, e2) <-- unneeded, e1=e jump(B, loop, e0, e1) jump(D, body, r1, r2) <-- unneeded, r1=r jump(G, loop, e3, e1) jump(E, done, b1, b3) <-- unneeded, b1=b jump(B, loop, r0, r1) jump(E, done, e1, e4) <-- unneeded, e1=e jump(G, loop, r3, r1) jump(E, done, r1, r4) <-- unneeded, r1=r
(Observe that it is not immediately apparent that we can get rid of b 1 , because there are two sources b 0 and b 2 and neither one is equal to b 1. We only learn that we can get rid of the parameter b 1 after we perform the substitution of b 1 for b 2 .) What would the program look like if we presented it in a way that made min- imization easier? We would need, associated with every parameter and every pa- rameterized label, a list of the the lines that might jump to the parameterized label and the temp that should get substituted for the parameter when we jump from that line. The loop block would look like this:
loop: b1 <- b0 if coming here from line B, b2 if coming here from line G e1 <- e0 if coming here from line B, e3 if coming here from line G r1 <- r0 if coming here from line B, r3 if coming here from line G
This organization is actually the traditional way of presenting SSA form, except that SSA is usually presented in a more compact form called φ-functions. The idea is the same, but we don’t mention call sites explicitly, instead we say b 1 ← φ(b 0 , b 2 ) to represent that b 1 should, at the beginning of the loop block, be assigned to ei- ther b 0 or b 2 (whichever one we most recently wrote to). Applied to our pre- minimization example program, we get this:
pow(b0,e0): r0 <- 1 goto loop
loop: b1 <- phi(b0,b2) e1 <- phi(e0,e3) r1 <- phi(e0,e3) if (e1 > 0) then body else done
In conclusion, φ-function SSA is important for a number of reasons:
7 Assembly Code Generation from SSA Form
Of course, actual assembly code does not allow parameterized labels. To recover lower level code, we need to implement labeled jumps by moves followed by plain jumps. We show this again on the first example, with functional SSA and the left and the de-SSA form on the right.
pow(b0,e0): pow(b0,e0): r0 <- 1 r0 <- 1 goto loop(e0,r0) e1 <- e r1 <- r goto loop
loop(e1,r1): loop: if (e1 > 0) if (e1 > 0) then body() then body else done() else done
body(): body: r3 <- r1 * b0 r3 <- r1 * b e3 <- e1 - 1 e3 <- e1 - 1 goto loop(e3,r3) e1 <- e r1 <- r goto loop
done(): done: return r1 return r
In some cases of conditional jumps, there may be no natural place for the addi- tional move instructions. This can be addressed by switching to an extended basic
block format, or by adding a new basic block that performs the moves required by SSA. Either way, we retain here the parameters at the function boundary; we will talk about the implementation of function calls in a later lecture. The new form on the right is, of course, no longer in SSA form. Therefore one cannot apply any SSA-based optimization. Conversion out of SSA should therefore be one of the last steps before code emission. At this point register allocation, pos- sibly with register coalescing, can do a good job of eliminating redundant moves.
8 Conclusion
Static Single Assignment (SSA) form is a quasi-functional form of abstract machine code, where variable assignments are variable bindings, and jumps are tail calls. It was devised by Cytron et al. [CFR+89] and simplifies many program analyses and optimization. Of course, you have to make sure that program transforma- tions maintain the property. The particular algorithm for conversion into SSA form we describe here is to due Aycock and Horspool [AH00]. A final note about Ay- cock and Horspool’s algorithm: it works for arbitrary SSA programs, but it only produces the minimal SSA for some programs. (Programs for which Aycock and Horspool’s algorithm finds the minimal SSA are called reducible. All C0 programs are reducible, so the algorithm will always find the minimal SSA if you use it in your compiler.) Hack has shown that programs in SSA form generate chordal interference graphs which means register allocation by graph coloring is particularly efficient [Hac07]. For further reading and some different algorithms related to SSA, you can also consult the Chapter 19 of the textbook [App98].
Questions