Skip to content

Commit

Permalink
Overhaul SSA representation text
Browse files Browse the repository at this point in the history
  • Loading branch information
sampsyo committed Feb 24, 2025
1 parent 4ff1c88 commit 07303c7
Showing 1 changed file with 134 additions and 21 deletions.
155 changes: 134 additions & 21 deletions content/lesson/6.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,27 +112,6 @@ You can write the above program in SSA like this:

It can also be useful to see how ϕ-nodes crop up in loops.

(An aside: some recent SSA-form IRs, such as [MLIR][] and [Swift's IR][sil], use an alternative to ϕ-nodes called *basic block arguments*.
Instead of making ϕ-nodes look like weird instructions, these IRs bake the need for ϕ-like conditional copies into the structure of the CFG.
Basic blocks have named parameters, and whenever you jump to a block, you must provide arguments for those parameters.
With ϕ-nodes, a basic block enumerates all the possible sources for a given variable, one for each in-edge in the CFG;
with basic block arguments, the sources are distributed to the "other end" of the CFG edge.
Basic block arguments are a nice alternative for "SSA-native" IRs because they avoid messy problems that arise when needing to treat ϕ-nodes differently from every other kind of instruction.)

[mlir]: https://mlir.llvm.org
[sil]: https://github.com/apple/swift/blob/main/docs/SIL.rst

### Bril in SSA

Bril has an [SSA extension][bril-ssa].
It adds support for a `phi` instruction.
Beyond that, SSA form is just a restriction on the normal expressiveness of Bril—if you solemnly promise never to assign statically to the same variable twice, you are writing "SSA Bril."

The [reference interpreter][brili] has built-in support for `phi`, so you can execute your SSA-form Bril programs without fuss.

[bril-ssa]: https://capra.cs.cornell.edu/bril/lang/ssa.html
[brili]: https://capra.cs.cornell.edu/bril/tools/interp.html

### The SSA Philosophy

In addition to a language form, SSA is also a philosophy!
Expand Down Expand Up @@ -208,6 +187,140 @@ For a more extensive take on how to translate out of SSA efficiently, see [“Re

[boissinot]: https://hal.inria.fr/inria-00349925v1/document

### Alternative SSA Representations

There are some tricky problems with ϕ-nodes that make them a little more subtle than they may appear at first.
You can either deal with them directly or pick a "twist" on the classic SSA formula that can help avoid these subtleties altogether.

#### Subtleties in Classic SSA

While ϕ-nodes behave like "normal" instructions in some ways, they are different in weird in other ways.
The biggest way in which ϕ-nodes are weird is that their semantics depends on the control-flow history of the program.
Returning to our first example above:

a.4: int = phi .left a.2 .right a.3;

The result we produce in `a.4` depends on something that happened *in the past:*
whether we just "came from" the `.left` block or the `.right` block.
No other instruction depends on this strange, hidden state about the history of the program's execution.

A second subtlety is that, if you have multiple `phi`s at the beginning of a basic block, you want them to execute "simultaneously."
Consider a loop in SSA form like this:

@swappy {
i: int = const 5;
one: int = const 1;
zero: int = const 0;

.l0:
x0: int = const 0;
y0: int = const 1;
jmp .l1;

.l1:
x1: int = phi .l0 x0 .l1 y1;
y1: int = phi .l0 y0 .l1 x1;
print x1 y1;

cond: bool = gt i zero;
i: int = sub i one;
br cond .l1 .end;

.end:
}

Roughly speaking, here's what this code is supposed to do:

let x = 0;
let y = 1;
for (let i = 5; i > 0; --i) {
swap(x, y);
print(x, y);
}

However, what happens if you execute those `phi` instructions in the loop in order, one at a time?

x1: int = phi .l0 x0 .l1 y1;
y1: int = phi .l0 y0 .l1 x1;

If we came from a previous iteration of the loop (the `.l1` label), then the first instruction would set `x1` to `y1`, and then the second would set `y1` to the same value.
That's not what we want!
This pitfall was named the "swap problem" in [this 1997 paper by Briggs et al.][briggs97].

The resolution is to define the semantics of ϕ-nodes so that, when you have several of them next to each other, they run "simultaneously": they both read their arguments, and *then* they both write to their destinations without clobbering each other's arguments.
And that is clearly different from any other kind of instruction.

To make this work, it is common for compilers with SSA ILs to require ϕ-nodes to appear together at the beginnings of basic blocks.
(This is true in LLVM, for example.)
This restriction makes sense because ϕ-nodes are for "inter-block" communication, but it can become a practical annoyance to maintain the invariant.
For example, imagine you have an optimization that can combine two basic blocks when it can prove that a branch is unnecessary.
That pass needs to carefully move around the two blocks' ϕ-nodes to get them all up at the beginning of the resulting, combined block.

[briggs97]: https://homes.luddy.indiana.edu/achauhan/Teaching/B629/2006-Fall/CourseMaterial/1998-spe-briggs-ssa_improv.pdf

#### Basic Block Arguments

One way of looking at these inconveniences is that ϕ-nodes should stop pretending that they are "just normal instructions."
If their semantics depends on control flow, if groups of them have to be handled together so they work simultaneously, and if they have to appear in a special position within basic blocks...
who are we fooling by trying to treat them as instructions inside of blocks?
They would be better off embracing their true identity as constructs intertwined with the CFG itself.

Some compilers, therefore, eschew ϕ-nodes altogether and replace them with *basic block arguments*.
(This design has appeared prominently in [MLIR][] and [Swift's IR][sil], and
the idea is often attributed to [Andrew Appel's short and fun 1998 paper titled "SSA is Functional Programming"][appel].)
These IRs give their basic blocks named parameters; whenever you jump to a block, you must provide arguments for those parameters.

Here's a rough impression of what that might look like in Bril-like syntax:

@main(cond: bool) {
.entry():
a.1: int = const 47;
br cond .left() .right();
.left():
a.2: int = add a.1 a.1;
jmp .exit(a.2);
.right():
a.3: int = mul a.1 a.1;
jmp .exit(a.3);
.exit(a.4):
print a.4;
}

Notice that the `jmp` instructions here pass different arguments to the `.exit` block's parameter, just like a function call.

I think it's interesting that, compared to ϕ-nodes, basic block arguments push the information to the "other end" of each CFG edge.
With ϕ-nodes, a basic block enumerates all the possible sources for a given variable, one for each in-edge in the CFG;
with basic block arguments, the sources are distributed to the predecessor blocks.

The upshot is that basic block arguments fulfill the same essential role as ϕ-nodes while being a little more transparent about the complexity that they introduce into the IR and the invariants that the compiler must enforce.

[mlir]: https://mlir.llvm.org
[sil]: https://github.com/apple/swift/blob/main/docs/SIL.rst
[ssafun]: https://homes.luddy.indiana.edu/achauhan/Teaching/B629/2006-Fall/CourseMaterial/1998-spe-briggs-ssa_improv.pdf

#### Pizlo's Upsilon/Phi Variant

Here's another twist on classic SSA with a similar motivation to basic block arguments that Filip Pizlo [recently sketched][pizlo] and named after himself.
As with basic block arguments, the idea is to split ϕ-nodes into two parts:
the "sending side" (the predecessor blocks) and the "receiving side" the (successor block).
But let's return to using normal-ish instructions instead of changing the way labels, branches, and jumps work.

TK Examples and description.

[pizlo]: https://gist.github.com/pizlonator/79b0aa601912ff1a0eb1cb9253f5e98d

### Bril in SSA

Bril has an [SSA extension][bril-ssa2] that follows the "upsilon/phi form" representation.
It adds support for `set` and `get` instructions, which correspond to Pizlo's upsilon and phi operations.
Beyond that, SSA form is just a restriction on the normal expressiveness of Bril—if you solemnly promise never to assign statically to the same variable twice, you are writing "SSA Bril."

The [reference interpreter][brili] has built-in support for `set` and `get`, so you can execute your SSA-form Bril programs without fuss.

[bril-ssa2]: https://capra.cs.cornell.edu/bril/lang/ssa.html
[brili]: https://capra.cs.cornell.edu/bril/tools/interp.html


## Tasks

* Implement the “into SSA” and “out of SSA” transformations on Bril functions.
Expand Down

0 comments on commit 07303c7

Please sign in to comment.