Interpolation in macro calls

Now and again, I see macros that are able to interpolate their arguments. Here’s an example with BenchmarkTools:

julia> using BenchmarkTools

julia> ex = :(sin(3))
:(sin(3))

julia> f(x) = @btime cos($x)
f (generic function with 1 method)

julia> f(ex)
  3.186 ns (0 allocations: 0 bytes)
0.9900590857598653

Note that the cos($x) is not evaluated globally, but rather within the function scope.

There’s a huge potential here, but I haven’t been able to get my head around it (I’m still using @eval and invokelatest). The source code for @btime seems maybe a bit much to start with. Is there a general design pattern here I’m missing?

I’d really appreciate a very simple example that achieves this effect, or any help on the right way to approach metaprogramming that has reasonable composability.

3 Likes

I think what happens is that @btime just does something clever with the expression generated by interpolation syntax. Consider:

julia> macro m(e)
       @show e
       1
       end
@m (macro with 1 method)

julia> @m sin($x)
e = :(sin($(Expr(:$, :x))))
1

So @btime then presumably does some tricks to make the x not a slow global. Maybe such as doing a const xyz = x where xyz would be a gensym. But I’m just guessing.

I don’t see the connection here.

3 Likes

Yes, that is more or less what happens. Below is an expansion of @btime cos($(x+1)), with comments explaining the parameters of generate_benchmark:

julia> using BenchmarkTools
julia> using MacroTools
julia> (@macroexpand @btime cos($(x+1))) |> rmlines |> MacroTools.alias_gensyms
quote
    local manatee = begin
      (BenchmarkTools).generate_benchmark_definition(
         Main, # eval_module
         Symbol[], # out_vars
         Symbol[Symbol("cheetah")], # setup_vars
         $(Expr(:copyast, :($(QuoteNode(:(cos(cheetah))))))), (Core._expr)(:block, $(Expr(:copyast, :($(QuoteNode(nothing))))), # code
         (Core._expr)(:(=), Symbol("cheetah"), x + 1)), # setup
         $(Expr(:copyast, :($(QuoteNode(nothing))))), # teardown
         (BenchmarkTools.Parameters)() # params
      )                                                                                                                                                                        
    end
    (BenchmarkTools).warmup(manatee)
    (BenchmarkTools).tune!(manatee)
    local (sanddollar, bison) = (BenchmarkTools).run_result(manatee)
    local crow = (BenchmarkTools).minimum(sanddollar)
    local donkey = (BenchmarkTools).allocs(crow)
    println("  ", (BenchmarkTools).prettytime((BenchmarkTools).time(crow)), " (", donkey, " allocation", if donkey == 1
            ""
        else
            "s"
        end, ": ", (BenchmarkTools).prettymemory((BenchmarkTools).memory(crow)), ")")
    bison
end

In particular, you see that the “code” has been transformed into basically cos(cheetah), with a “setup” which says: cheetah = x+1.


You’ll probably be able to find the part of the BenchmarkTools which does this if you search for it. In the GFlops.jl package, I try to implement the same kind of feature (separation between code and preliminary setup) here, in order to use it there.

Hopefully you’ll find this example useful.

2 Likes

If you have a function that calls another function, you can inline the latter without changing the behavior of the former. This composability makes functions easy to reason about.

Scoping is also a difficulty. With functions, the rules are… not necessarily easier, but maybe just more familiar to most of us. There’s also lots of documentation about scoping for functions.

With macros, all of this goes out the window. Your @m macro is a good example of this: it works fine at top-level, but try calling it from a function and…

julia> g(x) = @m sin($x)
e = :(sin($(Expr(:$, :x))))
g (generic function with 1 method)

julia> g(ex)
1

From what I’ve learned about macros to this point, I’m guessing the solution is “you need to escape it”. But exactly where the esc should go is still (for me) a matter of trial and error.

