Comptime-known values passed to runtime function paramters, do they optimize?

SUMMARY: How far should I trust the compiler to be able to determine that comptime-known values passed to runtime function parameters can produce (alternate) optimized function calls that prune out unecessary branches/logic, and should it be considered a better practice to provide library functions that always take runtime parameters and trust the compile to fix it, or force function parameters to be comptime if I always want the comptime optimizations to occur.


Hi, I’ve been writing a number of my own tools/libraries in zig and one thing that has consistently bothered me is if I should or should not be forcing certain function parameters to be declared as ‘comptime’.

Like, if I expect 99.9% of users to know a parameter MyOptions at comptime,
I might just simply force the parameter to be comptime:

pub fn myOperation(comptime options: MyOptions, value: anytype) void {
    switch (options.mode) {
        .MODE_1 => {
            // comptime selected algorithm using 'MODE_1',
            // this branch should not exist when providing option 'MODE_2'
        },
        .MODE_2 => {
            // comptime selected algorithm using 'MODE_2',
            // this branch should not exist when providing option 'MODE_1'
        },
    }
}

Written like this, depending on what ‘mode’ the user provides as an option,
the compiler would be expected to prune the branches that are never applicable at their call-sites (maybe not in ReleaseSmall, but that’s fine).

But this puts a hard constraint on how an end user can use the code: they MUST know the comptime parameters at compile time, and maybe they wanted to use my library function with runtime MyOptions

The solution as a library developer is to provide a version with runtime-allowed parameters, but providing 2 separate functions that do the same thing with the exception that one takes comptime parameters and one takes runtime parameters seems like a bad/bloated pattern, even if I expect only 0.01% of users to want to use it in this way.

That means the ‘better’ choice seems like it might just be to provide the runtime options version as the ONLY option:

pub fn myOperation(options: MyOptions, value: anytype) void {
    switch (options.mode) {
        .MODE_1 => {
            // probably accessed by a jump table based on the integer value of options.mode
            // but *might* be pruned away if options is comptime-known to be 'MODE_2'?
        },
        .MODE_2 => {
            // probably accessed by a jump table based on the integer value of options.mode
            // but *might* be pruned away if options is comptime-known to be 'MODE_1'?
        },
    }
}

It is not obvious in this example, but what if comptime pruning of unused branches is significantly better/faster/smaller than the runtime alternatives, for whatever reason.

My question then is this: If there is clearly a benefit to the compiler pruning branches based on comptime-known values, but I still want to allow end users to be able to call the same function with runtime values without writing a runtime version with duplicated code, how far should I trust the compiler to be able to determine that comptime-known values passed to runtime function parameters can produce (alternate) optimized function calls that prune out unecessary branches/logic, and should it be considered a better practice to provide library functions that always take runtime parameters and trust the compile to fix it, or force function parameters to be comptime if I always want the comptime optimizations to occur.

The compiler does optimize runtime parameters into comptime parameters, and creates specialized versions of functions. In general, if the parameter can be runtime-known, then it’s better to let it be runtime-known.
Sometimes, even when the actual argument is comptime-known, it’s still better to pass it at runtime, due to code size and cache locality, for instance.
If a specific function is generating suboptimal code at a certain call site, I think you can give a hint to the compiler by doing this:

fn foo(a: u8) void{}

fn foo2(comptime a: u8) void{
  foo(a);
}

Only if the compiler is still not generating the desired assembly should you modify the original function foo to force optimizations based on comptime-ness.
You don’t really need to provide foo2, the user can write their own.

2 Likes

From what I’ve seen so far both in C and Zig: you can trust the compiler to optimize comptime known values (and very aggressively so) without marking them comptime - assuming that optimizations are enabled. This can go as far as replacing an entire function call with its result if the function is ‘pure’ and is called with comptime-known values.

Marking a parameter comptime is mainly useful to get an error on a ‘comptime-violation’ instead of silently falling back to runtime evaluation, or to enable other comptime features like generics.

AFAIK most of those optimizations happen down in LLVM though, so it remains to be seen how good the ‘native’ backend will be.

In general I would try to avoid marking function args comptime unless needed, because with comptime you’re basically ‘coloring’ the function and can’t use it both in a comptime and runtime context.

Also note that you can enforce comptime evaluation at the call site of a function via comptime expressions: Documentation - The Zig Programming Language

3 Likes

I am still a bit puzzled about the why and how of comptime.
Inside my chessprogram I have for example this function:

pub fn by_side(self: *const Position, side: Color) u64 {
    return self.bitboards_by_side[side.u];
}

The parameter side is a runtime one.

Now when using this function where side is comptime known I can do this:

fn gen(self: *const Position, comptime us: Color) void {
    const bb_us = self.by_side(us);
}

or this:

fn gen(self: *const Position, comptime us: Color) void {
    const bb_us = self.by_side(comptime us);
}

Is there any difference?
There are of course more complicated ones, where calls do calls do calls etc. to other functions which have a runtime side parameter.

I don’t know how to view the assembly code in releasefast mode in some readable format, so I cannot check that ouput.

A little experiment writing comptime only function copies did not make the code faster.

No. The comptime keyword inside the argument list just makes the argument be evaluated at comptime, but it already is, because the argument is already comptime, so it’s redundant.

2 Likes

Not completely redundant. It documents that the parameter must be comptime-known, and moreover, it enforces that being the case. So it creates a semantic difference if the function would otherwise compile correctly with a runtime-known value.

Sometimes one doesn’t want that, of course, it wouldn’t make a lot of sense to write two identical functions, one with a comptime argument, because what can be evaluated at comptime already is.

2 Likes

Completely redundant. The parameter was already comptime by the function signature.

I definitely had some strange case. I will try and reproduce that it in a simple way.

When using show in the code, it just compiles of course.
But it is not optimized by the compiler.
We can kind of deduce that because the show_comptime does not compile.
That is where my unsureness about what happens comes from.

When calling show it seems to me that show is not comptime optimized.
At least I suspect so in more complicated cases.

const std = @import("std");

pub fn main() !void {
    go(1);
}

fn go(comptime us: u1) void {
    const them: u1 = opp(us);
    // show_comptime(them); // error: unable to resolve comptime value
    show(them); // this one compiles of course
}

fn opp(us: u1) u1 {
    return us ^ 1;
}

fn show_comptime(comptime us: u1) void {
    std.debug.print("{}", .{us});
}

fn show(us: u1) void {
    std.debug.print("{}", .{us});
}

We can fix compiltation either by

  • inlining the opp function
  • using const them: u1 = comptime opp(us);
  • making the opp function’s us parameter comptime but then we lose functionality and need a runtime version as well.

them is not comptime, by the language definitions, which is why the compiler complains. Optimizations will deduce it is compile-time known, but that comes at a later stage in the compilation, and only when optimizations are enabled. In this case, you need to mark it as comptime.

For clarity: function calls are not evaluated at compile time, unless all parameters are/the return type is required to be comptime, or you do comptime foo()