I've been playing with const fn
s 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 fn
s:
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 fn
s in Rust are declared like regular functions, the only difference is that the const
keyword is added to the beginning of the declaration. const fn
s 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 fn
s 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 fn
s 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 fn
s and new releases of Rust often contain functions that were changed into const fn
s.
Now that we know what const fn
s 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 fn
s 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 fn
s 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.