I've been playing with const fns in Rust recently and kept wondering when they are executed. In this post, I'm going to take a closer look at them.

First, some background on regular functions and const fns:

Regular functions

Regular (non-const) functions in Rust are compiled into machine code and are executed at runtime. They can take any kind of arguments, both constants and non-constants. The result of a regular function can only be used in non-const contexts though. Using a regular function in a constant context (for example in a constant or as the length of an array) leads to a compiler error.

The following example shows a simple regular function square. It can be used with const and non-const arguments, but its result cannot be used in a constant.

fn square(x: usize) -> usize {
    x * x
}

pub fn main() {
    // called with constants
    const CONST_ARG: usize = 2;
    let res_a = square(CONST_ARG);

    // called with non-consts
    let runtime_arg: usize = std::env::args().len();
    let res_b = square(runtime_arg);
    
    // const RES_CONST: usize = square(5);
    // error: cannot call non-const fn `square` in constants
}

const fn

const fns in Rust are declared like regular functions, the only difference is that the const keyword is added to the beginning of the declaration. const fns can be used in constant contexts and can also be compiled and executed like regular functions.

The following example contains the same square function, but now it's a const fn. Compared to the regular function we've had before, square can now be used in a constant!

const fn square(x: usize) -> usize {
    x * x
}

pub fn main() {
    // called with constants
    const CONST_ARG: usize = 2;
    let res_a = square(CONST_ARG);

    // called with non-const arg
    let runtime_arg: usize = std::env::args().len();
    let res_b = square(runtime_arg);
    
    // evaluated at compile time
    const RES_CONST: usize = square(5);
}

You can think of const fns as being generic over whether they're evaluated at compile time or executed at runtime. By adding the const keyword to a function, we declare that in addition to being compiled and executed at runtime, the code we've written can also be evaluated at compile time.

This additional use-case comes with some restrictions though. Not everything we're allowed to do in a regular function can also be done in a const fn. For example, you cannot allocate memory in a const fn. The subset of Rust that's usable in const fns is documented in the Rust Reference and continuously expanded by the Rust developers.

By the way: changing a regular function into a const fn is a backwards-compatible change! Existing (non-const) uses of the function will continue to work, but additionally, the function can now be used in constant contexts as well. Many functions in Rust's standard library are already const fns and new releases of Rust often contain functions that were changed into const fns.

Now that we know what const fns are and how they're different from regular functions, we can look into the real question:

Compile time or runtime?

Whether a const fn is evaluated at compile time or executed at runtime depends on the arguments passed to the function as well as the context in which it is called. In fact, the same function can be used in multiple places, some of which are evaluated at compile time and others being executed at runtime.

For each usage of a const fn, we can ask the following two questions to determine when it is run:

  • Are all arguments available at compile time (only const arguments)?
  • Is the result of the function needed at compile time (function called within a constant context)?

These two simple yes/no questions generate four different cases which we will look at individually:

non-const arguments && non-const context

The simplest and most common case is a function that takes non-const arguments and that is used in a non-const context. In this case, the function needs to be executed at runtime because the arguments can't be known at compile time.

The following example uses the same square constant function as above and passes the number of command line arguments to it. The number of arguments the program is called with cannot be known at compile time, so the function can only be executed at runtime. That's fine because the result (res_b) is also a runtime variable and its value isn't needed during compilation.

pub fn main() {
    // called with non-const args
    let runtime_arg: usize = std::env::args().len();
    let res_b = square(runtime_arg);
}

const arguments && const context

If the function is used in a constant context and used with constant arguments, it is guaranteed to be evaluated at compile time. Evaluating it at compile time is possible because all arguments are also constants and therefore available during compilation.

In the example below, square is called with a constant argument CONST_ARG and the result of the evaluation becomes the constant RES_CONST.

pub fn main() {
    const CONST_ARG: usize = 2;
    const RES_CONST: usize = square(CONST_ARG);
}

non-const arguments && const context

Using a const fn in a constant context and providing non-const arguments leads to a compile error. The constant context means that the function would need to be evaluated at compile time, but the arguments aren't available until runtime:

pub fn main() {
    // called with non-const args
    let runtime_arg: usize = std::env::args().len();
    const RES_CONST: usize = square(runtime_arg);
    // error: attempt to use a non-constant value in a constant
}

const arguments && non-const context

Lastly, what happens is the function is used in a non-const context, but with const arguments?

pub fn main() {
    const CONST_ARG: usize = 2;
    let res = square(CONST_ARG);
}

All arguments are known at compile time, so the function could be evaluated during compilation. The result is only needed at runtime though, so it would also be possible to compile the function and execute it at runtime.

Looking at the code, it's not obvious what the Rust compiler will do here. Let's do some experiments!

Inspecting compiler output

To see what's going on, we're going to look at the assembly code generated by the compiler.

Assembly code quickly becomes huge and difficult to read, so we're going to take a look at a minimal example that's adapted to generate simple assembly code:

const fn square(x: i32) -> i32 {
    x.wrapping_mul(x)
}

pub fn main() {
    std::process::exit(square(10));
}

