World Age for beginners: one way to compile a dynamic language

0. Preface (skippable)

The Manual tries to describe the language in a more accessible way, but I’ve felt for a while that world age was unfortunately avoided. In fact, the 1.12 documentation says as much1:

World age is an advanced concept. For the vast majority of Julia users, the world age mechanism operates invisibly in the background. This documentation is intended for the few users who may encounter world-age related issues or error messages.

That may be mostly true in practice, but world age is still a unique design that allows Julia’s approach to compilation, a bit of which newcomers may find beneficial to know. This is my attempt at an accessible explanation.

1. Compilers need information ahead of time to make fast code

There’s a stereotype that dynamic languages are slow because they are interpreted instead of compiled, and it is very common for users of other dynamic languages to consider Julia for its just-in-time (JIT) compiler. This begs the question: why don’t dynamic languages broadly have compilers, too? Actually, they do. Strictly speaking, compilation is just translating code from one language to another, and compiling to an intermediate language for an interpreter counts. More rarely, dynamic languages have JIT compilers for native/machine code, directly for the processor. However, this usually has feature and performance limitations unheard of in statically typed languages. So the real question is: why is dynamic native compilation harder?

Statically typed languages are usually compiled ahead of time (AOT), that is we provide all of our code to the compiler before any of it is executed. The more the compiler can assume, the more work it can do at compile-time, and thus the less work we need at runtime to accomplish the same things. To demonstrate this effect in Julia’s JIT compilation, let’s define a couple of constant global variables and a function that calculates something from them:

julia> const onetoten = 1:10; const pi = 3.15;

julia> foo() = sum(onetoten) * pi; # define function and its first method

julia> foo() # call function, compile and run its method, then print result
173.25

If this were an interpreted language, then each function call must do exactly what we wrote in the definition. But let’s look at the optimized LLVM code, a step before machine code:

julia> @code_llvm foo()
; Function Signature: foo()
;  @ REPL[33]:1 within `foo`
; Function Attrs: uwtable
define double @julia_foo_5583() #0 {
top:
;  @ REPL[33] within `foo`
  ret double 1.732500e+02
}

All it does is return the answer 173.25. sum, onetoten, *, pi were all const names, so the compiler could assume their values won’t change within the method and did the entire calculation in advance. The biggest reason why compilers in other dynamic languages struggle is the languages opted to be more dynamic and interactive instead of allowing such assumptions.

2. If the compiler assumes, how dynamic could Julia be?

Julia is in some ways less dynamic than typical dynamic languages, or rather by default. Definitions implicitly make const global names for functions and types. A composite type (struct) or its instances/objects cannot change their structure at will. Nested functions can’t define methods depending on the outer function’s execution state. (It’s okay for the reader not to be familiar with these terms or what they mean in Julia at this point; that’s what learning Julia is for.) On the other hand, we can easily opt into slower dynamic behavior. Global variables are not const by default. Dictionaries can be used to make composites that change at runtime. In these cases, the compiler won’t assume much, and the same slower compiled code can be reused for very different objects.

Still, Julia wouldn’t be meaningfully dynamic if compiled assumptions were stuck that way. In the earlier example, pi was intended to be the mathematical constant, and 3.15 was a very poor approximation. Let’s update it:

julia> const pi = 3.1415;

julia> foo()
172.7825

When we redefined a const name like pi, the dependent compiled code for foo() was invalidated, and the method was recompiled when called again for an updated result.

3. eval and World age

As some readers familiar with dynamic languages may have spotted, dynamic code evaluation (eval) from within the same function poses a problem for compilers. First, the compiler can’t assume anything about a method if we can add lines to it at runtime. For example, being able to add lines for a local sum function to foo prevents the compiler from assuming what sum does in foo. This is simply resolved by Julia’s eval only being able to execute code into the top level outside of any function. Second, eval can still redefine pi at top level while foo is mid-execution, so what happens to the compiled code for foo? The deceptively simple answer is nothing; foo() uses the previous pi to the end. For a simpler example:

julia> function bar(newvalue)
         eval(:(const pi = $newvalue)) # this code is evaluated at top level
         pi
       end
bar (generic function with 1 method)

