"Compiling" DSL code to Reduce Indirection

Hello,

In my quest to learn more about how to write DSLs in Julia, I’ve reach a point in the design and would like feedback and suggestions. For reference, the DSL is based of the SPPL language for symbolic exact inference. Like in normal Julia, SPPL can run basic arithmetic computations like

X ~ normal(0,1)
A = X^2
B = X+1
C = exp(A)

From the example above, A,B,C were computed using simple arithmetic transforms of the sampled X. The legal rules for what type of computation based of a sample (e.g. X) are intuitive (and maybe obvious?) - subsequent variables can only depend on previous variables. In the example above, C depends on A which depends on X.

The example above provides some context, but my question really boils down to executing - mostly arithmetic - function calls. Under the hood, I have a struct that stores the compute graph which describes how variables depend on each other and how they are transformed. The compute graph has several operations, but the most important one is sampling from it. Sampling should produce a mapping between variables to their values. In dictionary form, it would look something like this

Dict(:X => 0.0, :A => 0.0, :B => 1.0, :C => 2.71828)

Basically, samples are generated as if we ran the above code snippet as normal Julia code. The only difference though is that the computation uses the compute graph and must follow the pointers from one variable to the next. Example pseudocode:

G # compute graph
assignments = Dict{Symbol, Float64}() # Values of evaluated variables

...
# Variable C is the next to compute.
child = :C
parent = :A
f = G[child] # exp
assignments[child] = f(assignments[parent]) # evaluate C
...

This is fine, but definitely not performant. What would be ideal is if this computation didn’t follow the compute graph at all and behaved exactly like Julia code.* So really, I would like to go from something that is effectively a graph with function pointers and transform it into normal Julia code. For the running example, this can look something like

function f(X)
  A = X^2
  B = X+1
  C = exp(A)
  Dict(:A=>A, :B=>B, :C=>C) # a named tuple (A,B,C) could work here as well
end

I suspect that compiling the data structure into a normal Julia function not only removes any indirection, but also can leverage Julia’s compiler for great performance, especially for many calls to sample. However, it’s not really clear to me what tools I have available. Macros run at parse time, so that is out of the question. Dynamically creating Exprs is possible, but I’m unsure whether this is good practice or how it effects type stability. Symbolics.jl is tangentially related and maybe can take something that is compute graph-esque and give compiled code (this?) What are some options to consider?

So far I have mentioned using a compute graph, but there is an arguably simpler problem posters can respond to which is what if instead of a compute graph, it is just a simple dictionary mapping symbols to functions of X. An example:

Dict(:A=>exp, :B=>sqrt, :C=>abs)

The compiled code could look something like

function f(X)
return Dict(:A=>exp(X), :B=>exp(X), :C=>abs(X))
end

Thanks.

*Now why do I need the computation graph in the first place? I think other operations turn out to be easier and it is not so clear otherwise. That being said, I’m open to design changes if need be.