Implementing a Static Single Assigment (SSA) in FAST-Python
Implementing the SSA of Python in FAST-Python
Section titled “Implementing the SSA of Python in FAST-Python”What is SSA?
Section titled “What is SSA?”Static Single Assignment is a program representation where every variable is assigned exactly once. The same logical variable x is renamed into several versions x_1, x_2, … — one per assignment — and each use points to the version that can actually provide its value.
The goal is to make data flow explicit in the AST: instead of “this x is assigned somewhere before”, we get “this x is exactly the value produced by that assignment”.
x = 2 # x_1print(x) # uses x_1
x = 3 # x_2print(x) # uses x_2The hard part starts when the value of a variable can come from several assignments depending on the control flow:
if z > 3: x = 2 # x_1else: x = 3 # x_2
print(x) # uses phi(x_1, x_2)After the merge of the two branches there is no single assignment that produced x: we say x has a phi version phi(x_1, x_2), a virtual assignment stating “the value is x_1 if we came from the first branch, x_2 if we came from the second”. An analysis consuming the SSA can then explore both possible values.
For us, the SSA is the third stage of the pipeline, sitting on top of the two previous stages:
Import → Local resolution → CFG → SSA
It has a hard prerequisite: we must know, for each use, which declaration it refers to (the local resolution stage) and which control-flow paths exist (the CFG stage). If you have not read about the FAST CFG and its visitor, I strongly recommend the Visiting the control-flow graph article first — this blog post is the direct continuation.
Architecture: one visitor on top of the CFG
Section titled “Architecture: one visitor on top of the CFG”The whole transform lives in a single class, FASTPythonSSAVisitor. Its declaration shows the trick that makes it work: it combines two visiting abilities—
FASTPythonVisitor << #FASTPythonSSAVisitor traits: 'FASTCFGTVisitor'; slots: { #newVariablesMap. #localDeclarations }; tag: 'CFG/LocalResolver/SSA'; package: 'FAST-Python-Tools'- the superclass
FASTPythonVisitorbrings the language visitor (thevisitFASTPyXxx:methods generated from the model, throughFASTPyTVisitor); - the trait
FASTCFGTVisitorbrings the CFG visitor (thevisitCFGXxxBlock:methods that walk the blocks of the CFG, plus the conditional-branch hooks).
So a single accept: can traverse the CFG (visitCFGBlock: and friends) and, when a block contains Python statements, dispatch back to the language-specific visit methods (visitFASTPyAssignment:, visitFASTTCanBeVariable:, …).
The trait is generic and lives in the base FAST project (FAST-Core-Tools), which means any language model can reuse it the same way. It is the same trait-based composition idea as the local resolver, but applied to the CFG.
The state
Section titled “The state”The visitor has only two instance variables:
localDeclarations— anIdentitySetof every local declaration that received at least one SSA version while walking the entity. It is the source of the “current active versions” snapshot (see below).newVariablesMap— anIdentityDictionaryused only during the merge of conditional branches, to accumulate, per branch, the versions that were created inside that branch.
And the entry point is a class-side convenience:
FASTPythonSSAVisitor class >> resolve: aPythonEntity ^ self new resolve: aPythonEntityThe three SSA value classes
Section titled “The three SSA value classes”The versions are not strings, they are first-class objects living in FAST-Core-Tools (again reusable across languages), and they are Moose entities: each version is added to the model, so it is persisted and exported with it.
FASTAbstractVariableVersionSSA— abstract root. It holds thelocalDeclarationthe version belongs to, and offers the queries we need once the SSA is built:nodes(the accesses directly linked to this version, the bidirectional opposite ofssaVersion),localUses,readAccesses,writeAccesses(restricted to this version),name,isPhi,ssaVariables.FASTVariableVersionSSA— a basic version for one assignment. It carries an integerversionnumber, sonameanswersx_1,x.y_1…FASTVariablePhiVersionSSA— a phi version. It carrieschoices, the collection of versions being merged, sonameanswersphi(x_1, x_2), andisPhianswerstrue.
FASTVariableVersionSSA >> name ^ String streamContents: [ :s | s nextPutAll: self localDeclarationName; nextPut: $_; nextPutAll: version asString ]FASTVariablePhiVersionSSA >> name ^ 'phi(' , ($, join: (choices collect: #name)) , ')'Nested merges flatten themselves: the phi’s addChoiceToPhi: re-dispatches each choice into the receiving phi, so a phi inside a branch ultimately contributes its own choices to the outer phi.
The links carried on the model
Section titled “The links carried on the model”The SSA result is attached to the model through two links defined in FAST-Core-Tools on the trait FASTTCanBeLocalDeclaration (used by FASTTEntity, so available on every FAST entity):
entity ssaVersion— on any access (read or write) to a variable, the version it is linked to. This is a bidirectional relation: each version exposes the accesses linked to it through itsnodescollection.declaration activeVersion— on the local declaration, the current version at the current point of the traversal. This is the internal “state variable” of the algorithm: it is bumped at each assignment and replaced by a phi at each merge. It stays a plain attribute.
FASTTCanBeLocalDeclaration >> activeVersion ^ self attributeAt: #activeVersion ifAbsent: [ nil ]FASTTCanBeLocalDeclaration >> ssaVersion <FMProperty: #ssaVersion type: #FASTAbstractVariableVersionSSA opposite: #nodes> ^ self attributeAt: #ssaVersion ifAbsent: [ nil ]The SSA is then a traversal that, at every write, creates a version, adds it to the model, and sets it as activeVersion; at every read, copies activeVersion into ssaVersion of the access.
The pipeline inside resolve:
Section titled “The pipeline inside resolve:”FASTPythonSSAVisitor>>#resolve: is deliberately buildable in your own project step by step:
FASTPythonSSAVisitor >> resolve: aFASTBehaviouralEntity "First we do a local resolution so that each entity is linked to its local declaration." FASTPythonLocalResolverVisitor resolve: aFASTBehaviouralEntity.
"Then we build the CFG to know the different paths in the entity." cfg := aFASTBehaviouralEntity cfg.
"And lastly, I can build the SSA now that I have the CFG and the local declarations." self visit: cfgThree stages, three small lines. The local resolution is done inside the SSA visitor only for convenience; you can equally run it beforehand.
Producing the SSA versions
Section titled “Producing the SSA versions”The core: visitFASTTCanBeVariable:
Section titled “The core: visitFASTTCanBeVariable:”Just like the local resolver, the SSA logic is concentrated on the trait FASTTCanBeVariable, caught in a single visit method for every node kind that can be a variable (identifier, attribute access, subscript, walrus):
FASTPythonSSAVisitor >> visitFASTTCanBeVariable: aTCanBeVariable
aTCanBeVariable isVariableWriteAccess ifTrue: [ self handleNewAssignemntTo: aTCanBeVariable ] ifFalse: [ aTCanBeVariable localDeclaration isNonLocalDeclaration ifFalse: [ "Subscript is a special case since it is not directly assigned. So the first read acts as a new declaration." (aTCanBeVariable isSubscript and: [ aTCanBeVariable localDeclaration activeVersion isNil ]) ifTrue: [ self handleNewAssignemntTo: aTCanBeVariable ] ifFalse: [ aTCanBeVariable ssaVersion: aTCanBeVariable localDeclaration activeVersion ] ] ].
super visitFASTTCanBeVariable: aTCanBeVariableThe rule is a direct SSA translation of the local resolver’s “write declares, read binds”:
- write access → create a new version (
handleNewAssignemntTo:); - read access → copy the declaration’s
activeVersioninto the access’sssaVersion; - read of an unresolved name (
localDeclaration isNonLocalDeclaration) → nothing.print,len, imported functions and variables have no declaration in the model, so they get no version (ssaVersionstaysnil); - first read of a subscript whose declaration has no version yet → it behaves like a declaration: the first
x[3]read is treated as an assignment (mirroring the local-resolver convention), so its version is created there.
Creating a version
Section titled “Creating a version”FASTPythonSSAVisitor >> handleNewAssignemntTo: aFASTEntity aFASTEntity ssaVersion: ((self createVariableVersionFor: aFASTEntity) newVersionNumber; yourself)FASTPythonSSAVisitor >> createVariableVersionFor: aFASTEntity | newSSA | newSSA := FASTVariableVersionSSA for: aFASTEntity. aFASTEntity mooseModel add: newSSA. aFASTEntity lastSSAVersion ifNotNil: [ :lastSSA | newSSA version: lastSSA version ].
aFASTEntity localDeclaration activeVersion: newSSA. localDeclarations add: aFASTEntity localDeclaration. ^ newSSAFASTVariableVersionSSA for: aFASTEntity records aFASTEntity localDeclaration as the declaration the version belongs to. Then:
- the new version is added to the model — the versions are Moose entities, so this makes them persistent and exported with the model;
- the version number is seeded from
lastSSAVersion— the active version of the previous declaration of the same name, chased through the local-resolver shadowing chain: it is what makesx_1thenx_2even when an unrelateddef x():was declared in between; - the new version becomes the
activeVersionof the declaration; - the declaration is added to
localDeclarations(so the “snapshot” of active versions, used for the branches, sees it); newVersionNumberbumps the integer —version := version + 1— and the result is stored as the write access’sssaVersion.
FASTPyEntity >> lastSSAVersion "scrolling the shadowing chain of the declaration to find a previous version" | entity | entity := self localDeclaration. [ entity isNotNil ] whileTrue: [ entity activeVersion ifNotNil: [ :version | ^ version ]. entity := entity shadowing ]. ^ nilReordering the visits: the order matters for SSA
Section titled “Reordering the visits: the order matters for SSA”The generated visitor visits the children of a node in metamodel order. For the SSA, that order is often wrong because a read must be resolved against the versions before the node, and a write must only bump the version after its right-hand side has been read. We overwrite the generated visit methods in four places.
Assignments: the right side comes first
Section titled “Assignments: the right side comes first”This is the most important one. In the metamodel, the left of an assignment comes before its right. For the SSA we must invert:
visitFASTPyAssignment: anAssignment "Visiting the right field before the left because the variables at the right should be linked to the previous assignment." self visitFASTPyTAssignable: anAssignment. self visitFASTPyExpression: anAssignment.
self visitEntity: anAssignment right. self visitEntity: anAssignment left. self visitEntity: anAssignment typeConsider x = x + 1. In metamodel order we would visit the left x first: it would create x_2 and set it as activeVersion, and then the x in x + 1 would read — wrongly — the new x_2. By visiting right before left, the read sees x_1 and the write bumps afterwards.
Function definitions: parameters before the body
Section titled “Function definitions: parameters before the body”Parameters are declarations that the body reads, so they have to be visited before the body:
visitFASTPyFunctionDefinition: aFunctionDefinition "Reordering so parameters are visited before the statements, in order to have all the declarations." self visitFASTTWithParameters: aFunctionDefinition. self visitEntity: aFunctionDefinition returnType. self visitFASTPyTDefinition: aFunctionDefinition. self visitFASTPyTWithTypeParameters: aFunctionDefinition. self visitFASTPyStatement: aFunctionDefinition
visitFASTPyMethodDefinition: aMethodDefinition "We resolve in the same way as functions." self visitFASTPyFunctionDefinition: aMethodDefinitionDefinitions rebuild their own CFG
Section titled “Definitions rebuild their own CFG”A function definition appearing as a statement of the module is inside the module CFG. But the function’s body is a separate control-flow world. When we reach a definition, we therefore rebuild a fresh CFG and restart the traversal on it, so each definition is turned into its own SSA form:
visitFASTPyTDefinition: aTDefinition "We need to redo the CFG for definitions." | cfg | self visitFASTTNamedEntity: aTDefinition. self visitCollection: aTDefinition decorators.
cfg := aTDefinition cfg. self visit: cfgBuilding the phi versions
Section titled “Building the phi versions”Where do phi versions come from? The CFG visitor. When FASTCFGTVisitor visits a conditional block, it visits all the branches before the merge, and during that it calls four hooks you can override. Their default implementation in the trait is empty:
preConditionalsBranchesVisitOf: aConditionalBlock "before the first branch"preConditionalsBranchVisitOf: block conditional: aConditionalBlock "before each branch"postConditionalsBranchVisitOf: block conditional: aConditionalBlock "after each branch"postConditionalsBranchesVisitOf: aConditionalBlock "after all branches, before the merge"That is exactly the machinery we fill in to build phis. The idea is a three-step protocol:
- before the branches: snapshot the active versions;
- after each branch: record which new versions were created by that branch;
- after all branches: merge, per variable, one version per branch (plus the snapshot version for branches that did not assign it) into a phi.
Step 1 — snapshot before the branches
Section titled “Step 1 — snapshot before the branches”FASTPythonSSAVisitor >> preConditionalsBranchesVisitOf: aConditionalBlock "When we start to visit the branches of a conditional, we create an entry for this conditional in the new variables map and we save the active versions present before visiting the children." newVariablesMap at: aConditionalBlock put: (IdentityDictionary with: #previousVariables -> self currentActiveVersions)currentActiveVersions is a snapshot of every declaration’s active version at this point:
currentActiveVersions ^ localDeclarations collect: #activeVersionStep 2 — record the new versions of each branch
Section titled “Step 2 — record the new versions of each branch”FASTPythonSSAVisitor >> postConditionalsBranchVisitOf: firstBranchBlock conditional: aConditionalBlock "Once we visited a conditional branch, we save the current active versions for this branch." | mapForConditional | mapForConditional := newVariablesMap at: aConditionalBlock. mapForConditional at: firstBranchBlock put: (self currentActiveVersions difference: (mapForConditional at: #previousVariables))The difference is atomic: subtracting the snapshot (#previousVariables) from the current active versions leaves exactly the versions created inside this branch (the ones that were not there before). We store them per branch block.
Step 3 — build the phis and merge
Section titled “Step 3 — build the phis and merge”FASTPythonSSAVisitor >> postConditionalsBranchesVisitOf: aConditionalBlock "Once we are done visiting a conditional block we can clean the map and build the Phi variables."
| mapForConditional previousVariables | mapForConditional := newVariablesMap at: aConditionalBlock. previousVariables := mapForConditional at: #previousVariables. mapForConditional removeKey: #previousVariables. self producePhiVersionBasedOnPreviousVariables: previousVariables andNewVariableMap: mapForConditional. newVariablesMap removeKey: aConditionalBlockThe heavy lifting is in producePhiVersionBasedOnPreviousVariables:andNewVariableMap::
FASTPythonSSAVisitor >> producePhiVersionBasedOnPreviousVariables: previousVariables andNewVariableMap: newVariablesMap | declarationsToMerge | declarationsToMerge := newVariablesMap values flatten collectAsSet: #localDeclaration.
declarationsToMerge do: [ :localDeclaration | | allVersions | "First we collect all the versions to use to produce a new Phi version for the current local declaration." allVersions := Set new. newVariablesMap valuesDo: [ :variables | variables detect: [ :variable | variable localDeclaration = localDeclaration ] ifFound: [ :variable | allVersions add: variable ] ].
"If not all branches create a new version, we need to add the previous version also to the list of variables for the Phi variable. Note that it is possible it did not exist before." allVersions size = newVariablesMap size ifFalse: [ previousVariables detect: [ :variable | variable localDeclaration = localDeclaration ] ifFound: [ :variable | allVersions add: variable ] ].
self createPhiVersionFor: localDeclaration versions: allVersions ]Decoding it:
declarationsToMerge— the set of distinct local declarations that received at least one new version in some branch;- for each such declaration, walk the branch map and gather the one version per branch that belongs to it;
- if not every branch created a version for it, a branch may fall straight through with the old value — so we also merge in the version that was active before the conditional (from the
previousVariablessnapshot), if it existed; createPhiVersionFor:versions:then decides.
FASTPythonSSAVisitor >> createPhiVersionFor: localDeclaration versions: allVersions "No need to add it to the versions since it was already added by the previous versions." | phiVersion | allVersions size < 2 ifTrue: [ ^ self ]. "No phi if we do not have at least 2 versions"
phiVersion := FASTVariablePhiVersionSSA for: allVersions. allVersions anyOne mooseModel add: phiVersion. localDeclaration activeVersion: phiVersion. ^ phiVersionTwo guard rails:
- if a single candidate version remains (e.g. only one branch assigns, and merging the previous version produces a single version) there is nothing to merge, so no phi is created — the single version just stays active;
- otherwise a
FASTVariablePhiVersionSSAis created with the candidate versions aschoices, and becomes the newactiveVersionof the declaration — so the reads right after the merge pick it up via the normal “copyactiveVersionintossaVersion” rule.
No need to “attach” anything else: the phi inherits from the version classes, so it knows its localDeclaration and exposes localUses, readAccesses, writeAccesses restricted to the merged versions.
Traced example
Section titled “Traced example”if y < 2: x = 4 # x_1else: x = 5 # x_2
print(x) # phi(x_1, x_2)Walked by the visitor:
preConditionalsBranchesVisitOf:→previousVariablessnapshot is empty (no version yet).thenbranch:x = 4→handleNewAssignemntTo:createsx_1, becomes active.postConditionalsBranchVisitOf:→ branch recorded with{x_1}.elsebranch:x = 5→ createsx_2, becomes active.postConditionalsBranchVisitOf:→ branch recorded with{x_2}.postConditionalsBranchesVisitOf:→ declaration merged:{x_1}from branch 1,{x_2}from branch 2 → both branches assigned, so no previous version added →allVersions = {x_1, x_2}→ phi created, becomes active.print(x)is read after the merge →ssaVersion := activeVersion→phi(x_1, x_2). The use is linked to both possible values.
And the case where a branch does not assign the variable:
x = 3 # x_1if y < 2: function() # no assignment of xelse: x = 4 # x_2
print(x) # phi(x_1, x_2)Step 6 here: branch 2 introduced x_2 only, and branch 1 introduced none for x → allVersions starts as {x_2}, its size (1) is not the number of branches (2) → we add the previous version x_1 from the snapshot → allVersions = {x_1, x_2} → phi. Correct: if the first branch is taken, x still holds the old value.
If the assignment happens in a single branch and no previous version exists, then allVersions ends up with a single element → no phi, and the reads just use that single version — matching the (correct) semantics that the variable is simply that assignment in all reachable paths.
Querying the result
Section titled “Querying the result”Once the SSA is built, the model can be queried directly:
access ssaVersion— the version of the access: aFASTVariableVersionSSAor aFASTVariablePhiVersionSSA(ornilfor unresolved names and non-local declarations).access ssaName— its pretty name, e.g.x_1,x.y_2,phi(x_1, x_2).version nodes— the accesses directly linked to this version throughssaVersion(the bidirectional opposite).version localUses/version readAccesses/version writeAccesses— the accesses for this specific version (in the phi case, over all its choices).model allSSAVersions/model allResolvedVariableVersions— all the versions of the model, the latter without duplicates.variable allSSAVersions/variable allSSABasicVersions— all versions of one variable, the latter without the phis.
(printCall arguments first) ssaVersion name. "phi(x_1, x_2)"(printCall arguments first) ssaVersion localUses size.The full API is documented in the Exploiting the SSA section of analysis.md.
Known limits
Section titled “Known limits”The SSA inherits the limitations of the two stages it sits on:
- Instance variables (
self.x) cannot be treated correctly: there is no declaration of them, and we do not know the order in which methods are invoked. - Subscripts are matched by their source code, so
x[a]andx[b]are conflated — and the whole “first read acts as a declaration” convention is a workaround, not a real answer. - Non-local declarations get no version: unresolved and imported names are invisible to the SSA.
- Attribute access chains are not tracked (
a = x.ythena.zis not linked tox.y.z).
You will find these documented in the Limitations section.
Quick start
Section titled “Quick start”"Import"model := FASTPythonImporter parseFile: aFile.
"Full pipeline: local resolution is done inside"FASTPythonSSAVisitor resolve: model allFunctionDefinitions first.
"Or explicitly, step by step"model := FASTPythonImporter parseFile: aFile.FASTPythonLocalResolverVisitor resolve: model module.model allFunctionDefinitions first cfg.FASTPythonSSAVisitor resolve: model allFunctionDefinitions first.The SSA requires Python 3 scoping, like the local resolver it builds on.
Advice for implementing SSA in your own FAST project
Section titled “Advice for implementing SSA in your own FAST project”- Reuse the generic machinery.
FASTCFGTVisitor,FASTVariableVersionSSA,FASTVariablePhiVersionSSAand the version queries live in the base FAST project (FAST-Core-Tools). You should only have to write the language-specific part: thevisitX:rules for your nodes and the branch hooks. - The write/read rule is your whole core. One visit on the trait “can be a variable” (read → copy active version, write → create version) is enough, provided the local resolution already linked uses to declarations.
- Drive phis from the CFG hooks, not by scanning. The four
pre/postConditionals*hooks give you the branch boundaries for free. Snapshot before, record per branch, merge after — that protocol is generic. - Order your visits so reads see the old version. The metamodel order is a trap: reorder assignments (right before left), functions (parameters before body), and anything that mixes declaration and use.
- Give each access a version and each declaration a “current version” attribute. A
ssaVersionrelation (bidirectional withnodes) and anactiveVersionattribute onFASTTCanBeLocalDeclarationwere enough. Make the versions Moose entities and add them to the model so they are persisted and exported. - Make the versions first-class objects, not strings. Give them a
localDeclaration,name, andssaVariables/choices; the query API (localUses,readAccesses, …) then comes almost for free on the abstract root. - Handle your “no declaration” case explicitly. In Python, unresolved/imported names simply get no version. Decide early what your language’s equivalent is (a non-local placeholder, an explicit global scope…).
The FAST-Python sources you will want to look at: FASTPythonSSAVisitor (this post is a guided read of it), the FASTCFGTVisitor trait and the FAST*VersionSSA classes in FAST-Core-Tools, and the SSA tests in FASTPythonSSATest.
Moving this implementation to FAST
Section titled “Moving this implementation to FAST”This implementation currently lives in FAST-Python, but almost everything in it could move to the base FAST project in the future.
The language-specific part is surprisingly thin. Given a CFG, a local resolution, and a model whose variables use the FASTTCanBeVariable trait, this SSA implementation gives you almost everything for free:
- the version classes (
FASTVariableVersionSSA,FASTVariablePhiVersionSSA) and theFASTCFGTVisitortrait already live inFAST-Core-Tools; - the write/read rule is a single visit method on
FASTTCanBeVariable; - the phi building runs on the generic CFG hooks.
So, for a new language, there is almost nothing more to do: mostly override the few visitX: methods where the metamodel order is wrong for SSA — assignments (right before left), functions (parameters before body), and definitions (rebuild their own CFG) — to reorder the visits.