Porting src/RSE/UoE/jit_comparison/src/jit_comparison/intro_numba.py to Julia.
Numba needed @jit to opt a function into compilation to machine code. Julia has no such decorator: every function is compiled to machine code, specialised on its argument types, the first time it is called with a new combination of types — the whole language works the way mul_numba did. So instead of contrasting an interpreted function with a compiled one, three compiled functions below compute the same thing and differ only in how much of the fusion work they leave to the compiler versus doing it themselves by calling straight into BLAS. All three share LinearAlgebra.mul!’s five-argument signature, (C, A, B, α, β), and all three write their result into C — that’s what makes the comparison fair: the naive and fused versions don’t get to allocate a fresh output array while mul_native! doesn’t.
usingInteractiveUtils, LinearAlgebrafunctionmul_naive!(C, A, B, α =true, β =false) C .= α * (A * B) + β * Creturn Cendfunctionmul_fused!(C, A, B, α =true, β =false) C .= α .* (A * B) .+ β .* Creturn Cendfunctionmul_native!(C, A, B, α =true, β =false)returnmul!(C, A, B, α, β)end
mul_native! (generic function with 3 methods)
mul_naive! reads like the eager NumPy version: * and + each build and return a whole new array — α * (A * B), β * C, and their sum are three separate temporaries — and only the final .= writes that last temporary into C. mul_fused! differs only by adding dots — Julia’s broadcast fusion syntax — all the way through, including the assignment. A * B stays plain * because matrix multiply isn’t elementwise (it lowers to a BLAS call, same as Numba’s A @ B); everything elementwise around it, including the final store into C, becomes dotted, and the dots are what tells the compiler to fuse. C appears on both sides of mul_fused!’s assignment — safe here because broadcasting into a destination that also appears in the broadcasted expression is computed and stored one element at a time, so there’s no danger of reading a location after it’s been overwritten.
mul_native! is C, A, B, α, β handed straight to LinearAlgebra.mul! — which computes exactly α·(A·B) + β·C, in place, because that five-argument signature is BLAS’s gemm calling convention. Nothing is fused here because there’s nothing left to fuse: the whole expression is one call into a library routine that never materialises A * B at all.
M, N, K =16, 12, 32A =randn(M, N)B =randn(N, K)C =randn(M, K)α =2.0β =Float64(π)
All three now mutate C, so all three need setup = ... — but resetting with fill!($C, 0) rather than Cc = copy($C) is deliberate: a fresh copy is itself an allocation, and while @benchmark’s setup expression runs outside the timed region, it would still leave every sample reading from last sample’s output instead of a known state, since it’s the same C object being mutated and re-read for β·C on every call. fill! zeros it in place, in O(1) extra memory, before each sample runs. The shape of the table is the point: memory roughly halves from mul_naive! to mul_fused!, then drops to zero for mul_native!, and time drops with it. Fusing the elementwise part removes one of two temporaries; skipping Julia’s arithmetic entirely and letting BLAS accumulate β·C in place removes the other.
Where the fusion actually happens
Numba discovers the fusion by analysing typed IR after the fact. Julia decides it before any type is known at all — during lowering, the step that turns parsed syntax into the untyped IR the compiler works from. @code_lowered shows that IR directly.
println("mul_naive!, lowered:")display(@code_loweredmul_naive!(C, A, B, α, β))
mul_naive!’s lowered IR is a straight-line chain of eager calls: *(A, B), then *(α, ...), then *(β, C), then +, then a final materialize! that stores the result into C — each one a call that runs to completion and hands back a full array before the next starts. mul_fused!’s IR calls Base.broadcasted instead of */+ for every dotted operator — and broadcasted doesn’t compute anything, it just records “multiply these, lazily.” The three broadcasted calls nest into a single unevaluated tree, and only the final Base.materialize! actually walks it, in one pass, writing the result directly into C. The macro-expanded syntax already encodes “one loop” before type inference, LLVM, or anything else in the pipeline has run — this is Julia’s dot syntax doing at parse time what Numba’s optimiser had to reconstruct from typed IR.
Down the rest of the tower
From here the pipeline is the same shape as Numba’s: typed IR, then LLVM IR, then assembly.
io =IOBuffer()show(io, (@code_typedmul_fused!(C, A, B, α, β)))typed_ir =String(take!(io))typed_lines =split(typed_ir, '\n')println(length(typed_lines), " lines of typed IR. The interesting bits:\n")for l in typed_linesifoccursin("generic_matmatmul_wrapper", l) ||occursin("mul_float", l) ||occursin("add_float", l)println(l)endend
A * B still calls out to LinearAlgebra.generic_matmatmul_wrapper! — the BLAS call didn’t fuse, exactly like A @ B in the Numba example. The mul_float/add_float pair is the fused α·x + β·y from the broadcast — one multiply-add per element, downstream of that call, in place of three separate array-producing operators.
io =IOBuffer()code_llvm(io, mul_fused!, Tuple{typeof(C),typeof(A),typeof(B),typeof(α),typeof(β)})llvm_ir =String(take!(io))println(count('\n', llvm_ir)," lines of LLVM IR. A sample of the fused multiply-add sequence buried inside it:\n",)for line inIterators.take( (l for l ineachsplit(llvm_ir, '\n') ifoccursin("fmul", l) ||occursin("fadd", l)),10,)println(line)end
<8 x double> — LLVM auto-vectorised the fused loop, same as in the Numba example, just with a wider register (AVX-512 on this machine: 8 doubles at a time, versus 4 via AVX2). Most of the thousands of surrounding lines are bounds checks and array-header bookkeeping, not arithmetic.
io =IOBuffer()code_native(io, mul_fused!, Tuple{typeof(C),typeof(A),typeof(B),typeof(α),typeof(β)})asm =String(take!(io))println(count('\n', asm)," lines of x86-64 assembly. A sample of the vectorised multiply/add instructions:\n",)vector_ops = (l for l ineachsplit(asm, '\n') ifoccursin("mulpd", l) ||occursin("addpd", l))for line inIterators.take(vector_ops, 10)println(line)end
One line: α and β pass straight through to generic_matmatmul_wrapper!, which forwards them to BLAS’s gemm — the same routine A * B already called, just given the accumulate coefficients it was always able to take. There’s no elementwise Julia code left to lower, type-infer, or vectorise, so LLVM IR and assembly for this function are uninteresting: it’s a calling-convention wrapper around a ccall, nothing more.
The takeaway
Three rungs of the same ladder, all writing into the same C: mul_naive! leaves three temporaries for the allocator before its final store, mul_fused! collapses the elementwise half into one loop and gets down to zero temporaries of its own, mul_native! hands the whole expression to a library that never needed a temporary in the first place. It’s the same trick — fuse operators to avoid materialising intermediates — applied at three different levels: by hand, by the language’s broadcast syntax, and by the numerical library underneath both. XLA does the same fusion at a much larger scale, later in this talk.