julia> bar(3), pi # we commit to the "previous" pi
(3.1415, 3.1415)

julia> pi # now pi has changed
3

This delay is obviously not present in interpreted languages. On the rare occasions these languages get JIT compilers however, pi needs to be checked at runtime to decide whether to keep using the compiled code or not. If pi didn’t change, the code would be slowed down by this check, and if pi did change, the code is recompiled or falls back to interpretation, which is even slower. These JIT compilers can do a lot better in practice if they profile the program execution and pick consistent parts to compile, but instead of dealing with such situational limitations, Julia specifies this delay in world age, a counter for some of the global program state. When a const name is defined for example, the world age increases. When a method is executed from top level, it commits to the world age at the time until it returns. @invokelatest can opt out of that commitment and the associated compiler optimizations to behave more like other dynamic languages:

julia> function baz(newvalue)
         eval(:(const pi = $newvalue)) # this occurs in global scope
         @invokelatest @__MODULE__().pi # @__MODULE__() means the top level
       end
baz (generic function with 1 method)

julia> baz(4), pi, @invokelatest @__MODULE__().pi
(4, 3, 4)

World age allows Julia to compile and optimize methods more similarly to how statically typed programs can be compiled ahead of time, so Julia’s compiler is also called just-ahead-of-time (JAOT). This also makes it more feasible for Julia to cache compiled code, though there are limits to dynamic behavior for compile caches and there is still a lot of work, e.g. JuliaC, going into this unique approach.

4. Practical caveats of world age (expand to read, or learn this later)

The code examples here use const redefinitions, but that wasn’t part of world age until Julia v1.12. As described in the 2020 paper, only method definitions were part of world age before2, and while const names could be redefined interactively, this came with an error or a warning that didn’t affect dependents. This is how global variable assignments behave as of 1.12:

initial declaration and assignment a = 0 (world age changes) b::Int = 0 (world age changes) const c = 0 (world age changes)
assignment with no type a = 1 (reassignment) b = 1 (type-checked reassignment) c = 1 (errors)
assignment with same type a::Int = 2 (errors) b::Int = 2 (type-checked reassignment) c::Int = 2 (errors)
assignment with different type a::Real = 3 (errors) b::Real = 3 (errors) c::Real = 3 (errors)
assignment with const const a = 4 (errors) const b = 4 (errors) const c = 4 (world age changes)

Speculatively, those errors could eventually become world age changes in future versions, though I wouldn’t even tentatively expect anything unless developers announce it.

Right side expressions of = or :: generally access the value of a variable at the time instead of continuing to reference the variable. This allows something to the effect of compile-time computation within program runtime, but reassigning variables could in turn require manual redefinitions.

julia> x = 3; y = x
3

julia> foo(::Val{x}) = 1;

julia> x = 3.14
3.14

julia> y # we never reassigned this
3

julia> methods(foo) # we never changed this
# 1 method for generic function "foo" from Main:
 [1] foo(::Val{3})
     @ REPL[8]:1

Of course, you’re more likely to use a non-const global variable and arguments for data that is expected to change. In practice, const redefinition occurs for struct redefinitions that change the fields, in which case we usually need to rewrite code anyway. Julia conveniently prints obsolete types differently:

julia> struct X end

julia> struct Y
         x::X
       end

julia> dump(Y)
struct Y <: Any
  x::X

julia> struct X # redefine X, not Y
         new
       end

julia> dump(Y)
struct Y <: Any
  x::@world(X, 38693:38699)

5. Citations

  1. Julia 1.12.2 Documentation (Nov 2025) > Manual > The World Age mechanism

  2. Belyakova, J., Chung, B., Gelinas, J., Nash, J., Tate, R., & Vitek, J. (2020). World age in Julia: optimizing method dispatch in the presence of eval. Proceedings of the ACM on Programming Languages , 4 (OOPSLA), 1–26. https://doi.org/10.1145/3428275

12 Likes

Are you referring to the paper World age in Julia: optimizing method dispatch in the presence of eval?

Congratulations on the explanation: very instructive and informative. Indeed, I hope this explanation ends up as a blog post on Julia website, so more people can find and read it.

1 Like

Yes, I thought I forgot something and it was the citations.