Skip to main content

Compile-time evaluation

To understand Mojo's metaprogramming, you need to understand how Mojo runs code at compile time. Several things can trigger compile-time code execution:

  • Assigning an expression to a comptime value.
  • Evaluating a comptime conditional or loop.
  • Assigning an expression to a compile-time parameter.
  • And a few less common cases, all identified with the comptime keyword.

Here are some examples:

comptime SIZE = 1024 // 32

Here the expression 1024 // 32 invokes the IntLiteral.__floordiv__() method. Since it occurs in a comptime assignment, the method must be run at compile time.

comptime for i in range(4):
   print(i)

Here the range(4) method needs to run to produce an iterator for the comptime for statement.

var array = InlineArray[Int, get_array_size()]()

In this example, the get_array_size() function needs to run at compile time to determine the size parameter, which forms part of the type of array. (For example, if get_array_size() returns 32, the type of the array variable is InlineArray[Int, 32].)

When the compiler encounters a function call in a compile-time context, the compiler runs the function separately, as if it was a small separate program. This is similar in concept to how C++ evaluates a constexpr. (For a slightly deeper look at this process, see How the compiler runs code.)

While most code can run at compile time, Mojo won't run code that depends on the execution environment. The following are examples of code that Mojo won't run at compile time:

  • File I/O.
  • Foreign function calls (for example, to external libraries).
  • Functions that can raise errors.

In addition, the compiler can't run functions on the GPU. Compile-time functions in GPU code are actually run on the CPU.

When running code, the compiler can allocate memory and instantiate types that allocate memory, such as strings and collections. With some limitations, it can pass compile-time values on to run-time code, a process called materialization. For more information, see the section on materialization.

Compile-time flow control

One of the simplest things you can do with metaprogramming is using compile-time flow control to conditionalize or repeat code. Some sample uses include:

  • Conditionalizing platform-specific code (CPU vs. GPU, Linux vs. macOS) without runtime overhead.
  • Unrolling loops to eliminate runtime branches.
  • Handling different data types in generic code.

Unlike run-time flow control constructs, compile-time flow control constructs are evaluated once, at compile time, and determine what code is actually compiled.

Compile-time conditionals

You can add the comptime keyword to any if condition that's based on a valid compile-time expression (an expression that can be evaluated at compile time). This ensures that only the live branch of the if statement is compiled into the program, which can reduce your final binary size. For example:

from std.sys import has_accelerator

def main():
    comptime if has_accelerator():
        run_on_gpu()
    else:
        run_on_cpu()

In this example, if no accelerator is available, the run_on_gpu() function is never called, or even compiled.

The comptime if statement can include elif and else branches just like a standard if statement.

Compile-time loop unrolling

You can add the comptime keyword to a for loop to create a loop that's fully unrolled at compile time. You should generally use this only for loops with small loop bodies and low iteration counts.

The loop sequence must be a valid compile-time expression (that is, an expression that can be evaluated at compile time). For example, if you use for i in range(LIMIT), the expression range(LIMIT) defines the loop sequence. This is a valid compile-time expression if LIMIT is a parameter, comptime value, or integer literal.

The compiler fully unrolls the loop by replacing the for loop with LIMIT copies of the loop body. The induction variable is replaced with a compile-time constant value for each "iteration." For example:

comptime for i in range(1, 5):
    b[i-1] = a[i] + a[i-1]

This is effectively unrolled to the following run-time code:

b[0] = a[1] + a[0]
b[1] = a[2] + a[1]
b[2] = a[3] + a[2]
b[3] = a[4] + a[3]

This unrolled loop compiles to branchless machine code, unlike a normal for loop, which includes a bounds test at every iteration. This can be especially important on GPU, to avoid thread divergence.

The comptime for construct unrolls at the beginning of compilation, which can greatly expand both the code size and the compilation time.

How the compiler runs code

The process of evaluating compile-time code involves three components of the compiler:

  • Parser. Parses the code into an intermediate representation (IR) and performs type checking.
  • Interpreter. Runs code at compile time.
  • Elaborator. Substitutes concrete values for compile-time parameters and produces concrete versions of parameterized functions and structs.

When the parser turns code into IR, it also replaces some very simple comptime expressions with their values, a process called constant folding. For example, the compiler can constant fold the expression 2 + 3 to 5. Standard library functions that are marked @always_inline("builtin") are constant foldable. Compile-time expressions that can't be constant folded persist and are evaluated in the elaborator.

When the elaborator encounters a function call in a compile-time context, it invokes the interpreter to run the function. The interpreter then checks whether the function being called has already been elaborated to produce a concrete, executable function. If not, the interpreter adds that function to the elaborator's work queue, and waits until it's done. Finally, the interpreter runs the concrete function—almost like it was a small separate program—and passes the return value back to the elaborator, which integrates it into the parsed IR.

When reading code, it's important to remember that when a function is being interpreted at compile time, the function has been concretized: compile-time conditionals have been processed, and compile-time constraints and assertions have been tested. This sometimes confounds the expectation that your code runs in the order it appears in the function. For example, if your function includes a compile-time assertion that fails, it fails before the interpreter enters the function, so no part of the function is evaluated—even code that occurs before the assertion.

Was this page helpful?