Skip to main content

Materializing compile-time values at run time

Mojo’s compile-time metaprogramming makes it easy to make calculations at compile time for later use. The process of making a compile-time value available at run time is called materialization. For types that can be trivially copied, this isn’t an issue. The compiler can simply insert the value into the compiled program wherever it’s needed.

comptime threshold: Int = some_calculation()  # calculate at compile time

for i in range(1000):
    my_function(i, threshold)  # use value at runtime

However, Mojo also allows you to create instances of much more complex types at compile-time: types that dynamically allocate memory, like List and Dict. Re-using these values at run time presents some questions, like where the memory is allocated, who owns the values, and when the values are destroyed.

When you use a comptime value at run time, you're explicitly or implicitly copying the value into a run-time variable:

comptime comptime_value = 1000
var runtime_value = comptime_value

This process of moving a compile-time value to a run-time variable is called materialization. If the value is implicitly copyable, like an Int or Bool, Mojo treats it as implicitly materializable as well.

But types that aren't implicitly copyable present other challenges. Consider the following code:

fn lookup_fn(count: Int):
    comptime list_of_values = [1, 3, 5, 7]

    for i in range(count):
        # Some computation, doesn't matter what it is.
        idx = dynamic_function(i)

        # Look up another value
        lookup = list_of_values[idx]

        # Use the value
        process(lookup)

This looks reasonable, but compiling it produces an error on this line:

lookup = list_of_values[idx]
cannot materialize comptime value of type 'List[Int]' to runtime
because it is not 'ImplicitlyCopyable'

Just like Mojo forces you to explicitly copy() a value that’s expensive to copy, it forces you to explicitly materialize values that are expensive to materialize by calling the materialize() function.

Here’s the code above with explicit materialization added:

fn lookup_fn(count: Int):
    comptime list_of_values = [1, 3, 5, 7]

    for i in range(count):
        idx = dynamic_function(i)

        # This is the problem
        var tmp: List[Int] = materialize[list_of_values]()
        lookup = tmp[idx]
        # tmp is destroyed here

        process(lookup)

This code materializes the list of values inside of the loop, which includes dynamically allocating heap memory and storing the four elements into that memory. Because the last use of tmp is on the next line, the memory then gets deallocated before the loop iterates. This creates and destroys the list on every iteration of the loop, which is clearly wasteful.

A more efficient version would materialize the list outside of the loop:

fn lookup_fn(count: Int):
    comptime list_of_values = [1, 3, 5, 7]

    var list = materialize[list_of_values]()
    for i in range(count):
        idx = dynamic_function(i)

        lookup = list[idx]

        process(lookup)
    # materialized list is destroyed here

Global lookup tables

Mojo doesn’t currently have a general-purpose mechanism for creating global static data. This is a problem for some performance-sensitive code where you want to use a static lookup table. Even if you declare the table as a comptime value, you need to materialize it each time you want to use the data.

The global_constant() function provides a solution for storing a compile-time value into static global storage, so it can be accessed without repeatedly materializing the value. However, this currently only works for self-contained values which don’t include pointers to other locations in memory. That rules out using collection types like List and Dict.

The easiest way to use it is with InlineArray, which allocates a statically sized array of elements on the stack. The following code uses global_constant() to create a static lookup table.

from builtin.globals import global_constant

fn use_lookup(idx: Int) -> Int64:
    comptime numbers = InlineArray[Int64, 10](
        1, 3, 14, 34, 63, 101, 148, 204, 269, 343
    )
    ref lookup_table = global_constant[numbers]()
    if idx >= len(lookup_table):
	      return 0
    return lookup_table[idx]

def main():
    print(use_lookup(3))

At compile time, Mojo allocates the numbers array, and then the global_constant() method copies it into static constant memory, where the code can reference it without requiring any dynamic logic to create or populate the array. At run time, the lookup_table identifier receives an immutable reference to this memory.

Note the use of ref lookup_table to bind the reference returned by global_constant(). Using var lookup_table would cause a compiler error, because it would trigger a copy, and InlineArray doesn’t support implicit copying.

Materializing literals

Literal values, like string literals and numeric literals are also materialized to their run-time equivalents, but this is mostly handled automatically by the compiler:

comptime str_literal = "Hello"  # at compile time, a StringLiteral
var str = str_literal  # at run time, a String.
var static_str: StaticString = str_literal  # or a StaticString

Both String and StaticString can be implicitly created from a StringLiteral, but without a type annotation, Mojo defaults to materializing StringLiteral as a String.

You can define a "nonmaterializable" struct that can only exist at compile time, and which is replaced by a different type at run time. For example, the standard library IntLiteral type supports arbitrary-precision math at compile time, but materializes as a standard Int in run-time code. For more information, see the @nonmaterializable decorator.

Was this page helpful?