There’s also a big challenge in figuring out which things are allowed. As a simple example, I had thought “macro definitions must return an Expr” was a hard constraint, but your @m returns an Int. It’s also surprising to me that @show doesn’t allow interpolation, but @m does. What is it about @show and @m that lead to the difference in interpolation ability?

For a long time, I had hoped there was a way to build “@eval but with local scope”. Then I could use a function to build an Expr, and finally call a macro to compile the code. I’ve been told this is impossible, but @show and @btime seem to do a sort of local evaluation. There’s also a design pattern I see in MLStyle.jl of using functions to build an expression, and wrapping the whole thing in a macro. So I know you can do this, I just don’t see how to learn the rules of the game.

I’d really, really love a “Metaprogramming in Julia” book, assuming an understanding of functions and carefully building up an approach to thinking about macros, with strong emphasis on getting the mental model right. Currently I don’t really see a path to building a deep understanding of this stuff.

Thanks for the link, I’ll check it out. Always great to have more examples of this, just need to work through what they all have in common. And @macroexpand is always great advice :slight_smile:

1 Like

I think there may be some misunderstandings here. I’ll write out how I understand this process and maybe that can help. Macros are functions that take in code (structured as an Expr) and return new code.

So, when you write

g(x) = @m sin($x)

and press enter at the REPL, the julia parser will ready the text, see the @m sigil and say "okay, I need to apply the macro m to the expression Expr(:call, [:sin, Expr(:$, [:x])]). This process does not wait for g to be called on an argument. It happens immediately. The macro m only operates once (unless you redefine g) and so that single time it operates, it prints out the expression it received :(sin($(Expr(:$, :x)))) and then it returns 1. This means that after macroexpansion, the function body of g gets transformed into

g(x) = 1

Macros do not know about runtime values, they only know about syntax trees. When a macro receives an expression with a $x in it, it can’t interpolate the value of x into the syntax tree because it reads the syntax tree before x ever has a value! So the interpolation syntax in macros is not given any actual meaning in julia.

Instead, when a macro is given an expression with $ in it, it assumes you’re going to give your own meaning to $x. In the case of BenchmarkTools.jl they return code that has to wait until runtime to receive the value of x and then splice that value into an expression which is evaluated and benchmarked. Nowhere in the actual body of the macro do they have access to the value of x though.

5 Likes

In addition to @Mason’s detailed and useful answer, I would like to add that macros are often shortly described as “mapping syntax to syntax”. I find this relatively useful to understand what you can and can’t do with a macro:

  • the input syntax should:
    • be parseable (i.e. Julia’s parser must be able to read the macro arguments and build a correct syntax tree out of it)
    • lead to a syntax tree which is understood by your macro
    • but nothing prevents you from assigning a meaning to the syntax that is completely different from what Julia does
  • the output syntax should:
    • be evaluable by Julia (i.e. it must obey the standard Julia rules that give meaning to an expression)

In particular:

  • 1 is something that can be evaluated by Julia, so it is a legitimate macro output
  • $ has a standard meaning in Julia; it interpolates a value in strings, quoted expressions, etc. But nothing prevents you from assigning it a different meaning in the input syntax of your macro. And that is what @btime (from BenchmarkTools) and @count_ops (from GFlops) do, but this is only because something specific is performed in these macros to handle $. Most other macros don’t have any code handling $ specifically, and simply forward $-based expressions in their input syntax to the same $-based expressions in their output syntax (which might or might not be valid code in Julia, and thus will or will not cause errors when evaluating).
  • all this is mostly not related to macro hygiene and escaping. But I would advise you to try and understand how macros work (i.e. get a mental model of the evaluation process, with the parsing, macro expansion, and evaluation stages). In a second stage, you can start understanding the details about hygiene and escaping. Of course it’s hard to dissociate the two in practice, so you might have to blindly escape everything from your first macros (knowing that it’s a bad thing and trying not to get into the habit of doing it). When you get something that works, you can start escaping only what is needed.
