I think the one central idea one should keep in mind when writing macros is this:
Macros are not magic: they are a way to transform input syntax into a desired output syntax
In particular, this means:
-
when writing a function, you first think about which results it should compute given a set of input arguments. Likewise, when writing a macro, the first step should be to think about what code the macro should produce when given a specific input. In this case, even before starting to write the macro, we decided that
@cas(a, b, c)
should produce this specific julia code
(("a", a), ("b", b), ("c", c))
We took that decision because this particular expression is better (in terms of runtime performance) than others (e.g. using a Vector of Tuples). But this is the only stage where runtime is even a consideration (and, again, it happens before we even start writing the macro)
-
Now that we know what code the macro should produce, we don’t care about runtime performance. Either a given implementation generates the code we want (and it will achieve the performance associated to that code), or it does not (incorrect => performance does not matter). So we should be primarily concerned with the correctness of a macro, i.e. check that the macro does indeed produce the desired code, when given the right input. The tool to do this is @macroexpand
; a working implementation of the macro should behave like this:
julia> @macroexpand @cas(a,b, c)
:((("a", a), ("b", b), ("c", c)))
Again, if we manage to get several correct implementations of the macro, they should all produce the same code (by definition of what “correct” means for a macro) and therefore should always have the same runtime speed.
-
However, different implementations of the macro might have different expansion-time performances. This will affect the compile-time performance of your code, and if you care about such issues, you can benchmark the macro expansion phase itself. One way of doing this (there might be others) is to turn the macro into a function, and benchmark the function itself as usual:
julia> function cas_fun(args...)
ret = Expr(:tuple)
for a in args
push!(ret.args, :(($(string(a)), $(esc(a)))))
end
return ret
end
cas_fun (generic function with 1 method)
julia> @btime cas_fun(:a, :b, :c)
593.309 ns (24 allocations: 1.38 KiB)
:((("a", $(Expr(:escape, :a))), ("b", $(Expr(:escape, :b))), ("c", $(Expr(:escape, :c)))))
(note how the function call had to be adapted)
Applying such a method here:
- as early as step 2, we notice that
@cas4
is incorrect from our code generation perspective; no wonder it does not yield the same performance as other implementations
- likewise, as early as step 2 we know that
@cas
, @cas2
and @cas3
will feature the same runtime performance => no need to test again (such benchmarks should already have been done at step 1, when trying to determine the best expression for the macro to produce)
- if we benchmark the macro expansion time of all versions, it looks like the original
@cas
implementation is the fastest:
function cas_fun(args...)
ret = Expr(:tuple)
for a in args
push!(ret.args, :(($(string(a)), $(esc(a)))))
end
return ret
end
function cas2_fun(args...)
aa = (:(($(string(a)), $(esc(a)))) for a in args)
return Expr(:tuple, aa...)
end
function cas3_fun(args...)
aa = Expr(:tuple)
aa.args = [:(($(string(a)), $(esc(a)))) for a in args]
return aa
end
julia> using BenchmarkTools
julia> @btime cas_fun(:a, :b, :c)
587.229 ns (24 allocations: 1.38 KiB)
:((("a", $(Expr(:escape, :a))), ("b", $(Expr(:escape, :b))), ("c", $(Expr(:escape, :c)))))
julia> @btime cas2_fun(:a, :b, :c)
2.735 ÎĽs (27 allocations: 1.48 KiB)
:((("a", $(Expr(:escape, :a))), ("b", $(Expr(:escape, :b))), ("c", $(Expr(:escape, :c)))))
julia> @btime cas3_fun(:a, :b, :c)
892.550 ns (27 allocations: 1.61 KiB)
:((("a", $(Expr(:escape, :a))), ("b", $(Expr(:escape, :b))), ("c", $(Expr(:escape, :c)))))