Porting src/RSE/UoE/jit_comparison/src/jit_comparison/python_as_metaprogramming_lang.py — but porting the point, not the code. That notebook’s whole argument was that Python is really two languages sharing one file: ordinary Python, which runs at generation time with the full ecosystem available, and Numba’s typed subset, which runs at call time with almost none of it. Metaprogramming was the discipline of keeping the seam between the two deliberate — closures, @jit, descriptors, a proxy library, all in service of writing a program that writes a program.
Julia doesn’t have that seam. There is one language, and it is its own metaprogramming language: source parses to Expr objects that are ordinary Julia values — inspectable, buildable, mutable, evaluable — and the compiler’s specialisation machinery already treats an argument’s type as a place to fix things at compile time, no closure required. This notebook shows both halves: the automatic one that comes for free, and the deliberate one (Expr, macros) for when “free” isn’t enough.
Code is data: Expr
:( ... ) does not run an expression, it quotes it — parses it and hands back the AST as a plain value.
ex =:(a + b * c)println(typeof(ex))dump(ex)
Expr
Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol +
2: Symbol a
3: Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol *
2: Symbol b
3: Symbol c
Expr(:call, :+, :a, Expr(:call, :*, :b, :c)): walkable and buildable with the same Expr constructor and the same field access as any other struct. eval runs it in a chosen scope:
a, b, c =2, 3, 4println(eval(ex))
14
That is the whole basis of what follows. Because code is data, “write a program that writes a program” is not a pattern bolted onto the object model — closures standing in for staged values, descriptors intercepting attribute access, a proxy library papering over the join — it is just building an Expr and handing it to the compiler.
The motivating gap, revisited
Horner’s rule again, coefficients as a runtime Vector:
functionhorner_runtime(x::Vector{Float64}, coeffs::Vector{Float64}) out =similar(x)for i ineachindex(x) acc =0.0for c in coeffs acc = acc * x[i] + cend out[i] = accendreturn outendCOEFFS = (1.0, -3.0, 2.5, 0.5, -1.25, 7.0, 0.125, -2.0)coeffs_vec =collect(COEFFS)x =randn(1_000_000)horner_runtime(x, coeffs_vec); # compile, once
Same obstacle as the Numba version: length(coeffs) is a runtime value the type carries no information about, so the inner loop needs a trip-count test every iteration, and the outer loop over i cannot vectorise because LLVM cannot prove the inner loop’s shape is uniform.
In Python the fix was to move coeffs into a closure, so Numba could open the cell while compiling and treat what it found as a literal. Julia needs no closure — but it does need one nudge: a plain for c in coeffs loop over an NTuple does not unroll (Base’s tuple iterate is written to work for any length, and nothing forces it open). What does unroll is recursing on the tuple’s own structure, one dispatch per shrinking type:
horner_step(acc, xi, coeffs::Tuple{}) = acchorner_step(acc, xi, coeffs::Tuple) =horner_step(acc * xi +first(coeffs), xi, Base.tail(coeffs))functionhorner_recursive(x::Vector{Float64}, coeffs::NTuple{N,Float64}) where {N} out =similar(x)for i ineachindex(x) out[i] =horner_step(0.0, x[i], coeffs)endreturn outendhorner_recursive(x, COEFFS); # compile
No Expr, no quote, no macro — just two ordinary method definitions. But coeffs::Tuple{} and coeffs::Tuple (a non-empty tuple) are two different types, so horner_step is not one recursive function, it is one method per tuple length, each calling a fully-resolved next method rather than looping. That resolution happens through ordinary multiple dispatch, the same mechanism behind every other Julia function call, applied to a type that happens to encode “how much work is left.” Confirm it actually unrolled and vectorised, the same LLVM-counting check the intro notebook used on the fused broadcast:
usingInteractiveUtilsfunctionfmul_vector_width(f, types) io =IOBuffer()code_llvm(io, f, types; debuginfo =:none) fmuls =filter(l ->occursin("fmul", l), split(String(take!(io)), '\n')) widths =sort(unique(m.match for l in fmuls for m ineachmatch(r"<\d+ x double>", l)))returnlength(fmuls), widthsendprintln("horner_runtime : ",fmul_vector_width(horner_runtime, Tuple{typeof(x),typeof(coeffs_vec)}),)println("horner_recursive: ",fmul_vector_width(horner_recursive, Tuple{typeof(x),typeof(COEFFS)}),)
horner_runtime : (2, SubString{String}[])
horner_recursive: (16, SubString{String}["<8 x double>"])
horner_runtime compiles to two scalar fmuls sitting inside a loop body — one loop iteration’s worth of work, repeated at run time, on one double at a time. horner_recursive compiles to sixteen fmuls (the eight-term chain, unrolled, run twice per @time warm-up path) operating on <8 x double> — no loop over coefficients survived to be vectorised around, so LLVM was free to widen the outer loop over i instead. Same shape the closure-baked Numba kernel reached, reached here with no Expr-level metaprogramming at all — just a recursive definition whose base case is a type, not a value.
Where dispatch runs out
horner_step still had to be designed around dispatch from the start — fine for an algorithm written from scratch, awkward for coefficients that only exist as a literal list at a call site, with no Tuple{} base case in sight. Deliberate metaprogramming — building an Expr and handing it to the compiler yourself — is the tool for exactly that shape of problem.
A macro is a function that runs at parse time: it receives the unevaluated syntax of its arguments as Exprs (or literals, or symbols) and returns the Expr that replaces the macro call — before lowering, before types, before anything downstream ever sees the original call.
macrohorner(x, coeffs...) acc =:(0.0)for c in coeffs acc =:($acc *$x +$c)endreturnesc(acc)endprintln(@macroexpand@horner(2.0, 1.0, -3.0, 2.5))
((0.0 * 2.0 + 1.0) * 2.0 + -3.0) * 2.0 + 2.5
Used inside a loop, @horner disappears entirely before the function is ever lowered:
functionhorner_macro(x::Vector{Float64}) out =similar(x)for i ineachindex(x) out[i] =@horner(x[i], 1.0, -3.0, 2.5, 0.5, -1.25, 7.0, 0.125, -2.0)endreturn outend@asserthorner_macro(x) ≈horner_recursive(x, COEFFS)horner_macro(x); # compile
@macroexpand shows exactly what the rest of the compiler receives: not a call to some generic @horner, but the eight-deep multiply-add chain, coefficients as source-level numeric literals — indistinguishable, from here on, from a programmer having typed it out by hand.
horner_step’s lowered IR is still just a recursive call — horner_step(acc*xi + first(coeffs), xi, Base.tail(coeffs)), one method, unresolved. The unrolling has not happened yet at this stage; it depends on type inference later seeing coeffs::Tuple{} versus coeffs::Tuple as genuinely different types and resolving eight nested calls into a straight line. horner_macro’s lowered IR has no call to horner_step-like machinery at all: the macro already flattened it into eight literal multiply-adds before lowering ever ran.
Two different points in the pipeline reach the same machine code — type inference resolving a chain of dispatches, or the program rewriting its own source before the compiler even parses names into scopes. Julia offers both, in the same language, with no host/target boundary, no separate metalanguage, and no object-model tax to pay for crossing it.
Benchmarking properly
The @time calls above each measured a single call — fine as a compile-vs-warm sanity check, noisy as a comparison between three implementations. BenchmarkTools.@benchmark reruns a function until the timing distribution is stable and reports the median rather than one sample; $ interpolates x, coeffs_vec and COEFFS into the benchmark expression so they are treated as constants, not slow global-variable lookups, inside the timed loop.
implementation median time memory speed-up
runtime (Vector arg) 2204.6 μs 8000072 B 1.00x
recursive (NTuple dispatch) 414.6 μs 8000072 B 5.32x
macro (@horner) 419.4 μs 8000072 B 5.26x
Memory is identical across all three — one similar(x) output array, no incidental allocation — so the whole difference is in the arithmetic each generates. The recursive-dispatch and macro versions land close to each other, both several times faster than the runtime-coefficient version, for the reason the LLVM counts and the lowered IR above already made precise: neither has a loop left over coeffs for the compiler to worry about.
The takeaway
Python’s notebook ended with five ways the closure-and-decorator pattern leaked through Python’s object model — wrong __name__, uncallable class attributes, compiling on mere attribute access, a repr that lies, __slots__ refused — and thirty lines of descriptor-plus-proxy machinery to paper over them. None of that has an analogue here. @horner is not an object standing in for a function; it is a compiler pass that runs once, at parse time, and by the time anything could introspect horner_macro there is nothing left to distinguish it from code a person wrote by hand. Homoiconicity does not just make metaprogramming available in Julia — it removes the entire category of bookkeeping Python needed to fake it.