How the Julia Compiler Works
01 Overview
Julia has no separate compile step from the user's perspective — julia script.jl looks like an interpreter invocation. Underneath, every function call goes through a full ahead-of-native-code-generation pipeline: parse, lower, infer, optimize, emit LLVM IR, JIT-compile to machine code. The compiler is invoked per method, per concrete argument-type signature, not once for the whole program. This is the mechanism that lets a dynamically typed language reach C-like performance: nothing is generic at the machine-code level, because Julia never emits machine code until it knows the exact types involved.
Each stage is inspectable from the REPL with a matching macro: @code_lowered, @code_typed, @code_warntype, @code_llvm, @code_native. These aren't debugging afterthoughts — they're the standard tool for diagnosing why a function isn't fast.
02 Parsing and lowering
Source text is parsed by a small Scheme-derived parser (historically written in femtolisp, being migrated to Julia itself) into a surface AST that mirrors the syntax closely — if, for, comprehensions, string macros, all still present as such. Lowering then desugars this into a much smaller core language: control flow becomes gotos and labels, for loops become calls to iterate, string interpolation becomes calls to string, and every macro (@-prefixed) is expanded via its macroexpand at this stage, before any types exist.
The output is untyped, SSA-form IR where every operation is either a literal, a variable reference, or a call to a generic function. There is no type information yet — x + y is just a call to the generic function + with two arguments.
function hypot2(a, b)
return a^2 + b^2
end
At the lowered-IR stage, this is a generic method definition — valid for any pair of types that support ^ and +. Nothing is compiled to machine code yet, because no concrete types have been observed.
03 Multiple dispatch triggers specialization
Julia functions are collections of methods, each associated with a type signature. Calling hypot2(3, 4.0) resolves, at compile time for that call site, to the most specific applicable method for the tuple of argument types (Int64, Float64). This is multiple dispatch: it considers the types of all arguments, not just the first (as in single-dispatch object systems).
The first time a method is invoked with a given concrete argument-type tuple, the compiler generates a specialization — a version of the method compiled specifically for those types. Call the same method again with (Int64, Int64) and a distinct specialization is generated and cached. This is why methods(f) lists user-written methods, while MethodInstances (visible via code_typed(f, types=...) or introspection tools) are the actual per-type-signature compiled units — there can be many instances per method.
| Concept | Granularity | Contains |
|---|---|---|
| Generic function | Name, e.g. hypot2 | All methods sharing that name |
| Method | One function definition | Untyped lowered IR, applicable type signature |
| MethodInstance | Method × concrete argument types | Inferred typed IR, optimized IR, compiled native code |
Specialization is triggered lazily, on first call with a new type combination, and the result is cached so subsequent calls with the same types reuse the compiled code — this is the origin of the "first call is slow" behavior anyone using Julia notices immediately.
Specialization can be suppressed deliberately with @nospecialize on an argument, which trades per-type compilation (and its latency cost) for a single generic method body — useful for functions called with many different types where compiling a fresh specialization each time isn't worth it.
04 Type inference
Once a concrete argument-type tuple is known, Julia's type inference — an abstract interpreter that runs over the lowered IR — propagates types forward through every statement, computing the most precise type it can prove for each variable and each call's return value. For hypot2(3, 4.0), inference determines a::Int64, b::Float64, that a^2 resolves to a specific Base.power_by_squaring method returning Int64, that the whole expression promotes to Float64, and so on — turning the untyped IR into typed IR annotated at (almost) every point.
Inference is what makes the rest of the pipeline effective. If every variable's type is known precisely, the compiler can unbox values (store them directly rather than as heap-allocated boxed Any objects) and devirtualize calls (replace a dynamic dispatch with a direct call to one specific method, known at compile time).
When inference can't narrow a type — often because a function returns different concrete types depending on a runtime branch, or a global variable lacks a type annotation — it falls back to a wider abstract type, in the worst case Any. Downstream code touching that value can no longer be unboxed or devirtualized, and the cost cascades: @code_warntype flags these spots in red/yellow specifically so they can be found.
julia> @code_typed hypot2(3, 4.0)
CodeInfo(
1 ─ %1 = Base.sitofp(Float64, a)::Float64
│ %2 = Base.mul_float(%1, %1)::Float64
│ %3 = Base.mul_float(b, b)::Float64
│ %4 = Base.add_float(%2, %3)::Float64
└── return %4
) => Float64
Every intermediate value has a concrete, narrow type. The generic calls to ^ and + from the source have already been resolved down to specific low-level float intrinsics — that resolution is the next stage.
05 Julia-level optimization: inlining, devirtualization, unboxing
Before anything reaches LLVM, the compiler runs several optimization passes on the typed IR itself. Three matter most for performance:
- Method inlining — a call resolved to a single small method is replaced with the method's own body, avoiding call overhead entirely.
- Devirtualization — a dynamically dispatched call where the receiver's type is known precisely is replaced with a direct call to the concrete method, skipping the dispatch table lookup.
- Unboxing — values whose type and memory layout are fully known (isbits types like
Int64,Float64, or immutable structs of such) are represented as raw bit patterns on the stack or in registers, rather than as heap-allocated, garbage-collected boxes.
These three compound: inlining exposes more code to devirtualization, which exposes more values as concretely typed, which enables more unboxing. Disabling any one of them in isolation has been measured to cause slowdowns ranging from roughly 5× to over 2000× on typical numeric workloads, since the three are mutually reinforcing rather than independently additive.
06 LLVM codegen and the JIT
The optimized typed IR is translated to LLVM IR by Julia's codegen layer. From here, LLVM's own optimization pipeline runs at the equivalent of -O2 by default — standard passes like loop unrolling, vectorization, constant folding, and dead code elimination, none of which are Julia-specific.
Compilation to native code happens through LLVM's ORCv2 (On-Request Compilation) infrastructure, using JITLink to resolve symbols and place code into the running process's memory. Julia builds a custom JIT on top of ORCv2 rather than using LLVM's off-the-shelf LLJIT directly, since it needs concurrent and lazy compilation, and the ability to update a function's entry point in place once compilation finishes without invalidating code that already called it.
julia> @code_native hypot2(3, 4.0)
.text
vcvtsi2sd %rdi, %xmm1, %xmm1
vmulsd %xmm1, %xmm1, %xmm1
vfmadd231sd %xmm0, %xmm0, %xmm1
vmovapd %xmm1, %xmm0
retq
No boxing, no dispatch, no interpreter loop — a handful of scalar floating-point instructions, indistinguishable from what a C compiler would emit for the equivalent code.
07 Caching, world age, and invalidation
Compiled MethodInstances are cached in memory keyed by method and argument types, so a given specialization is only compiled once per process. This caching interacts with Julia's world age mechanism: every method definition increments a global counter, and every call site is resolved against the "world" (counter value) that was current when the calling code itself was compiled. This is what guarantees that redefining a method mid-session doesn't retroactively change the behavior of already-running, already-compiled code — but it also means newly defined methods aren't visible to code compiled before them until that code is recompiled or re-entered from a fresh world age (the classic case being a function that calls eval to define a method and then immediately tries to call it).
Redefining a method that other cached specializations depended on triggers invalidation: those dependent MethodInstances are marked stale and will be recompiled on next use rather than reusing the now-incorrect cached code. Excessive invalidation — common when a package redefines widely-used base methods — is a major source of unpredictable latency, since it silently discards work the compiler already did.
08 Precompilation and pkgimages
Historically, only the typed/optimized IR could survive across Julia sessions in a package's precompiled cache — the final LLVM-to-native step had to be redone every time a package was loaded into a fresh process, which is why loading a large package could take longer than actually using it. Starting with Julia 1.9, native code caching (pkgimages) serializes compiled machine code itself to disk, alongside the existing typed-IR cache, so that a package's specializations don't need LLVM codegen re-run on every load.
Package authors can force specific method instances into this cache ahead of time using explicit precompile statements or the PrecompileTools.jl package, front-loading compilation cost to package build time instead of paying it on every user's first call — directly reducing "time to first X" (TTFX) latency.
This comes at a cost: precompilation itself takes noticeably longer, and cache files are larger, since they now store native code in addition to IR. The tradeoff is one-time (per package version, per architecture) against a recurring cost every process start would otherwise pay.
09 Why this differs from bytecode-VM JITs
Languages like Python or the JVM compile to an intermediate bytecode that is either interpreted directly or JIT-compiled speculatively, with deoptimization paths if a runtime assumption (a monomorphic call site, a stable object shape) turns out to be wrong. Julia has no such speculative/deoptimizing tier: every MethodInstance is compiled once, fully, for one exact tuple of concrete argument types, and that compiled code is unconditionally correct for that signature — there's nothing to fall back from. The cost of this design is upfront: compilation must happen (and can be comparatively slow) before a new type combination can run at all, and a function called with many different type combinations pays that cost repeatedly rather than converging on one adaptive compiled form.