An optimizer spends most of its time asking one question about the program in front of it: when this instruction reads a variable, which instruction wrote the value it reads? If the answer is “the line three above, which wrote the constant 6”, the read can be replaced by the 6. If the answer is “one of two lines, depending on a decision”, it cannot. Every improvement a compiler makes to your code starts with that question answered.
This chapter asks the question of a small program twice: first the way compilers answered it before SSA, and then the way SSA answers it.
The lab
Make a folder for the series and work in it:
mkdir -p ssa-lab && cd ssa-labSave this as TwoWrites.mica:
{
TwoWrites - the larger of two numbers, chosen by one decision.
Each arm of the decision writes the variable 'larger', and the one read of it below the decision can see either
write: the smallest program in which one use of a variable has two definitions.
}
program TwoWrites;
imp
WriteLn : std;
{ The larger of two numbers is written into 'larger' by whichever arm of the decision runs. }
function Larger(a, b : int64) : int64;
var
larger : int64;
begin
if a > b then
larger := a
else
larger := b;
Larger := larger;
end;
begin
WriteLn("the larger of 3 and 4 is %lld", Larger(3, 4));
WriteLn("the larger of 9 and 2 is %lld", Larger(9, 2));
end.Run it once, to see that it works:
mica TwoWrites.micathe larger of 3 and 4 is 4
the larger of 9 and 2 is 9Then compile it with --export, which writes every stage of the compilation
into the folder export of the build directory. --optimize debug asks for
the tier that does not optimize, so you see the program as it was first
translated:
mica --compile --optimize debug --platform linux,amd64,utf-8 --source TwoWrites.mica --build build/debug --exportExporting intermediate representations of source 'TwoWrites.mica' to 'build/debug/export'The folder holds one file per stage, numbered in the order the compiler works. This series reads six of them; this chapter reads three.
The intermediate code
A compiler does not optimize your source text. It first translates it into an
intermediate language: a flat list of small instructions, each doing one
thing, with at most two inputs and one result. That shape is called
three-address code. Mica’s intermediate language is called Spectra, and the
file TwoWrites.040.spectra is the program in it. The function Larger:
@TwoWrites.Larger:
// 16: begin
prologue @TwoWrites.Larger, 1
// 17: if a > b then
t2.1:int64 = loadVariable p2.1:int64 #14
t2.2:int64 = loadVariable p2.2:int64 #15
t2.3:bool = greater t2.1:int64, t2.2:int64 #16
jumpNotEqual t2.3:bool, @l2.1
// 18: larger := a
t2.4:int64 = loadVariable p2.1:int64 #17
storeVariable t2.4:int64, v2.1:int64 #18
// 17: if a > b then
jump @l2.2
@l2.1:
// 20: larger := b;
t2.5:int64 = loadVariable p2.2:int64 #19
storeVariable t2.5:int64, v2.1:int64 #20
@l2.2:
// 22: Larger := larger;
t2.6:int64 = loadVariable v2.1:int64 #21
storeVariable t2.6:int64, v2.2:int64 #22
@TwoWrites.Larger_leave:
// 23: end;
loadReturnVariable @TwoWrites.Larger, v2.2:int64 #23
epilogue @TwoWrites.LargerRead each line left to right. t2.4:int64 = loadVariable p2.1:int64 #17
says: read the parameter p2.1 into a new temporary t2.4, of type int64.
The tokens you need:
| You see | It means |
|---|---|
p2.1, p2.2 | the parameters a and b: p for parameter, 2 for the routine (the program body is 1), then a count |
v2.1, v2.2 | the variable larger, and Larger, the variable a function writes its result to |
t2.1 to t2.6 | temporaries: the compiler’s names for intermediate results |
loadVariable, storeVariable | read a variable into a temporary, write a temporary into a variable |
jumpNotEqual t2.3:bool, @l2.1 | jump to the label @l2.1 when the condition is false; otherwise run on |
@l2.1: | a label, a place a jump can go to |
// 18: larger := a | the source line the instructions below it came from |
#18 at the end | the value id: a serial number for every instruction that writes a value |
Two things matter for the rest of the series. Every temporary is written
exactly once. The variable larger, v2.1, is written twice: by the store
#18 in the then arm and by the store #20 in the else arm. And the read
#21, below the decision, could see either.
The question, answered without SSA
Before SSA, compilers answered the question with an analysis called reaching definitions. A definition is an instruction that writes a variable. A definition reaches a read when some path through the program goes from the definition to the read without passing another write of the same variable. The analysis computes, for every read, the set of definitions that reach it.
Mica computes this analysis when you export, and writes it to
TwoWrites.080.reaching-definitions, one line per read. The line for the
read of larger:
value-id=21 token=41 "t2.6:int64 = loadVariable v2.1:int64 #21" -> v2.1 = [18 20]The read #21 of v2.1 may see the value written by #18 or the value
written by #20. That is the honest answer: which one depends on a > b,
which the compiler cannot know.
The temporaries have one definition each, so their lines are short. The store
into larger in the then arm reads t2.4, and only one instruction ever
writes t2.4:
value-id=18 token=31 "storeVariable t2.4:int64, v2.1:int64 #18" -> t2.4 = [17]The reads of the parameters a and b have no line at all: their values
were written by the caller, by no instruction of this program, so there is no
value id to list.
This is how the question is answered without SSA: a set per read, computed over the whole routine, where every new write of a variable kills the facts about the writes before it. It works, but every optimization has to carry these sets around, and every change to the program can invalidate them.
The question, answered by SSA
Static single assignment form rewrites the program so that the question
disappears. Every write of a variable creates a new version of it, with a
name of its own. Every read names the one version it sees. Where two paths
that carry different versions meet, a new kind of instruction, the φ
function (written phi in the listings), picks the version that belongs to
the path control actually came along.
The same export holds the SSA form of the program, TwoWrites.090.ssa. The
part below the if:
// 18: larger := a
t2.4:int64 = loadVariable p2.1#0:int64 #17
storeVariable t2.4:int64, v2.1#0:int64 #18
// 17: if a > b then
jump @l2.2
@l2.1:
// 20: larger := b;
t2.5:int64 = loadVariable p2.2#0:int64 #19
storeVariable t2.5:int64, v2.1#1:int64 #20
@l2.2:
v2.1#2:int64 = phi 3: v2.1#0:int64, 4: v2.1#1:int64 #21
// 22: Larger := larger;
t2.6:int64 = loadVariable v2.1#2:int64 #22A # written right after a name, with no space, is a version: v2.1#0 is
version 0 of larger. The # after a space at the end of a line is still the
value id. Read it like this:
- The
thenarm writes version 0 oflarger,v2.1#0. - The
elsearm writes version 1,v2.1#1. - Where the arms meet, at
@l2.2, the φ function defines version 2:v2.1#2isv2.1#0when control came from block 3, andv2.1#1when it came from block 4. Blocks 3 and 4 are the two arms; the next chapter shows where those numbers come from. - The read below the decision reads
v2.1#2, and nothing else. - The parameters are read as
p2.1#0andp2.2#0: version 0 of a parameter is the value the caller passed.
Now ask the question again. Which write does the read of v2.1#2 see? The
one that defines v2.1#2. There is exactly one, because every version is
written in exactly one place: that is the “single assignment”. It is
“static” because it holds in the program text; a version inside a loop is
still written many times while the program runs.
The set of the reaching-definitions file became a name. An optimization that wants to know where a value came from no longer runs an analysis: it looks the name up.
Try it
Give larger a starting value before the decision. Add the line
larger := 0; in front of the if:
begin
larger := 0;
if a > b then
larger := a
else
larger := b;
Larger := larger;
end;Export again, and look for the read of larger in the reaching definitions
and for the versions of larger in the SSA form:
value-id=23 token=45 "t2.7:int64 = loadVariable v2.1:int64 #23" -> v2.1 = [20 22]
storeVariable t2.1:int64, v2.1#0:int64 #15
storeVariable t2.5:int64, v2.1#1:int64 #20
storeVariable t2.6:int64, v2.1#2:int64 #22
v2.1#3:int64 = phi 3: v2.1#1:int64, 4: v2.1#2:int64 #23
t2.7:int64 = loadVariable v2.1#3:int64 #24The read still sees two definitions, not three: both arms write larger, so
the 0 can never reach the read, and the analysis says so by leaving it out.
In the SSA form the 0 became version 0, the arms versions 1 and 2, and the
φ still joins only the two versions that arrive at the merge.
Now the question is how the compiler decides where a φ goes and which version each read names. Both answers stand on one structure, the dominator tree, which the next chapter builds.
Next: The graph and its dominators.