Add 2009 Jit Architecture Plan (excerpts) (#60939)
authorAndy Ayers <andya@microsoft.com>
Wed, 27 Oct 2021 22:20:22 +0000 (15:20 -0700)
committerGitHub <noreply@github.com>
Wed, 27 Oct 2021 22:20:22 +0000 (15:20 -0700)
We plan to gradually open up portions of historical documents that seem
relevant to our current work on the JIT.

This PR includes a writeup that is the inspiration for the current SSA
and Value Numbering designs.

docs/design/coreclr/jit/Jit Architecture Plan 2009.md [new file with mode: 0644]

diff --git a/docs/design/coreclr/jit/Jit Architecture Plan 2009.md b/docs/design/coreclr/jit/Jit Architecture Plan 2009.md
new file mode 100644 (file)
index 0000000..e9f46d5
--- /dev/null
@@ -0,0 +1,83 @@
+# Jit Architecture Plan
+
+CLR Jit Team, Sept 2009
+
+**Note:** this is an excerpt from a document written a number of years ago outlining a plan for Jit development. We are excerpting here relevant portions of historical or current interest. There is no guarantee that what is described here has been implemented, or was implemented as described.
+
+We may include more over time.
+
+## 3.3 Analysis Framework: SSA, value numbering, and constraint propagation.
+
+I argued above for basing optimizations, whenever possible, on semantic analyses rather than recognition of syntactic idioms.  I view the common question to be answered for whether an optimization applies as “can we prove that property P always holds of the value of expression E?”  This leads me to think that our analysis framework should answer questions about the values of expressions.
+
+In the rest of this section, I will first discuss static single-assignment form (henceforth SSA), then discuss value numbering (and flow-insensitive constraints), and finally constraint propagation.
+
+### 3.3.1.  SSA form
+
+The modern compiler literature seems quite united on the utility of static single-assignment form.  SSA breaks a local variable’s lifetime into different segments in which it may contain different values, assigning them different names.  Each name corresponds to a single definition.  Thus, in a static analysis, each SSA name represents a value that is immutable over the lifetime of the variable name.  This is already an important step forward: in optimizations like common sub-expression elimination (henceforth CSE), or loop-invariant code motion, we need to answer questions about whether two dynamic evaluations of the same (or related) expressions will yield the same value.  Without SSA, this is answered by determining whether any free variables of the expressions are modified between one evaluation and the next.  With SSA, the conversion to SSA decides this issue of whether free variables of an expression have changed – in CSE, if one of the free variables in an expression E is possibly modified on the set of paths from one occurrence to another, in SSA form, these variables will be given different SSA names, and thus the two occurrences will no longer be occurrences of the same expression.   (This approach, in both the SSA and non-SSA case, is somewhat conservative, since it doesn’t account for the possibility that free variables are modified, but to new values that preserve the value of the containing expression – it assumes that any modification of a free variable changes the value of all containing expressions.  The value numbering scheme we discuss later can sometimes address this imprecision.)
+
+Most readers will be at least somewhat familiar with SSA already, but to review, achieving the defining condition that every use of an SSA variable has a unique dominating definition requires the insertion of new definitions at appropriate places.  Assume that basic block `B3` has predecessor blocks `B1` and `B2`, each of which has a definition of local variable `v`, and that `B3` contains a use of `v`.  SSA conversion would convert the definitions of `v` in `B1` and `B2` to SSA names, say `v1` and `v2` (and, of course, similarly convert all the usages of `v` dominated by these definitions).  At `B3`, SSA would require insertion of a new definition of `v`, say `v3`, at the head of `B3`.  The value formally assigned to `v3` would be a so-called *phi* function, taking the incoming definitions `v1` and `v2` as arguments (I often think of phi functions as taking an additional integer argument, indicating which of the predecessor blocks control flow came from.   In this model, each predecessor block would pass an appropriate integer constant for this “selector” argument, and the phi function chooses which of the remaining arguments to return based on the selector.  With this view, it is easy to see that phi functions preserve execution semantics – and that they are really well-defined mathematical functions of their inputs.  From the viewpoint of static analysis, which must be conservative over all control-flow paths, this adds no value, and you generally think of a phi “function” as choosing non-deterministically between its inputs.  I think the fact that a different “executable viewpoint” exists for phi functions is important; this is not necessarily true, in any non-trivial way, of the so-called “pi functions” that are sometimes used to attach constraints to SSA names, as will be discussed later).  A pruned SSA form avoids adding phi definitions for variables `v` at points where they are dead, at the cost of requiring local variable liveness analysis.  The literature on performing SSA conversion is extensive, and it can be done quite efficiently.
+
+Another virtue of SSA form, cited frequently in the literature, is that it makes analyses more demand-driven.  Traditional (forward) dataflow analysis propagates abstract values forward, until a fixed-point is reached.  It has no knowledge of which abstract values will end up being relevant to optimizations, and therefore computes all values.  In contrast, SSA allows easy traversal of use-def chains; given an expression that uses an SSA variable, you can quickly get to the corresponding SSA definition, and thus to the initializing expression, and then to the SSA variables that expression uses, and so on, backwards-chaining through the program.  This essentially yields a “program slice” of the portion of the method that can influence the value of the variable at the usage point.  Often the required analysis can be done just on this “slice.”  For example, in array bounds check removal, we care about properties of the array reference expression, and of the index expression, and we can use use-def chains to analyze these independently of the rest of the method.
+
+### 3.3.2.  Value Numbering
+
+I have cited several excellent properties of SSA form.  However, I will propose that we also go a step farther, and also do value numbering.
+
+SSA variables are immutable names for the values they contain, but they don’t express even easy-to-capture equivalences between values.  Equivalences can be created by copies, or be a consequence of congruence.  At the most basic level, if we have a copy operation
+```
+    x = y;
+```
+it would be useful to know that after this assignment, the x and y variables contain the same value.  But SSA gives them separate SSA names.  As an example of a congruence, let’s say that we have
+```
+    …(a7 + b4)…; S ; …(a7 + b4)…
+```
+Here the variables are supposed to be SSA variables, so the fact that they are have the same name indicates that S defines neither `a` nor `b`.  The two applications of the `+` operator are congruent: they are applications of functions to equivalent arguments.  Thus, these are common sub-expressions.  Detecting equivalent expressions is outside the scope of SSA.
+
+Tracking such copy- or congruence-induced equivalences is the domain of value numbering.  Value numbering can also be set up to track further equivalences, in particular those that are consequences of the semantics of the functions used in expressions.  For example, a value numbering system could encode the fact that addition is commutative, so that in the example above, `b4 + a7` would also be discovered equivalent to `a7 + b4`.
+
+Value numbering discovers classes of equivalent expressions (As one might easily imagine, finding all equivalences between expressions in a program is undecidable, so particular value numbering systems will discover different subsets of the complete set of equivalences.  In my usage, the term value numbering refers to the goal, and how it is expressed, not to a particular algorithm for discovering the equivalences).  The expressions may be variables, or they may be functions of other expressions.  These equivalence classes are each assigned an integer value number, and expressions are labeled with the value number of which they are a member (creating singleton classes as necessary).  Thus, two distinct expressions with the same value number evaluate to the same value (technically speaking, on a control flow path leading from an evaluation of one to the evaluation of the other, with no intervening evaluation of a phi function that contributes to the value of the expression).  Of course, two expressions with different value numbers may also evaluate to the same value, but the point is that they may not, as well.
+
+I think we should be uniform, and extend our treatment to include heap memory in the SSA/Value-numbering framework.  In managed languages with (mostly) typed pointers, we can use a more restricted treatment of aliasing than a general pointer analysis, such as would be used in C.  If object type `A` has field `f`, then for an instance a of `A`, we will model `a.f` as indexing into a field map `A$f`, with the reference value `a`.  A field store like `a.f = v` can be modeled as `A$f ¬ A$f[a := v]` – latter expression denoting the “functional update” of the field map, that is, a new map that has the same contents as `A$f`, except at index `a`, where it has the value `v`.  We will assign value numbers to field map values (and, of course, to object pointer values).  This will enable us, for example, to accurately CSE expressions involving heap references (e.g., `o.f + o.f`), which, as far as I know, we don’t do today.  A store to a different field would not affect `A$f`.  We could even go so far as to track the relationship between one value of `A$f` and the next, using the definition above, enabling the compiler to know that a store through a reference known to be distinct from `a` does not affect the value of `a.f`.  When we make a call, we generally have to be quite conservative about effects of the call on heap memory – we need the ability to do “havoc,” to assign new value numbers to all field maps.  We can do this by considering field maps to be functions of a global memory state that would be assigned a value number.   More precisely, a global memory state is a map from field names to field map value numbers.  So `a.f` is really `globMem[A$f][a]`, and `a.f = v` “really” updates globMem so that it’s`A$f` map has `v` at reference value `a`.   A call can cheaply invalidate all knowledge of the heap state by setting the global memory state value number to a new value, about which nothing is known.  Thus, in this view, `globMem` is an implicit input to all methods, and, in the absence of knowledge to the contrary, is assumed to be modified by all methods.
+
+We assume that we can query whether a given value number is constant, primitive, or has a definition.  Constant value numbers represent literal constants.  Primitive value numbers represent values that exist before execution of the method starts, such as incoming arguments.  All other value numbers have an associated definition, which is some (pure) function of other value numbers.  One kind of function might be a value number phi function, which we will discuss below.  Because of the assumption that `globMem` is an input to all methods, the result of a method call (and values assigned to ref parameters of such a call) may be assumed to be a method-specific function of the arguments, including `globMem`, and, of course, the invocation target object.  (If the CLR ever supports a verifiable `[Pure]` attribute, this would mean that a method neither observes nor modifies global memory, so we could model such methods with functions that do not take `globMem` as an argument, and treat calls to such methods as not modifying `globMem`.)
+
+It is easy to see how most parts of a value number assignment would work.  We assign primitive value numbers for all input arguments (we will treat the incoming state of heap memory as an “input argument,” and assign it a value number).  We can obviously maintain a map from literal values to their value numbers.  Then we traverse the program in a forward flow analysis, tracking the value numbers contained in variables (again, considering globMem as an implicit program variable).  To detect expressions, we maintain a table mapping tuples of operators and argument value numbers to result value numbers, initially empty.  When we encounter an expression with a built-in operator (such as an arithmetic or logical operator), we have the option of attempting to find equivalent occurrences of applications of the operator.  If we don’t care, we just assign every such expression a new value number, and don’t store the occurrence in the table.  If we do care, though, we check in the table for an already-assigned value number for the current tuple.  We return the corresponding result value number if the tuple is present, otherwise we create a new value number, record its definition, and store the mapping of the operator/argument tuple to this value number in the table.
+
+A problem occurs, however, when we reach a control flow merge point.  What do we do if a variable `v` contains different values along the incoming edges?  The obvious answer is that we create a new value number, defining it as a value-number phi function of the incoming values.  But how do we know when to do this?  We don’t want to always create new value number for every variable at every merge point; in many cases, the variable will contain the same value on all the incoming paths (say, if it is a variable that is not modified in a preceding control flow diamond).  In the flow analysis, we can try to avoid this by delaying analyzing blocks until all their predecessors have been fully analyzed.  In the presence of loops, however, this is impossible.  In a program with loops, we’d eventually need to choose a block with at least one unevaluated predecessor.  Fortunately, we’ve mostly already solved this problem for SSA.  The SSA conversion process figures out where SSA phi nodes are necessary.  This is probably a very good approximation of where VN phi nodes are necessary.  It’s a conservative approximation: if there’s no SSA phi node for variable `v` at a merge point, we know that `v` will contain the same value on all incoming paths.  But there may be cases where an SSA phi is necessary, but a VN phi is not – for example, if the variable is assigned on all the incoming arcs, but is assigned the same value on each.  Our algorithm, then, will be to introduce VN phi definitions corresponding to SSA phi definitions, and then perform the flow analysis, eliminating VN phis (and their corresponding result value numbers) when we can prove that all incoming arguments are equivalent.  (We will only ever find new equivalences by this process, never invalidate a previously-claimed equivalence.)
+
+A practical question is how we do this elimination of VN phi functions.  A simple answer is that we could just follow the standard flow analysis paradigm.  If we had originally assigned a new value number `n2 = vnphi(n0, n1)` to `v` at a merge point, but later discovered that in fact value number `n0` flowed in along both branches, we would assign `n0` to `v`, and continue the flow analysis, treating this block as “changed”.  Propagating this change might eliminate further VN phi functions.  There is a technical point worth noting there.  Consider a loop that modifies a variable, but restores its initial value before the end of an iteration, such as:
+```
+    k = …
+
+    while (P) { k = k + 1; … use k …; k = k – 1; }
+```
+
+In SSA form, this becomes
+```
+    k_0 = …
+
+   loop:
+    k_1 = phi(k_0, k_3);  // n1 = vnphi(n0, ^)
+    if (!P) goto exit;
+    k_2 = k_1 + 1; … use k …; k_3 = k_2 – 1;
+
+    goto loop;
+
+   exit:
+   ```
+When we do value number flow analysis on this program, we will assign a value number `n0` to `k_0`, and flow this value to the loop.  Now the loop head is a join point at which we have an SSA definition for `k`.   We will therefore introduce a value number `n1` for the value of `k` at the loop head, defining `n1` as the result of a VN phi function of the value numbers assigned to by `k_0` and `k_3`, as shown in red above.  Note that initially the analysis doesn’t know the value assigned to `k_`3, so I show it as a bottom element.  We analyze the loop body, assuming `k_1` holds `n1`.  Assume the value numbering infrastructure has enough knowledge about arithmetic built in to know that n1 + `1 – 1 == n1`, so by the end of the loop we’ve determined that `k_3` holds value number `n1`.  Now the definition of `n1` has the form `n1 = vnphi(n0, n1)`.  By the non-deterministic nature of a phi “function,” this equation must be true whichever input is chosen by the VN phi.  The only solution that makes this possible is `n1 == n0`.  So when we reach a fixed point with a value number definition of this form, we’ve determined that `k` always holds the same value at the start of a loop iteration – this value does not vary within the loop, since its definition occurs outside the loop.
+
+As I’ve said, when we discover an equivalence in this way, we have to “retract” a newly-introduced value number, and use a previously existing one instead.  We can do this via the flow analysis, as described.  A potentially more-efficient alternative would add another mechanism for representing expression equivalence classes on top of value numbers.  We could have a map from value numbers to value numbers, taking each value number to its equivalence class representative.  We would always translate an expression’s value number its eq-class rep before any use.  We would use a union-find structure.  The equivalence-class representative of a class always points to itself in the map, and every other member of a class eventually reaches the eq-class rep by following these “pointers.”  When we query the eq-class rep of an element, we follow this “pointer chain”, keeping track of the elements traversed, making all of them point to the eq-class rep that is eventually found.  Thus, we can unify two equivalence classes by making the eq-class rep of one point to the eq-class rep of the other; as we query eq-class reps, elements will be lazily updated to point directly to the new rep.  If we use this mechanism, we may also want to unify distinct expressions stored in the function definition map – if we have `vn4 = +(vn1, vn3)`, `vn5 = +(vn2, vn3)`, and we unify `vn1` and `vn2`, ideally we would like to discover that `vn4` and `vn5` now are congruent.  There exists an elegant data structure from the theorem proving world called the **egraph** (Nelson, 1981) that represents equivalence classes efficiently, and discovers such congruences; the process of discovery is called congruence closure.  Using this technique would add some complexity, but would allow us, I believe, to do value numbering in a single linear traversal of the SSA-converted program.
+
+We will discuss how we will use SSA and value numbers in particular optimizations later, but I will make some general comments now.  First, many optimizations only care about properties of the value held in a variable or of an expression.  The value number of an expression or variable encodes strictly more information about the value than an SSA name does for a variable, since it captures at least some equivalences between expressions.  We can also know further facts about the value.  Some of these facts will be invariant: they are known to be true of the value produced by the defining expression, and obviously remain true for the lifetime of the immutable value.  These can be recorded directly as properties of the value number.  As an example, we might record that the result of a `new Foo()` expression is non-null, and has exact type `Foo`.  Other facts will be flow-sensitive: they are true as a consequence of control flow predicates in the program.  For example, a loop test on the value of a loop iteration variable can be used to infer an upper bound on the value of the (ascending) iteration variable, but only within the loop.  We will discuss a treatment of flow-sensitive facts in the next section.  The overall point will be that we can do dataflow analysis to determine what flow-sensitive facts apply to the value of an occurrence of an expression in a “demand-driven” way, just as we would with SSA; we don’t need to do a complete dataflow analysis determining all facts for all expressions to get any information.  Given an occurrence of an expression at a location, we can query its value number, and also query for constraints that apply to that value number at that location, and use those constraints in a “localized” dataflow analysis over the relevant portion of the SSA use-def graph.  Using value numbers instead of variable names ensures that if two variables hold equivalent values, a constraint on one is also a constraint on the other.
+
+Another class of use of SSA form just cares where the definitions occur.  For example, determining whether a given expression is loop-invariant reduces to determining whether the free variables of the expression are loop-invariant.  For a program in SSA form, this is usually done by following the uses of variables in the expression to their definitions, and determining, recursively, whether those definitions depend on any variables defined in the loop (via a phi function), or whether all the variables that contribute to the value of the expression are defined outside the loop.  In the latter case, the expression is judged loop-invariant.  But this is a conservative estimate:  as the most recent code example above illustrated, a variable might be syntactically modified in a loop in a manner that causes the SSA form to create a phi function, but still hold the same value whenever some use of the variable is evaluated in any iteration.  If we use value numbers, we can prove such properties for some examples (such as the previous code example) – these correspond to cases where the value-number assignment pass determines that an SSA phi function does not need a corresponding value-number phi function.  If the SSA test for loop invariance of a variable fails, we can check the value number graph: if the value number of the variable is defined (recursively) in terms of constants, primitive value numbers, or value-number phi functions that occur outside the loop, then the value of the expression is loop invariant.  Note that this description implies that we need a concept of a location for at least the definition of a value number defined by a value-number phi function.  This is obviously the location of the corresponding SSA phi function.
+
+### 3.3.3.   Value Numbering and SSA: commentary.
+
+A shared virtue of SSA and value numbering is that both provide a way of talking about immutable values, rather than mutable variables.  As an example of a way in which dealing with mutable variables complicates analyses, consider common sub-expression elimination (CSE).  JIT32 (the full framework x88 JIT) computes, for each candidate expression, the set of variables it depends on – two instances of the same (textual) expression are “common” only if none of these variables are modified on paths between the expressions.  In an SSA or value-numbering framework, these expressions wouldn’t even be considered as CSE candidates if such modifications occurred: in SSA, the set of SSA variables used in the two expressions would be different, and in value numbering, the expressions would get different value numbers.  Similarly, if an assertion propagation system is framed in terms of value numbers rather than variables, there’s no reason to track dependent variables and “kill” assertions when variables are updated.
+
+A similar shared virtue (at least for the hybrid conception of value numbering I’ve described) is that both allow “demand-driven” analysis over “sparse” use-def graphs – in contrast with the standard presentation of flow analysis, which computes “analysis facts” about the entire method being compiled. This allows us to concentrate analysis effort where optimizations need it, a property important when compilation time matters, as in JIT or NGEN compilation.
+
+Finally, the shift to the value number view completes a step that SSA starts, from a viewpoint in which variables are mutable cells, to reasoning about properties of immutable values that variables hold at each program point.
\ No newline at end of file