Static Single Assignment form, or
SSA, is the intermediate representation of choice for designing compilers for a vast variety of
languages and platforms. SSA, however, is usually cast in a very “imperative” style, making it
difficult to take advantage of functional programming when writing optimizations and analyses, and
extremely cumbersome to reason about effectively in a theorem prover. In this article, we discuss
how we might work towards a more “functional,” and in particular inductive, representation of SSA.
In particular, we will discuss representations of SSA inside the Lean theorem prover which I have developed over the course of my research, from a formalization of
“vanilla” 3-address code and SSA in freyd-ssa to my work on
inductive representations of SSA in debruijn-ssa.
Classical SSA
We begin by telling the story of freyd-ssa, our attempt to
formalize classical SSA mostly as is and prove some theorems. This is still very work-in-progress
and mostly abandoned, but it is sorry-free, and while it does not by any means follow best
practices, designing it was very educational!
A Type System for 3-address code
In 3 address code, a program is decomposed into a control-flow graph made up of basic blocks.
For example, this C program:
int f(int a, int b, int c) { int p = a + 5; int q = b + p; for (int i = 0; i < q; i++) { p += i + c; if (p % 3 == 0) { p += 2; } } return p;}
might become
^entry: p = a + 5; q = b + p; i = 0; br ^head^head: ite (i < q) ^exit ^body^body: i = i + 1; t = i + c; p = p + t; ite (p % 3) ^tt ^head^tt: p = p + 2; br ^head^exit: ret p
or, drawn as a graph,
In general, a program in three-adress code is composed of:
A control-flow graph, composed from a set of entries, exists, and
Basic blocks, which are defined as a linear sequence of instructions (the body)
followed by a terminator.
More formally, let’s consider instructions f:A1×...×An→B1×...×Bm taking in n parameters a1,...,an and returning m outputs of type B1,...,Bm. For
convenience, we will simply write such bundles of parameters as f:(Ai)i→(Bj)j.
We might begin by giving a typing judgement for bodies:
Γ⊢b:Δ
Γ⊢b:Δ means that if the variables in the context Γ are live on input to b, then the variables in the context Δ will be live once the instructions in b are
finished executing.
Here, a context is just a (finitely-supported) partial function from variables to types; the
domain of this function is our live variable set.
Γ≤Δ denotes that ΓweakensΔ, i.e., that if Γ(x)=A, then Δ(x)=A. So the first rule says that the empty body, ⋅, which just does nothing,
is allowed to throw away unused live variables in the input, but that’s it.
Γ,x:A denotes updating Γ(x) to A (whether or not it was previously defined). So
the second rule says that if b takes inputs Γ,(yj:Bj)j to Δ, then first
updating (yj)j to be the outputs of f(xi)i and then executing b takes Γ to Δ, assuming that Γ types each xi correctly to be an input of f.
For a quick sanity check, we can verify that this definition satisfies weakening on both the
left and right:
Γ′⊢b:Δ′Γ′≤ΓΓ⊢b:ΔΔ≤Δ′
We can also prove a variant of the frame rule (assuming all variables in Ξ are fresh!):
(Γ⊔Ξ)⊢b:(Δ⊔Ξ)Γ⊢b:Δ
A basic block, given a set of live variables on input, executes its body and then jumps to
another basic block via its terminator (for simplicity, we can model returns as jumping to a
special “return” label). We might represent this using the judgement
Γ⊢β:L
Just like before, Γ is the set of live variables on entry to the basic block β. On the
other hand, L is a label context: a finitely supported map from labelsℓ to
contexts Γ, which represent the variables which must be live on entry to ℓ, as below:
We might give the following simple typing rules for basic blocks
In other words, as one would expect, a basic block consists of a body followed by a terminator;
with the latter being either an unconditional branch (br) or a conditional branch (ite). The body transforms the variables required live on input, Γ, into the
variables guaranteed live on output, Δ; the conditions for terminators state that, for all
possible output labels ℓ, Δ is a weakening of the variables required for a branch to
that label, that is, L(ℓ)
The astute reader might notice that it would be perfectly equivalent, up to isomorphism, to elide
the syntactic category of bodies altogether, and give typing rules for basic blocks as follows:
It can often be useful to switch between these two ways of reasoning about basic blocks, as we will
see throughout this article.
Similarly, we can sanity-check our typing rules here by making sure weakenings hold. In particular,
we can define
L≤K⟺∀(ℓ,Γ)∈L.Γ≤K(ℓ)
Note that here K is allowed to have more labels than L (but not less),
however it must have less variables in the contexts associated with shared labels (whereas labels
not in L may be mapped to arbitrary contexts!) We then have the following theorem:
Γ′⊢β⊳L′Γ′≤ΓΓ⊢β⊳LL≤L′
Finally, a control-flow graphG can be viewed as a set of mutually-recursive basic blocks
taking a label context of entry points L to a label context of exit points K, as in the following picture:
Representing G as a finitely-supported map from labels ℓ to basic blocks β, the
judgement for this might look like the following:
L⊢G⊳K
with a typing rule of the form
L⊢G⊳KL≤R∀ℓ∈G.R(ℓ)⊢G(ℓ)⊳R∀ℓ∈/G.R(ℓ)=K(ℓ)
This rule is a bit complex, so let’s break it down:
We postulate the existence of a recursive label contextR such that:
L weakens R, i.e. for every label ℓ∈L, L(ℓ)≤R(ℓ). In particular, R is allowed to map labels
not in L to arbitrary contexts.
For every ℓ in G, if R(ℓ) are live on entry to G(ℓ) then all branches out
of G(ℓ) have the appropriate contexts in R live.
For every ℓ in R not in G, R(ℓ) is just a weakening of the
corresponding output label K(ℓ). Note the rule would be equally powerful if we
required an equality R(ℓ)=K(ℓ)
We can similarly state a weakening lemma
L′⊢G⊳K′L′≤LL⊢G⊳KK≤K′
and frame rule (assuming all labels in N are fresh!)
(L⊔N)⊢G⊳(K⊔N)L⊢G⊳K
SSA
So far, our type system and grammar applies to 3-address code in general, rather than SSA
specifically, and in particular much of our semantics work and optimizations work in this setting as
well. We can now define SSA form as a property of 3-address code: a piece of 3-address code is
said to be in SSA form if every variable is assigned to exactly once.
Conversion to SSA form, for straight-line code, is just variable renaming:
x = x + 3;y = x + 2;x = 3 + y;y = x + y;
becomes
x1 = x0 + 3;y0 = x1 + 2;x2 = 3 + y0;y1 = x2 + y0;
The reason we are interested in SSA is that this property allows for substitution: we can safely
substitute all occurences of x2 with 3 + y0, for example, whereas previously we would have to
keep control-flow in mind. In general, the ability to analyze the previous values of variables, and
do algebra with them, makes a huge class of analysis passes and optimizations much easier to
implement.
In the presence of branching, however, converting to SSA gets a bit more complicated: given
^entry: x = 5; ite p ^left ^right^left: x = 3 + x br ^end^right: x = 4 br ^end^end: x = 3 + x
trying to simply number our variables leaves us with the following:
In particular, it is dependent on control-flow whether x1 or x2 is used in the definition of x3. The solution to this issue is to introduce parameters for basic blocks, as follows 1
To take into account parameters, we can change our definition of label contexts to map labels ℓ to pairs (Γ,(Ai)i) of contexts Γ and tuples of parameters Ai.
We might then modify our typing judgement for blocks as follows:
Here, we define (Γ,(Ai)i)≤(Δ,(Bi)i)⟺Γ≤Δ∧∀i,Ai=Bi.
Similarly, control flow graphs must be modified to map labels ℓ to pairs ((xi)i,β) of
tuples of variable names (xi)i and basic blocks β parametrized the xis.
Note that these typing rules are still for 3-address code (extended with basic block parameters),
and well-typed programs do not necessarily have to be in SSA.
Adding an Expression Language
At this stage, we can define a predicate determining whether a control-flow graph is in SSA form
quite easily: all we need to do is check that for every basic block Γ,(xi:Ai)i⊢β⊳L, β does not overwrite any variables which were live on input to β, i.e., in Γ or {xi}. Unfortunately, writing down equations about substitutions
and rewrites, which was the point of moving to SSA in the first place, is still quite a pain in our
setting. For example:
Propagating a constant binding letx=c knowing that x is not redefined is sound
(the program being in SSA implying no variable is redefined), since constants cannot appear as
arguments to functions in our simple grammar, we need to instead create many constant bindings letxu=c; for each use u of x we want to replace, which at least temporarily
just seems to make our program more complicated. We also, of course, need to deal with fresh
variables.
We have to suffer a bit to perform algebraic optimizations such as simplifying x=y−5;z=x+5 to x=y−5;z=x, since we cannot directly rename variables.
We don’t know which operations we can safely substitute: an add is fine, but a call to print isn’t… and oftentimes, neither is a div due to risk of undefined behaviour on division by
zero.
We might want to detect more complicated multi-layer patterns, using advanced techniques such as
E-graph rewriting
In short, introducing instructions is a pain, and matching patterns of pure operations is a pain.
This in particular makes writing a formal substitution theorem a pain. To address this, we can
introduce a simple expression language, to replace operations, having the following typing
judgement
Γ⊢ϵa:A
This judgement says that, in the context Γ, the expressiona has type A and effectϵ. In general, our effects form a lattice, but for now, it is enough to consider pure
expressions with effect ⊥ and impure expressions with effect ⊤; only substitutions of the
latter are semantically sound! We will also extend contexts Γ to map variables x to pairs (A,ϵ) of a type and an effect: this allows us to reason about terms, bodies, blocks, and
CFGs with free variables of impure type, allowing us to reason about the syntactic soundness of
rewrites. 2
Note that, to effectively deal with multiple return values, we simply ban them, instead introducing
product types (A1×...×An), which we will write ΠiAi. We also annotate each expression with an effect ϵ.
Note that label contexts L are now mappings ℓ↦(Γ,A), i.e., there is
now only a single parameter type. Similarly, we modify the rules for CFGs as follows:
where control-flow graphs G are now mappings ℓ↦(x,β). Weakening and
label-weakening can be shown to hold exactly as before.
Note that this system is obviously isomorphic to the original, with expressions just introducing
anonymous temporary variable bindings and renamings.
Adding a Terminator Language
Similarly, it greatly simplifies label substitutions if we also have a language for terminators;
this also allows us to avoid introducing spurious basic blocks consisting only of conditional
branches, especially since we do not, in our simple language, have a switch-statement. In
particular, we introduce the following judgement, analogous to that for blocks, with the following
obvious rules:
Once again, this change doesn’t add any new expressive power to our system: we can convert a CFG in
the new system to a CFG in the old system by just introducing basic blocks with temporary names to
the CFG in the obvious way. One other useful property this system has is that it typechecks a strict
superset of the syntax the previous system does.
Substitution
Now that we have an expression language, we can attempt to formalize substitution, which can then
be used to formalize a wide variety of optimizations such as loop hoisting and constant propagation.
We begin by defining a substitutionσ:Γ→Δ to be a map from variables to
terms such that ∀(x,A,ϵ)∈Δ.Γ⊢ϵσ(x):A. In
particular, we say σ is pure if ∀(x,A,ϵ)∈Δ.Γ⊢⊥σ(x):A.
Stating substitution for expressions is quite straightforward: if σ:Γ→Δ,
then
Δ⊢ϵa:A⟹Γ⊢ϵ[σ]a:A
with this substitution semantically sound if σ is pure. Unfortunately, even graduating to
bodies makes things a lot more complicated. One issue is that, since we are working in a named
setting, it is nontrivial implement capture-avoiding substitution, since the usual approach of
renaming bound variables to fresh names won’t work if implemented as the names of bound variables
are visible on the right-hand side of the judgement for bodies. More concretely, given the body
let z = 3;let x = y;
and the substitution y↦3+z, we might try writing the substitution as
let z0 = 3;let x = y + z;
except now z does not have the appropriate value (3) at the end of the body! So we instead need
to write something like
let z0 = 3;let x = y + z;let z = z0;
Instead, we’ll do what real SSA-based compilers often do and simply use naive, non-capture
avoiding substitutions. This of course mans that a substitution is only valid if
∀x∈defs(b),σ(x)=x
Another complication is, considering the case where σ:Γ′→Γ, it is unclear
what Δ′ should be in
Γ⊢b:Δ⟹Γ′⊢[σ]b:Δ′
This is because some variables in x∈Δ may come from Γ (rather than being newly
defined in the body), but not be contained in Γ′, being instead defined as σ(x).
We hence introduce a new judgement
Γ⊢′b:Δ
where Δ consists of exactly the variables defined by b, with rules
This new judgement then respects substitution, with substitution lemma
Γ⊢′b:Δ⟹Γ′⊢[σ]b:Δ
We can proceed similarly for basic blocks and CFGs, but we have to do a whole lot more fiddling with
names, taking thousands of lines of Lean to formalize! It seems clear that, should we want to be
able to effectively state this and more complicated theorems (such as label-substitution), we will
need a more convenient representation of SSA.
Formalization
We have now obtained a system which is essentially the same as that formalized in freyd-ssa, except for a few small details, which we will go
over now.
In particular, types are generated freely from a set of base types X. For simplicity, we only
define pairs of types A×B, and an explicit unary type 1; these can simulate n-ary pairs ΠiAi by defining, e.g., Πi=1nAi=A1×Πi=2Ai. We
also include a builtin boolean type 2.
Hence, in particular, our term language only defines binary pairs and a constant () of type 1:
where G,ℓ(x)⇒β, as expected, denotes changing G(ℓ) from undefined to (x,β), with CFG well-typedness then given by
L⊢G⊳KL≤RR⊢′G⊳K
The reason for this had to do with wanting a more inductive semantics; in retrospect, I can’t think
of why exactly we went for this this at the moment, but that’s what was formalized and we have to be
honest! This is roughly equivalent to the formulation given above, though it provides a bit more
power, since “dead” code, i.e. ℓ not reachable from the entry context, does not need to
typecheck.
Adding Coproducts
One of the design goals for freyd-ssa was to keep things as
conventional as possible. Hence, we did not support coproducts, and instead only provided a Boolean
type 2. However, effectively reasoning about coproducts can not only allow us to perform
various interesting optimizations, especially when the source language supports them, but also makes
it a lot easier to prove category-theoretic properties of our semantics. So, in debruijn-ssa, we choose to support them.
In particular, our types are now given by the grammar
For control-flow, we replace ite with a case terminator, which binds
variables, similarly to pattern-matching:
Γ⊢casee(x⇒s)(y⇒t)⊳LΓ⊢ϵe:A+BΓ,x:A⊢s⊳LΓ,y:B⊢t⊳L
Note that we can easily simulate booleans in this setting by defining
2=1+1tt=inl()ff=inr()iteest=casee(⋅⇒s)(⋅⇒t)
Explicit Scoping and De-Bruijn Indices
Now that we’ve seen how difficult it is to state substitution when using names as above, we want to
develop an inductive representation of SSA which is easier to reason about, while at the same time
isomorphic to our original language. To do this, we have to think carefully about how variables and
labels are scoped in SSA.
Dominator Trees
Consider the control-flow graph below:
Here, the entry block, A, defines variables x,y, and then jumps to either B or D. B defines z and then jumps to C unconditionally, which then jumps to E, whereas D defines w and then jumps straight to E.
We might want to ask ourself which variables can be used, i.e. are live, at each point in the
program. We can perform a liveness analysis as follows:
At the very beginning of the program, the definition of x in A, no variables are yet live
Immediately afterwards, at the definition of y, only x is live
On entry to B, at the definition of z, x,y are live, while on entry to C, x,y,z are
live
Similarly, on entry to D, at the definition of w, x,y are live
On the other hand, only x,y are live on entry to E. This is because
z cannot be live, since if we came from D, it would be undefined
w cannot be live, since if we came from C, it would be undefined
In other words, the live variables on entry to any basic block are the intersection of the live
variables on exit from any basic block which jumps to that basic block. This gives us a recipe for
building a dataflow analysis to compute live-variable sets, one of the fundamental building blocks
of classical techniques for building compilers for 3-address code.
If our program is additionally in SSA, however, things are a bit simpler, and more interesting. Note
that a variable x is live at the entry to a given basic block B if and only if all paths to
that block through the control-flow graph reaching B go through at least one place where the x is defined. If our program is in SSA, each variable x is defined at exactly one point A, so a x is live if and only if all paths reaching B through the control-flow graph go through A,
i.e., in graph-theory speak, if AdominatesB. Note that dominance is a transitive and
reflexive relation.
Of course, since in general x is not visible in its own definition (or for variables defined
before x in the same basic block A!), we want to consider whether Astrictly dominates B,
i.e., whether A dominates B and A=B; if this is the case, we can be certain all variables
defined in A are live on reaching B.
In general, the dominance tree of a control-flow graph encodes this relation; it has:
Nodes for each basic block in the control-flow graph
An edge from node A to B if A strictly dominates Band there is no intermediate node A′ such that A dominates A′ and A′ dominates B.
For example, the dominance tree of the example control-flow graph from before can be written:
As long as all nodes in the control-flow graph are reachable (in the graph-theoretic sense, even if
control flow will never actually reach them!) from the program entry point, this is indeed always a
tree; this property always holds after unreachable code elimination. To see why, consider the case
where A and B both strictly dominate C. It suffices to show that A dominates B or B dominates A. Assume that A does not dominate B and B does not dominate A. Then there
exists a path pA from the entry point to A which does not reach B, and a path pB from the
entry point to B which does not reach A. It follows that all paths from B to C must reach A, as if there was a path q which did not do so, then pB;q (where ; denotes path
composition) would go from the entry point to C without reaching A, yielding a contradiction. By
symmetry, all paths q from A to C must reach B.
We wish to show that there is no path q from A to C or from B to C. To do show, we prove
that any such path must be infinitely long, i.e., for all n, it must be longer than n. We
proceed by induction:
Inductive hypothesis P(n):=∀k≤n. “the path cannot be of length ≤n”
P(0): The path cannot be of length 0, since A,B=C
P(n)⟹P(n+1): if the path is of length n+1, then it must go through either A or B after 0<k<n+1 steps. But the sub-path from this point to C is of length (n+1)−k≤n, yielding a contradiction.
Going back to the dominance tree we drew, we can add our control-flow edges back in in grey:
Let’s consider what kind of control edges we could add without changing the dominance structure. As
we would hope, it is always OK to add control edges to direct descendants in the dominance tree:
Similarly, control edges between siblings seem to be fine:
It also seems to be fine to add control edges to the siblings of our ancestors, our “uncles”:
As well as to parents, grandparents, and ourselves
But edges to our grandchildren would violate the dominance structure, since, for example, adding an
edge from A to C would create a path from the entry to C which does not reach B.
This points to an organization of our program into regions, such that
Each node in the dominance tree is the entry block of its region
The children of that node are the region’s children
All a node’s descendants, including the node itself, are considered inside that node’s region
Now, the correctness rule becomes very simple: “every control-flow edge going from the outside of a
region to the inside of a region must target the entry-block of that region.”
We might hence represent a region as a basic block, followed by a list of child regions which only
this entry block can call (the entry blocks of)
In particular, since liveness is now taken care of by the structure of the CFG itself, we no longer
need to keep track of live variable sets in label contexts, and hence can define them to simply be
maps L from labels to parameter types A.
We may hence introduce the syntactic class of regions l,r with typing rule
The astute reader may notice that in both this typing rule and the above diagram, the entry block
cannot necessarily call itself. However, any region other than the root of the CFG will have its own
address available to jump to as a sibling; consequently, the natural way to represent a programs
CFG is by nesting in a dummy outermost region with an unconditional jump to the program’s entry
point as entry block.
Recovering a standard control-flow graph from such a region is simple: all one needs to do is to
“flatten” the syntax tree as follows (assuming all labels are fresh for simplicity):
Graphically, this simply corresponds to “erasing the region boundaries”, here in orange:
De-Bruijn Indices
One of the major advantages of this representation is, having gotten ride of the cumbersome original
“context of contexts” definition for label contexts L, we now have a clear system of
variable scoping, following the structure of the dominance tree. This allows us to use de-Bruijn
indices for variables, rather than names. Since labels also obey this scoping system, we may use
de-Bruijn indices for them too. For simplicity, however, we will use names to describe the typing
rules in this article.
At this point we’ve described the BBRegion data structure in debruijn-ssa, except that our expression language is
extended into a term language, which we will elaborate on later.
Removing Bodies
Substitution is much easier to state in this new framework.: in particular, since we now have a
strict variable scoping discipline, we may use capture-avoiding substitution, such that, in
particular, for any substitution σ:Γ→Δ, we have
Δ⊢r⊳L⟹Γ⊢[σ]r⊳L
That said, actually proving it is still slightly irritating, since of course our rules for bodies
remain unchanged. Thankfully, things now become much easier if, analogously to our previous
transformation for basic blocks, we fuse bodies and regions, as follows:
We already have a pretty decent inductive representation for SSA at this point. One additional thing
we want to be able to reason about effectively, however, is control-flow graph manipulations, such
as composing control-flow graphs by connecting their inputs and outputs.
One way to state such rewrites effectively is through label substitution, in which branches brℓa are replaced with arbitrary code parametrized by a. Unfortunately, this is
a bit unwieldy when terminators can only contain control-flow. Thankfully, it’s straightforward to
generalize this problem away: all we need to do is merge terminators and regions, as follows:
For brevity, we will omit the full normalization algorithm.
Label substitution
We can now define a label substitutionσ to be given by a map from labels ℓ to regionsr parametrized by a free variable xℓ. We say such a substitution σ:Γ;L→K is well-typed in Γ if
∀(ℓ,A)∈L,Γ,xℓ:A⊢σ(ℓ)⊳K
In this case, we have that
Γ⊢r⊳L⟹Γ⊢[σ]r⊳K
where [σ]r is capture-avoiding (for both labels and variables; this is much easier to state
with de-Bruijn indices!) substitution of labels defined as follows:
We now come to the final change required for some of the optimizations we would like to do:
generalizing our expression language to a term language. It turns out that sometimes we want to
splice and merge a control-flow graph based on algebraic rewriting; the simplest way to do this is
to allow (branching) control-flow to be represented as part of our algebraic expression language, as
follows:
Again, this grammar can represent strictly more programs than those based on the old expression
language. Given a region parametrized by terms, we can rewrite it to a region valid in the old
grammar using obvious semantics-preserving rewrites such as
We have now finished describing the inductive representation of SSA formalized in debruijn-ssa. For convenience and clarity, we restate the
system in its final form here:
WIP: a proof of completeness for this equational theory
There’s a whole lot of future work in the wings as well, including:
A version of this theory supporting linearity, which has mostly been worked out on paper. This
allows applications to fundamentally linear domains such as quantum computing, as well as
linearity-dependent rewriting and settings such as probabilistic programming.
Work on making the formalized theorem more streamlined, and supporting features such as n-ary
bindings, tuples, and sums
Support for regions as parameters to instructions, like in MLIR
But, I think this article is already long enough! Until next time!
Traditionally, this is implemented using a Φ-node to carry the argument,
but this is isomorphic to the more modern basic-blocks-with-arguments approach.↩
We could also use terms with holes, which are more general, however there are some potential
advantages to supporting both.↩