This repository was archived by the owner on Nov 17, 2025. It is now read-only.
Construct graphs resulting from arbitrary applications of rewrites #1082
brandonwillard
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
ApplyNodesWe need the ability to efficiently construct the graphs resulting from arbitrarily ordered applications of individual rewrites.
This functionality is required for our work in AeMCMC, which after aesara-devs/aemcmc#45 has moved the rewrite considerations into the realm of applied vs. unapplied rewrites. More specifically, AeMCMC needs to "temporarily" consider scale-mixture expanded random variables (see aesara-devs/aemcmc#46) as a means of discovering custom Gibbs samplers. Those expansions need to be ephemeral, because some may end up being pointless–or potentially deleterious–when they don't serve to produce a sampler.
Consider the following two rewrites:
and the following example graph:
In other words, we're rewriting the expression$\log\exp\left(x + x\right) + x + x$ and we want to record all the possible graphs resulting from iteratively applying each rewrite in different orders.
We can anticipate rewrites like the following:
If we want to apply the above two rewrites to a graph, we must first choose a means of traversing the graph and applying them to each node. In this case, nearly any topological traversal order will do (as long as it visits every node), but the traversal processes must be performed repeatedly so that the graphs resulting from each successfully applied rewrite can themselves be rewritten.
The
EquilibriumOptimizeris currently the most suitableRewriterfor this kind of job. It will construct aLocalOptGroupand apply the two rewrites until a fixed point is reached.N.B.
LocalOptGroupuses a "tracking" mechanism to make sure that the above two rewrites are only attempted when a given node uses anat.logorat.addOp–i.e. the twoOps registered by each rewrite in theirlocal_optimizerdecorators.As with all rewrites, we need to construct a
FunctionGraphobject and apply aRewriterto it:As we know, only one of the anticipated sequences of rewrites has been applied. If we want to observe all sequences, we need to alter the steps applied by
EquilibriumOptimizerso that a newFunctionGraphinstance is produced each time a rewrite is successfully applied, then a queue/"stream" ofFunctionGraphinstances can be constructed and processed by each rewrite in varying orders.First, why are new
FunctionGraphinstances needed? Because our rewriting interfaces are all based onFunctionGraphs, and information likeFunctionGraph.clientsand other data structures are maintained byFeatures attached to aFunctionGraph. Information in the attachedFeatures is updated incrementally through callbacks triggered by aFunctionGraphwhen nodes are added, removed, and updated.Since the incrementally built data structures in a
FunctionGraphand itsFeatures are used all throughout our rewrite implementations, we need them to work in this new situation where independent graphs are repeatedly produced by rewrites.At first, it might seem like we can simply clone
FunctionGraphs at a certain point; however, sinceFunctionGraph.replaceworks by alteringApplynodes in-place, every singleApplynode in a graph must be cloned, and this invalidates much of the information tracked byFeatures and the like, because that information often consists ofdicts mappingApplynodes and/or their outputVariables.One might notice that the situation can be reformulated into
FunctionGraph"diffs" (i.e. each rewrite applies changes to a graph viaFunctionGraph.replaceand those replacement pairs/maps can be collected and applied to a reference/initialFunctionGraph. Functionality based on this reformulation is provided by theHistoryFeature, which "snapshots" aFunctionGraphinstance, collects the changes/diffs, and can apply them in "reverse" to revert aFunctionGraphto a previous state. BecauseHistoryusesFunctionGraph.change_node_input, all the listeningFeatures can update their internal data structures accordingly.This approach still doesn't address the main issue:
Applynodes are updated in-place, making it necessary to clone entireFunctionGraphs at some point.N.B. #666 addresses some of these cloning issues, but it still needs to recurse into the clients of a cloned
Applynode and clone the client nodes. In other words, any time anApplynode's inputs are changed, that node needs to be cloned, as well as all theApplynodes that depend on the updated node's outputs.Using our example graph
z, i.e.if we want to replace the node/
VariablelabeledE(as we would withlocal_reduce_add) and produce a new, independent graph, we need to clone the "downstream" nodes (i.e.D,C,B, andA), becauseEis an input to all of them.This cloning simplifies some things, but it also demands resources and doesn't scale well with the size of a graph. The same goes for all the information collected about the cloned
Applynodes; it all needs to be recomputed. These recomputations can be very redundant as well. Just consider theFunctionGraph.clientsinformation. The only client of nodeBisA, and that relationship doesn't change whenAandBare cloned; nevertheless, the entry forB's clients inFunctionGraph.clientsneeds to be recomputed using the cloned values.So how can we avoid the need to clone downstream
Applynodes and invalidate collected information?Generalized
ApplyNodesOne way to look at this cloning situation:
Applynodes can only have one set of inputs, which limits their reuse.Recall that
Applynodes are simply containers that hold anOpand lists of input and outputVariables. If we allowApplynodes to have multiple sets of inputs, then the example replacement mentioned above would only involve the addition of an alternative input to nodeD.Using this approach an
Applynode is now determined by itsOp, outputVariable(s), and inputTypes–instead of inputVariables. A "context" is needed to determine the inputVariable(s), and, if we connect these contexts toFunctionGraphs, we get a little closer to our goal.Here's a MWE illustrating these alternative
Applynodes:These contexts still require that one clone
FunctionGraphs, but at least they make it possible to do so without cloning most of theVariables andApplynodes involved. Instead, it might be possible to get away with only shallow clones ofFunctionGraphs and the data structures inFeatures that track node-related information.Traversing the Space of Rewrites
Concerns similar to the ones above arise when we consider rewrites that require the use of associative-commutative (AC) properties, because we don't–for instance–want to permute the order of arguments to a commutative
Op, construct the newApplynode, make the change in aFunctionGraph, call all the listeningFeatures, let them perform their updates, and then see if that ordering leads to a graph that matches the next step in a rewrite. Again, in this sort of scenario, we need to temporarily manifest these graphs in a lower-cost way and then reason about them.In #634, AC properties are implemented using miniKanren (via
kanren). miniKanren allows one to easily traverse and evaluate a few–or all–graphs resulting from every application order of each rewrite.Simply put, miniKanren provides a framework for lazily producing and processing sequences of rewrites applied in every order. These sequences can be infinite, as well.
Let's take a look at what miniKanren does with our graphs.
miniKanren works by producing branching streams of "state" objects. The states in miniKanren are usually unification states: i.e. maps of logic variables and their unified values. As miniKanren process each goal, those goals add new states to the stream–states which satisfy their goals, of course. When a goal-satisfying state cannot be produced in a given stream, that stream ends, yielding no results.
Let's take a look at the internal states produced by
kanren.The second state example demonstrates a failure of the state to satisfy the relation implemented by the goal (i.e.
cons(1, cdr(cdr_lv)) != z_etfor anycdr_lv).In Aesara, graphs are walked via recursive calls to nodes' inputs. In
kanren, nodes are effectivelyetuples, and their inputs are obtained via a call tocdr, making recursiveconsogoals a relational means of walking a graph. See here for detailed illustrations of relational graph walking inkanren.The miniKanren states produced when graphs are walked look similar to the example state printed above: they contain logic variables for the
carandcdrvalues of each node in a graph.Starting at the "output" logic variable
res_lv, one can see thatgraphis being deconstructed intoConsPairs.ConsPairs are the base representation of two non-sequence objects that have beenconsed together (e.g.cons(1, 2) == ConsPair(1, 2)). ThoseConsPairs hold logic variables created during recursive iterations withinwalko. Some of the logic variables map directly to the sub-graphs that haven't been traversed/"deconstructed" intoconses yet. (N.B. a sequence represented inconsform is terminated by an empty sequence (e.g.cons(a, ()) == (a,)).)These
conschains produced bywalkoare convenient representations for mixing and matching arbitrary parts of a graph. For example:The "multi-input"
Applynode functionality illustrated in the section above can be reproduced using these unification states, because the "context" arrays in that framework are effectively encoded by theconspair relationships in a unification state. This can be seen in the toy example above; by changing the value of a single logic variable, we were able to arbitrarily alter a nested tuple. If such a tuple corresponds to the inputs of a node in an Aesara graph, the resulting state corresponds to a new context array.These unification states are considerably more general/flexible than the very Aesara-specific "multi-input" contexts, but they don't translate as directly to Aesara graphs and/or
FunctionGraphs. Callingreifyon a state will construct an Aesara graph almost from scratch (i.e. by walking the graph and creating creatingetuples viaconses and then evaluating thoseetuples to produce theApplynodes). There is some caching inetuples, though, so aconsof parts that have already beenconsed before won't be re-evaluated in all instances.In general, the graphs produced by miniKanren are not automatically reified at any point in the miniKanren process, so they don't ever take the form of standard Aesara graphs or
FunctionGraphobjects. This means it's not possible to use our existing Aesara rewrites and tooling on these values without costly reifications.Nevertheless, it should be possible to combine the two techniques and leverage the streaming framework of miniKanren for our graph-space traversal needs.
To clarify, a
kanrengoal constructor and goal take the following general form:As the template above illustrates, we are able to do anything to the miniKanren state object (i.e.
Sin the example) within a goal. Also, we are able to use any type of state object that implements theMutableMappinginterface.Within this framework, we could sync the miniKanren states and their associated Aesara graphs and/or
FunctionGraphs.More on this later…
All reactions