The example program simply calls the square function with a hard-coded constant value of 10 and exits with the value returned from the function call. I'm using wrapping_mul and std::process::exit here to make the resulting assembly code simpler.

There are a couple of ways to view assembly code, but my favorite is the godbolt.org compiler explorer.

Entering our example program and selecting the latest stable Rust compiler (1.80) generates the following output (godbolt.org):

example::square:
        mov     eax, edi
        imul    eax, eax
        ret

example::main:
        push    rax
        mov     edi, 10
        call    example::square
        mov     edi, eax
        mov     rax, qword ptr [rip + std::process::exit@GOTPCREL]
        call    rax

Looking at the assembly of the main function, we can see that the square function is called at runtime. Interesting!

Let's try again, this time with optimizations enabled (-C opt-level=3, godbolt.org):

example::main:
        push    rax
        mov     edi, 100
        call    qword ptr [rip + std::process::exit@GOTPCREL]

Now, the call to square and the square function itself aren't present anymore. Instead, the program simply returns a constant 100, which happens to be the result of out square(10) call. The function call has been evaluated at compile time.

It seems like the compiler's evaluation of const fns depends on the optimization mode (debug / release). Just to make sure, let's try the same example with optimizations, but this time using a non-const square function (godbolt.org):

example::main:
        push    rax
        mov     edi, 100
        call    qword ptr [rip + std::process::exit@GOTPCREL]

It's the same output as before! Our regular function square has been evaluated at compile time. What's going on here?

Constant propagation

We're seeing compiler optimizations in action here, most likely function inlining and constant propagation. The Rust compiler uses llvm to generate machine code and when compiling in release mode, llvm applies lots of clever optimizations to the code it is given. These are general optimization techniques and for them to work it doesn't matter whether we declared the function as const or not. The downside of these optimizations is that in general, we can't rely on the optimization to work. The outcome depends on optimization flags (as we've seen before) and might also change when updating to another version of the compiler. For simple programs like the example we've used, these optimizations are very likely to work, but they probably won't be able to handle more complex cases.

Let's try this with a function that's a bit more complex:

const fn fib(n: i32) -> i32 {
    if n < 2 {
        return n;
    }
    fib(n-1) + fib(n-2)
}

pub fn main() {
    std::process::exit(fib(10));
}

Compiling this code with optimizations enabled, we get the following main function (godbolt.org):

example::main:
        push    rax
        mov     edi, 10
        call    example::fib
        mov     edi, eax
        call    qword ptr [rip + std::process::exit@GOTPCREL]

This time, the compiler optimizations were't able to evaluate the function call at compile time.

We can still force the compile time evaluation by calling the function in a constant context:

pub fn main() {
    const RES: i32 = fib(10);
    std::process::exit(RES);
}

Compiling with optimizations (godbolt.org):

example::main:
        push    rax
        mov     edi, 55
        call    qword ptr [rip + std::process::exit@GOTPCREL]

This time, the function has been evaluated at compile time and the main function simply returns the result of the fib(10) call, which is 55.

Rust's const eval policy

The Constant evaluation chapter in the Rust reference contains the following statement regarding compile time evaluation (emphasis mine):

Certain forms of expressions, called constant expressions, can be evaluated at compile time. In const contexts, these are the only allowed expressions, and are always evaluated at compile time. In other places, such as let statements, constant expressions may be, but are not guaranteed to be, evaluated at compile time.

This means that when calling a const fn with const arguments and in a non-const context, Rust is free to decide whether to do compile time evaluation or not. From what I've seen and have been told, the Rust compiler currently doesn't do compile time evaluation in these cases, but that might change in the future.

Enforcing compile time evaluation

What if we want to make sure that a call to a const fn is evaluated at compile time?

One way to do it is to assign the result of the function call to a constant. This forces the function to be evaluated at compile time and I've used this trick in previous examples.

Instead of directly using the function from a non-const context

pub fn main() {
    std::process::exit(fib(10));
}

you simply write assign the result of the function to a constant and use the constant's value in the non-const context:

pub fn main() {
    const RES: i32 = fib(10);
    std::process::exit(RES);
}

This has been the only way to ensure compile time evaluation for a long time, but has two downsides. It introduces an extra declaration and since it's a constant, you're required to explicitly state the type.

Fortunately, Rust 1.79 (release in June 2024) introduced inline const expressions. Using them, it's easy to force compile time evaluation:

pub fn main() {
    std::process::exit(const { fib(10) });
}

Summary

Here's a short TL;DR of the post:

  • const fns in Rust can be evaluated at compile time, but can also be compiled and executed at runtime just like regular functions.
  • What happens depends on the arguments and the context in which a const fn is used.
  • For function calls that could either be compile time evaluated or not, the Rust compiler currently seems to be conservative and doesn't seem to do compile time evaluation.
  • General code optimization passes in the compiler backend (llvm) can do inlining and constant propagation though, which can have the same effect on the output.
  • To make sure that a function call is evaluated at compile time, wrap it with a constant declaration or use inline const expressions.

Thanks to oli_obk for answering my questions and providing feedback on an earlier version of this post!

Discussion on reddit.