How deep goes the comptime rabbithole

I was wondering what happens in case I have some comptime function

fn go(comptime ori: Orientation, comptime dir: Direction) void
{
    // call other functions where `ori` and `dir` are not comptime.
    // which call on their turn other functions where `ori` and `dir` are not comptime.
}

If that all would be “comptimed out” by the compiler, it would save me some duplicate code (writing a comptime and non-comptime version).

Captain Obvious here. The comptime can only go so far as something is strictly comptime known. It isn’t magical predict-everything fairy dust you can just srpinkle on your code…

Here, you’ve declared a function with two comptime arguments. That doesn’t necessarily make the function itself comptime (that would be comptime fn go..., no?).

For your code, use it and see how far you can get. You might be surprised how far you can go, and equally surprised at how quickly you run into things that must be runtime.

No way to know without writing it and learning how comptime works in detail, and how best it can apply to your use case.

No? 100% comptime parameters definitely makes it comptime. How would you call it at runtime?

1 Like

The easiest way to ensure that something is run at comptime is to use the comptime keyword at the callsite. You can then just write the function as normal and it can be called at runtime or comptime:

fn go(ori: Orientation, dir: Direction) void {
    // anything in here will be run at comptime
    // if the function is called at comptime
}

pub fn main() void {
    // run at comptime
    comptime go(foo, bar);
    // run at runtime
    go(foo, bar);
}

(this works as long as the function doesn’t try to do IO)

6 Likes

Like this:

const std = @import("std");

fn go(comptime a: usize) void {
    std.debug.print("{}\n", .{a});
}

pub fn main() void {
    go(1);
    go(2);
    go(3);
}

which outputs

1
2
3

If the functions were executed at comptime it’d be a compile error due to the IO:

pub fn main() void {
    comptime go(1);
    comptime go(2);
    comptime go(3);
}
/home/ryan/Programming/zig/zig/lib/std/Thread.zig:1165:30: error: unable to resolve comptime value
        return tls_thread_id orelse {
               ~~~~~~~~~~~~~~^~~~~~
/home/ryan/Programming/zig/zig/lib/std/Thread.zig:376:29: note: called at comptime from here
    return Impl.getCurrentId();
           ~~~~~~~~~~~~~~~~~^~
/home/ryan/Programming/zig/zig/lib/std/Thread/Mutex/Recursive.zig:50:54: note: called at comptime from here
    const current_thread_id = std.Thread.getCurrentId();
                              ~~~~~~~~~~~~~~~~~~~~~~~^~
/home/ryan/Programming/zig/zig/lib/std/Progress.zig:545:22: note: called at comptime from here
    stderr_mutex.lock();
    ~~~~~~~~~~~~~~~~~^~
/home/ryan/Programming/zig/zig/lib/std/debug.zig:204:28: note: called at comptime from here
    std.Progress.lockStdErr();
    ~~~~~~~~~~~~~~~~~~~~~~~^~
/home/ryan/Programming/zig/zig/lib/std/debug.zig:214:15: note: called at comptime from here
    lockStdErr();
    ~~~~~~~~~~^~
comptime_io.zig:4:20: note: called at comptime from here
    std.debug.print("{}\n", .{a});
    ~~~~~~~~~~~~~~~^~~~~~~~~~~~~~
comptime_io.zig:8:16: note: called at comptime from here
    comptime go(1);
             ~~^~~
comptime_io.zig:8:5: note: 'comptime' keyword forces comptime evaluation
    comptime go(1);
    ^~~~~~~~~~~~~~
4 Likes

Interesting, I guess I never did that before!

The simplest example from my code is like this.

inline for (all_orientations) |ori| { // comptime
    inline for (all_lanes) |lane| { // comptime
        const a = board.get_anchor_mask(ori, lane) // non-comptime parameters board
    }
}

That just works and that is where my question came from.

If you want something to for sure happen at comptime, use an explicit comptime:

inline for (all_orientations) |ori| {
    inline for (all_lanes) |lane| {
        const a = comptime board.get_anchor_mask(ori, lane);
    }
}

If it’s superfluous, the compiler will tell you:

test.zig:14:5: error: redundant comptime keyword in already comptime scope
    comptime go(1);
    ^~~~~~~~~~~~~~
2 Likes

Yes I know that we can run code at comptime. Maybe I did not make myself clear enough what I was thinking about.
The question is about monomorphization or optimization. When I have a function with 2 comptime booleans the exe will have 4 dedicated specialized functions, each for 1 of the possible 4 situations isn’t it? (If they are used all 4 in the program code).

In my example ori and lane are comptime in the loop.
“How comptime” is the function from board we call? Or maybe better asked. Or how optimized?
Inside there we have a switch on ori and a switch on lane returning a result.
Maybe it calls other functions as well inside there etc.

The function call isn’t comptime, but the values of the parameters are comptime. Which allows the compiler to perform more optimisations.

in your example the switches on ori and lane would be removed and replaced with the code in the correct branch.

other functions would not be called at comptime, and will not have the values of ori or lane known at comptime unless the take them as comptime parameters. As the function body is not comptime, only the specified parameters are.

Does that mean: The rabbithole goes one call level deep?

no, the body of the function isnt even comptime, just the values of the comptime parameters are comptime.

thinking of the comptime as going down the calls is inaccurate, its just the values.

Unless you comptime foo(a, b) then it goes all the way down

Aha ok :slight_smile: I think I get it. Smart compiler. Deep rabbithole.

Still not sure if I’m answering your question, but hopefully this is relevant:

export fn sum() u32 {
    return foo(.a) + foo(.b) + foo(.c) + foo(.d);
}

pub fn foo(comptime x: enum {a,b,c,d}) u32 {
    switch (x) {
        .a => return 1,
        .b => return 2,
        .c => return 3,
        .d => return 4,
    }
}

When compiled in Debug mode, this will generate 4 different versions of foo:

Each one is compiled into a single return statement (i.e. the switch doesn’t make it into the final binary at all).

example.foo__anon_460:
        push    rbp
        mov     rbp, rsp
        mov     eax, 1
        pop     rbp
        ret

example.foo__anon_462:
        push    rbp
        mov     rbp, rsp
        mov     eax, 2
        pop     rbp
        ret

...

When compiled in any release mode, though, the calls actually get inlined and sum just becomes a single return statement with the result of the addition precomputed:

If you put an explicit comptime on the foo calls, then sum becomes a single return statement in Debug mode as well:

4 Likes

In a world where functions are actually functions that’d would be the case.