4 Likes

Ok, this is really helpful. I know macros take inputs as an Expr. But it had seemed $ was a magical exception to this rule. Good to know that’s not the case.

Thank you, this seems like a good way of thinking about things. But what’s still not clear is the right way of implementing a “call by reference”-like macro pattern.

For example, I have a function sourceRand to take a Model and build an Expr that evaluates to a function that samples from it. Here’s my current implementation:

function makeRand(m :: Model)
    fpre = @eval $(sourceRand(m))
    f(;kwargs...) = Base.invokelatest(fpre; kwargs...)
end

rand(m::Model; kwargs...) = makeRand(m)(;kwargs...)

I use this approach all over the place. I know there are better ways of doing this, but any macro approach I’ve tried either can’t dereference m, or evaluates to an unevaluated Expr.

Could you send a contrived but complete example, including a tentative implementation of sourcerand and an example of Model?

With this info we might be able to propose a solution.

Sure! A Model looks like this:

struct Model
    args :: Vector{Symbol}
    body :: Vector{Statement}
end

I have a @model macro that builds one of these from an expression. Here’s a simple example:

julia> normalModel
@model x begin
        μ ~ Normal(0, 5)
        σ ~ HalfCauchy(3)
        x ~ Normal(μ, σ) |> iid(10)
    end

julia> normalModel.args
1-element Array{Symbol,1}:
 :x

julia> normalModel.body
6-element Array{Soss.Statement,1}:
 Soss.LineNumber(:(#= /home/chad/git/jl/Soss/src/examples.jl:31 =#))
 Soss.Follows(:μ, :(Normal(0, 5)))                                  
 Soss.LineNumber(:(#= /home/chad/git/jl/Soss/src/examples.jl:32 =#))
 Soss.Follows(:σ, :(HalfCauchy(3)))                                 
 Soss.LineNumber(:(#= /home/chad/git/jl/Soss/src/examples.jl:33 =#))
 Soss.Follows(:x, :(Normal(μ, σ) |> iid(10)))                       

Then here’s sourceRand:

function sourceRand(m::Model)
    m = canonical(m)
    proc(m, st::Let)     = :($(st.x) = $(st.rhs))
    proc(m, st::Follows) = :($(st.x) = rand($(st.rhs)))
    proc(m, st::Return)  = :(return $(st.rhs))
    proc(m, st::LineNumber) = nothing

    body = buildSource(m, proc) |> striplines
    
    argsExpr = Expr(:tuple,freeVariables(m)...)

    stochExpr = begin
        vals = map(stochastic(m)) do x Expr(:(=), x,x) end
        Expr(:tuple, vals...)
    end
    
    @gensym rand
    
    flatten(@q (
        function $rand(args...;kwargs...) 
            @unpack $argsExpr = kwargs
            $body
            $stochExpr
        end
    ))

end

buildSource is a little helper function for this:

function buildSource(m, proc; kwargs...)
    q = @q begin end
    for st in m.body
        ex = proc(m, st; kwargs...)
        isnothing(ex) || push!(q.args, ex)
    end
    q
end

Oh, and seeing the result might help:

julia> sourceRand(normalModel)
:(function ##rand#368(args...; kwargs...)
      @unpack () = kwargs
      μ = rand(Normal(0, 5))
      σ = rand(HalfCauchy(3))
      x = rand(iid(10, Normal(μ, σ)))
      (μ = μ, σ = σ, x = x)
  end)
1 Like

Thanks, I think now I start understanding your real problem.

I think the easiest solution would be to build the specific rand function at the same time when the model is declared (using @model). Would it be possible?

No, and this has been the whole problem.

The point of the library is to provide a convenient way to describe a Model, and then to allow code to be generated for various kinds of inference. One goal is for models to be first-class. We should be able to write a model in terms of Distributions, or in terms of other models, composing arbitrarily before finally choosing an inference algorithm and generating code for it.

1 Like