Comptime and Incremental Compilation

Continuing the discussion from Comptime unique IDs:

Worth pointing out a thread about this very topic already exists, with a relevant issue about why, even though you can probably make this work in 0.13, it should be considered a latent bug to do this.

My hope is that, as developer attention turns from building out the stage2 compiler, to adding incremental compilation, that will be an opportunity to start specifying comptime semantics in detail. It’s Zig’s signature move, after all, but the way it works right now is rather ad-hoc.

I fear we’re facing a trilemma here, where the horns are:

  • Zig has comptime, which lets you do things while compiling which only a Turing-complete language gets you. To put it more directly, Zig has comptime, a feature which lets you use Zig while compiling (with some wiggle room for necessary differences in semantics).
  • Zig has incremental compilation, and eventually, hot loading: you can work on a Zig program while it’s running, and the compiler will a) only recompile what it has to and b) patch the new object code into the runtime.
  • Zig’s incremental compilation is deterministic and consistent: it always results in the same program as a full compilation, modulo details which don’t affect execution.

My bias is that I consider a powerful comptime essential, it’s easily the #1 reason I’ve committed to the language and intend to make heavy use of it for many years. But while I see the appeal of incremental compilation and hot loading, and would love to see that added to the language, I don’t need it or especially want it, and certainly don’t want it to cripple comptime.

The characteristic trait of a Turing complete language is that it isn’t decidable. This is, to put it mildly, a barrier to deterministic incremental compilation of code which has comptime components. It’s a heavy-duty and gnarly manifestation of cache invalidation.

There’s some way to thread this needle, but I don’t think we know what that is yet. I don’t expect that limiting mutable state to particular scopes will turn out to be sufficient, which might mean it isn’t necessary either.

There’s two paths forward here: one is taking the time and energy to specify comptime semantics, to decide what they even should be. The other is to barrel ahead with incremental compilation without doing that, and put out fires caused by comptime in a piecemeal way, declaring the result to be the semantics of comptime.

I think maintaining a unique global counter is representative of an entire class of behavior which comptime should enable. That raises a whole host of questions even before incremental compilation enters the picture.

Removing it from the language before even trying to answer those questions would be a mistake.

2 Likes

There’s a linked issue pointing to how problems of this nature can already arise without needing closed-over state. Mutable state alone is sufficient.

I think one of the main challenges is defining comptime, or pinning it down in a succinct way that limits the amount of confusion.

As I understand it, comptime can:

  • Compute a value (i.e. comptime fib(35), [1]u8{0} ** 256)
  • generate a value (i.e. ArrayList(T)
  • Allow a function to operate on multiple values as long as they meet the interface (i.e. writer: anytype)

And that’s about it. The rest should be done at runtime.
I don’t see comptime as “Running zig at compile time” because there are limitations. It’s using the syntax and semantics to make meta-programming more homogenous. I don’t think it is feasible to have all of the Zig runtime work with comptime.

As far as the linked issue goes, I disagree that it would be expected to work. We have to separate out runtime behavior to compile time behavior. To me changing a type halfway through a program makes no sense.

A while ago, I said something here that I still stand by on the thread about “what does 1.0 mean to you?”. Essentially, until Zig is stable, no one has actually written a line of “true” Zig code. Everything is up for being changed.

In that same post, I also argued that certain things seem cast in stone - that u8 will always mean unsigned 8-bit integer seems like a given.

The core team has stated in the past that their goal is to make the fastest and most advanced compiler possible and that the language will have to change to meet those goals. I think this is why they’re hesitant to put out specs like the C-standard.

2 Likes

Also, I believe (but could be wrong here) that this is also why they are deferring problems like aliasing. I seem to recall that they mentioned they want to figure out more about the compiler first before trying to solve those issues along the way. I could have sworn I read that posted here actually…

2 Likes

That wasn’t what I was saying should be expected to work, I assume you refer to this issue, since it’s the one which has a type in it.

The type is changing halfway through the compilation process, not halfway through a program. I brought it up because, as the Expected Behavior section suggests, there are two possible ways to prevent the problem from arising.

That’s an example of comptime being underspecified. I don’t think that the correct answer is “no comptime-mutable types”, either: building up a type directly using conditionals is useful for metaprogramming, and that requires the type to be variable.

“No instantiation from a comptime-variable type”, that’s probably the better rule: you can mutate a type all you want, but you need to assign it to a constant to use it in your program as a type.

This is a drastically incomplete, and impoverished, list of what comptime can do.

“Should” is not very interesting here. What is and isn’t good style is a separate question from how comptime itself should work.

But comptime is, in fact, running Zig at compile time. I don’t think that it’s a good goal to have all of the Zig runtime work at comptime, necessarily, but it could be feasible within broad limits. A compiler is a program, it could contain a feature-complete Zig interpreter, done.

There’s an open issue to get comptime execution speed roughly in line with CPython, which suggests a more expansive role for comptime than just your three bullet points. Slow comptime would be plenty for that sort of undemanding application.

I mean specifying slightly less formally than this. A published standard is, among other things, a commitment, and generally speaking it should be documentation of something established. Algol 68 and Ada were all the cautionary tale we need for the hazard of specifying a language standard and only then implementing it.

There are some good issues in the tracker already, like the Comptime Memory and Type Punning Rules one, the topic isn’t being neglected by any means.

But there’s a specific tension between incremental compilation and a powerful comptime mechanism, and my hope is that this tension is a creative one. To carve out one example, I consider the inability to use allocation at comptime to be a regression, I’m told it was a necessary one, but it would be a shame if subsequent design decisions painted adding that back out of consideration.

One of the main things I’m thinking about here is order of execution. Alas, that didn’t come with a flash of brilliant insight about precisely how that should work.

1 Like

Hello, having comptime types does not bother me in itself, on the contrary. But having the values of these fields be comptime, I find that difficult. Alternatively, there should be a special comptime that indicates not only that the type of this field is immutable, but its value as well.

This is really something I am struggling with.

2 Likes

As far as I’m aware, there is no longer a way to do this. If you find one, please do say.

The issue you link is essentially solved, albeit with slightly different semantics. That issue proposes some stricter restrictions which we may or may not apply, but that’s not related to incremental compilation.

You seem to think the interactions between these features are unsolved problems. The relevant changes in the 0.12.0 release cycle have all but solved this problem. We have modeled incremental compilation in such a way that no more major language changes are probably necessary, with the possible exception of removing usingnamespace.

Even ignoring the aforementioned fact that no more big changes to comptime are necessary, I’d also like to note that Andrew has explicitly stated his willingness to change the language if necessary to make incremental compilation viable. To be blunt, if they’re necessary, these changes are going to happen.

Also, global comptime mutable state was always a bug. It worked in often unexpected ways, and made compiling Zig code non-deterministic. This “feature” is not coming back.

Why do you deem this a problem? Decidability is completely tangential to incremental compilation.

We do know what it is; we’ve done it. The incremental compilation model in the Zig compiler should work correctly under the current semantics.

As discussed above, we can and will change the language as needed to make incremental compilation work. From this perspective, nailing down the precise semantics before we’re sure of what works would be a ridiculous move.

Global comptime counters are not and will never be an intended feature of Zig. We are not going to design a language where the behavior of the program at compile-time is hugely implementation-defined (and in fact non-deterministic once we thread semantic analysis). This matter is entirely settled and has been for a long time.

5 Likes

This design issue is already solved. I hadn’t noticed the issue before now – it’s now closed.

I’m unsure where you heard this from: it’s not at all true. Using Allocator at comptime has never worked (at least not in recent history), and we do plan to make it work; there is no problematic interaction with incremental compilation here. See this issue.

I suppose my post was more provocative than intended.

Thanks for the additional information, it’s appreciated.

That doesn’t seem inevitable to me, particularly the non-deterministic part. “global” is a bit of a misnomer, correct? file-container-scoped, rather.

But it isn’t the sort of thing I want to go to bat for, either.

I suppose my post was more provocative than intended.

I didn’t interpret it that way; apologies if my response came off as aggressive or anything!

That doesn’t seem inevitable to me, particularly the non-deterministic part.

If you have a container-level variable which is mutable at comptime – a basic necessity for a comptime counter – then the way comptime logic executes depends on the order in which different things are analyzed. For instance:

// pretend you can do this at container level
comptime var a: u32 = 0;

comptime {
    if (a == 0) @compileLog("foo");
    a += 1;
}

comptime {
    if (a == 0) @compileLog("bar");
    a += 1;
}

Would this code snippet print foo or bar? Well, that depends in which order the blocks are analyzed. You could argue that they should be analyzed top-to-bottom, but that a) breaks down in more complex cases like generic functions, and b) hugely limits the ability of the compiler to be threaded.

3 Likes

Considering that

I guess it’s just going to be “whatever the compiler does”.

There do happen to be other approaches to partitioning a control flow graph, though.

Ah, sorry on this one. I didn’t read the issue all the way, just the code example. Appologies for the misunderstanding

Okay, then add to it. What is your understanding of comptime? Right now everyone is throwing out the word comptime but everyone seems to have a different understanding. If we could have a better way of showing people the abilities/limits/goals, it might help with some confusion.
To be fair, I am coming at this more from the perspective of comptime as currently implemented and (correct me if I am wrong) you seem to be taking a perspective of what comptime could potentially be at 1.0

Agreed. It largely is bound by what the goals of comptime are. I would argue that the goal of comptime is largely to allow the programmer to provide the compiler with information on how to generate the code. This will shape what can be done with comptime. Like you are saying, this has an impact on incremental compilation. If comptime can’t be deterministic (or cacheable in the case of side effects), it will cause problems and would need to come with “Here be dragons” caveats.

I model comptime as a mechanism, not a list of applications. As far as common applications which didn’t make your list, I would cite conditional type construction based on any information available to the program, and arbitrary construction of function bodies based on the same thing.

The mechanism is arbitrary code execution during compilation, using the same language as the program is written in. Not totally arbitrary, the consensus is that we don’t want comptime making network calls or operating on the filesystem, and I agree with that, broadly speaking. Nor is it, or should be, exactly the same language, comptime is a superset of Zig, but not a proper superset, it’s disjoint, meaning there are things you can do at runtime which won’t work at comptime. But on the level of syntax, there are some types and control structures which only apply to comptime constructs.

I’d like to note that the reasoning behind disalllowing syscalls at comptime should, at this point, be a clean separation between the build script and comptime execution, because the use of a build.zig means that running zig build does in fact result in arbitrary code execution in status quo.

I wanted to verify that this was the case, so I wrote a little demonstration program which makes a syscall for some randomness and produces a random program every time the build script needs to be rebuilt. There’s definitely a tension here between numerous useful things which open code execution in build.zig can accomplish, and the expectation that merely building a program can’t delete your hard drive. Overall though, if you’re building a strange module, then the module code itself is capable of causing arbitrary amounts of damage, so limiting the capability of build.zig provides roughly no protection against malice, and would just restrict the ability of good actors to do things like look for a config script, or download missing data. So I think this behavior is correct and good.

Still, I think comptime should be sandboxed: if a build needs to interact with the system or the network, the build script is the place to do that.

I’m interested in both, but you’re right that I care more about what Zig becomes than about what it happens to be right now.

In a way, asking what comptime is ‘for’ is like asking what any programming language is ‘for’, the great thing about programming is that authors are constantly astonishing us with things they’ve figured out how to do with the tools available. Is it practical to, say, play Tetris at comptime, and use the result of the game to fill up an array? No, clearly not, but the power that ability represents is very practical indeed.

I was exceptionally pleased when I wrote some comptime tests for mvzr and it just… worked. Using regular expressions at comptime doesn’t fit your typology of what comptime is for, but is eminently practical in a bunch of ways which I’m sure will occur to you. Of course Fluent has been doing that for a long time, and I wrote mvzr because I needed runtime regex, but that’s an aside.

I think it can be, and sure, one way to make that easier is to restrict stateful operations to certain scopes. What I don’t like is rhetoric of the “you shouldn’t do that anyway” school. That may be applicable to any one application, but it’s a senseless thing to say about something as general as maintaining some global state within a file while compiling it.

There’s two kinds of nondeterminism here: one is “this program should deterministically compile but it doesn’t”, and of course this is undesirable. The other kind is like my gist: it’s supposed to be nondeterministic, and it is. Since it’s a pointless program written because I wanted to verify a property of the build system, I stopped once I got it to compile randomly, but I’m sure that tweaking it a bit with a has_side_effects = true on some step, I could make it compile randomly on every compile, not just when the build step changes.

It’s quite clear to me that the bad kind of nondeterminism is not an inevitable feature of file-container-scoped mutable comptime state. Putting the effort into sketching a design for that isn’t worthwhile given that

But there’s nothing impossible about it.

I don’t think it matters whether it is possible or not, it isn’t wanted for the development direction that was chosen.

I just don’t see it as a desirable feature at the comptime level.
Global counters remind me of c preprocessor shenanigans, like turning the whole source code into one linear file and defining and re-defining symbols while that is happening. I don’t think that is a good way of organizing and using source code.

I would find it preferable if instead in the future the communication protocol between the compiler and the build.zig could be used to inform the compiler about needed ordering constraints or somehow be used to customize how things are arranged, if that is something that is needed by some applications.
(or maybe something like that would be done through (maybe extended) linker rules?)

What you describe reminds me a bit of Jai from what I have heard about it, it seems that its version of meta programming literally works by some kind of messaging system between the running compiler and intercepting messages and probably somehow emitting new code from that, I guess Zig could work like that, but I suspect it would lead to a version of comptime where you have to understand a whole lot more about how the compiler works internally and I also wonder how that version behaves when people do stuff with meta programming that turns that mechanism into some giant self-recursive knot.

My guess would be that this would open the door to destroy compile times by creating crazy meta-programs, where with Zig there is some hope that with @setEvalBranchQuota and Run build.zig logic in a WebAssembly sandbox · Issue #14286 · ziglang/zig · GitHub you could get something fairly controllable and sandboxable. I think limits help with making certain use-cases more feasible.

To me it seems like it would lead to too much freedom and a whole lot of foot-shooting via way to much meta programming, if Zig had too much available power via comptime. You always can argue that people could be taught to use a powerful tool carefully and don’t do certain things, but I think one of the benefits of comptime is that it allows you to do a good portion of things, while also having limits that make you reconsider the approach and look for different tools, instead of cramming everything into one tool, which would basically lead to something like what Jai seems to be (It seems like it can do basically everything at comptime, but also needing a very responsible programmer not to create something badly performing) or Lisp/Rust-Style Macro Systems, which come with their own burden of increased complexity, more learning difficulties, increased splitting up of the language in multiple sub-dialects and families of popular macros.

The things I have seen about Racket (Lisp) macros are impressive, but they become increasingly more tedious and difficult to actually get right and understand, Racket has like 3 different ways to write macros, they all can interact (and all end up expanding to racket), but that alone is a problem of having these language extension mechanisms. You get competing implementations of language extension meta-languages, for describing pattern based macros, different notations and short-hands and so on.

Getting a grasp about these things just confuses people and wastes their time, instead of actually getting work done, you end up trying to become a macro guru, for something you could easily have written just by hand.

To much meta programming power can easily become a trap of wasted effort and brain cells. The worst part is that it is enticing and then a few weeks later, it can be difficult to understand what it was doing.

I get that this isn’t an objective argument for or against more unlimited forms of meta programming, I just want to point out that I think that comptime has limits that I find helpful, in not going overboard with meta-programming.

No, a module being compiled by the language has a specific meaning and semantics and those semantics of the language and how it deals with its source code while compiling matter, restricting what can happen in that process is part of the language, the language can open doors to provide hooks within those semantics or it doesn’t. It is perfectly fine to say certain things aren’t supported by the language, those limits create the needed constraints to enable certain use-cases and whatever was made impossible through the limits has to be achieved on some other level.

Every language has a set of valid programs, and certain restrictions make it possible to implement specific features, I don’t think it makes sense to say stuff should be allowed, because we shouldn’t restrict power, so we can have general things.

It doesn’t make sense to talk about these things, unless we speak about specific use-cases and how they interact and work together, because that is the whole reason why specific limits are chosen, so that other things become possible to implement in a reasonable manner and with the wanted results/quality.

There might be another language that chooses to implement something that Zig doesn’t and make compiling less parallelize-able, it is about tradeoffs and priorities.

2 Likes

The “play Tetris to configure an array” was lightly paraphrased from Jai, yes. Zig has “fuel” with @setEvalBranchQuota, and that’s a good thing, as long as comptime can be configured to use arbitrary amounts of fuel. Which currently, it can be. I hope it stays that way, languages should serve their users.

This could be a good idea, or not, it depends on how it’s implemented. For instance, if build.zig was sandboxed, but also had a capabilities architecture, so that other build scripts were allowed to do things but only on an explicitly-configured basis, that would be a good thing. I think it’s worth remembering that, whatever restrictions are imposed on build.zig, they won’t apply to the module which it produces. The effect on a determined malicious actor is effectively nothing. A build script has a certain surface area, but the attack surface of a module is every line of the source code, and always will be.

That doesn’t make sandboxing a bad idea, it’s probably a good idea, but the advantage I see is exactly in offering opt-in capabilities, so that if a library wants to make a network call, it has to tell me, and I have to give that module permission to do it in my own build script.

Another thing I like about that proposal is that adding a Wasm sandbox to the build system makes it available for executing comptime. If we’re going to get a 50x speedup in single-threaded comptime performance, it will need that, or something very much like it.

The fact that Zig’s comptime mechanism isn’t based on macros is a strength in my opinion. Macros obscure the program, they mean that you literally don’t know what a line of code does without understanding the entire macro infrastructure. This has been a great source of frustration for me in understanding C programs, let alone Lisps. Julia’s macro system, and Rust’s, at least tag macros with a mandatory syntax, and that helps, but it doesn’t change the fundamental dynamic.

Comptime doesn’t work that way. It’s just Zig, at compile time. That’s good. Unfortunately, programmers are able to write runtime-only code which is very hard to understand. Therefore, they can do the same thing at comptime, it simply can’t be prevented. But macros make code inherently hard to understand, because they mean that something is happening which is different from the source code you’re looking at, based on a conceptually separate mechanism of AST manipulation, or for C, a simplistic and error-prone token injection mechanism.

There are two questions here: would allowing mutable file container level state linearize compiling when used pervasively? Yes, that’s inevitable. Would it pessimize the compiler when it isn’t used? I don’t see why it would have to, no. Another question would be: is it actually likely to be a perceptible bottleneck? I doubt this very much.

Compiling, like anything else, is subject to Amdahl’s Law, it isn’t embarrassingly parallel and it can’t be made to be. Improving comptime’s single-threaded performance by a factor of 50 is an achievable goal! That benefits every program which uses comptime. Incremental compilation and file watching should make it more tenable to support per-container state, rather than less.

This is more specific than that. But broadly, I haven’t found Zig to be in the business of keeping me from writing the program I want to write. It does insist that I do certain things correctly, which I appreciate: that helps me not write the program I don’t want to write. I am frequently annoyed by how strict it is about numerics and casting, but only when writing it. When reading or debugging it, I love it. It’s the right trade off.

But the decision has already been made here, so there’s little value in trying to envision alternatives.

1 Like

I agree with you on most, except on this:

If the build.zig is sandboxed you can use it to compile a program that also targets a webassembly sandbox and in that case you can continue the chain of sandboxed execution to run-time. The sandboxing can still have security vulnerabilities, or there can be some kind of side-channel attack that breaks the sandbox, but I think it can at-least get closer towards something that makes it difficult to break it.


I think it opens up more possibilities. Maybe there are desirable use-cases for this container comptime state, I guess my personal bias is that I tend to avoid/dislike mutable state unless I see a good reason to have it, some problems are just way simpler to reason about without mutation, currently I don’t have a use-case where I would want to use it, if I had one, maybe I would want that as a feature.

Mutable container comptime state, somehow reminds me of what some languages call functors, those are usually runtime executed things that then produce a dynamic-runtime-module that gets dynamically linked into the running program. While I see how that could be useful, in the context of Zig I would expect to implement something like this via the build-system and a plugin loader, or a self-built jit / code-loader. I guess these functors are more dynamic then comptime mutable container state would ever be, but it still reminded me of functors, which basically can run run-time code produce code / values and then bake that into dynamically linked modules. (basically requiring that the compiler/jit is part of a runtime)

It has some similarities to comptime, but the implementation is quite different.

2 Likes

This is a reasonable point. What it means is that there are some uses of the build system where it would be the only native code which ever runs, but I’d say the general point remains: the security benefits of sandboxing the build aren’t very compelling.

Variations on this image are very common in infosec circles:

Also, non-trivial builds simply require system resources. If build.zig makes that impossible, then people will just use another build tool, which I consider a less than optimal outcome. Examples abound, I’ll provide two: some builds need to fetch large binary assets, which git is not great at providing, and someone might want to build a <spins wheel> Rust project, so the build needs to call out to cargo and rustc.

But I support the idea, as long as it comes with a capabilities system. There’s a certain amount of busywork involved in that, sure, but it gives control to the builder of dependencies, which is good, and as you point out, Zig can be targeted at a sandboxed environment. If that’s the approach, then the sandbox has a wall around it, and the gate joke above doesn’t actually apply.

Now I’m wondering if the build system could target WASI already, given one of the full-featured runtimes. There’s no obvious reason why not, but the Zig/Wasm intersection isn’t something I’ve explored at all.

TL;DR, if something isn’t a security feature, it’s important not to pretend that it is. But there are other reasons to support the proposal, so I do.

There’s an example of the sort of utility I see in that feature, one which everyone who writes Zig code is constantly using, and is often touted as among the best features of the language.

I’m talking about errors. Here’s how the documentation describes it:

An error set is like an enum. However, each error name across the entire compilation gets assigned an unsigned integer greater than 0. You are allowed to declare the same error name more than once, and if you do, it gets assigned the same integer value.

It’s a… global counter! Oh no!

I’m fairly sure that the outcome is deterministic: if you compile an identical program twice, errors will have the same number. But it’s also somewhat sensitive to details of the program: changes which alter the order in which errors are encountered by the compiler can change the actual numbers, and it would be a mistake to rely on some error always being, like, 3.

If it’s good for Zig, it’s good for Zig’s users. Now, lazy compilation is essential to the language, and that does mean that comptime execution order will always be hard to reason about, because small changes to the program can dramatically affect what gets evaluated first. Right now there’s no documentation at all about how this happens, either, although it’s essential that this will change eventually, because Zig aspires to be a specified language, and this would be a critical part of such a specification.

So that means that if you tried to use global state in a way which relies on details of that execution order, even in the event that this order was fully documented, you’re going to have a bad time, and a buggy program. But there are many applications where this doesn’t matter! Either it’s like errors: say you’re building up an enum from program components, and you don’t care what the integer value of the enum elements are, just that they have a value, or it’s something like a HashMap where the evaluation order literally doesn’t matter, you’ll end up with a functionally identical data structure (structurally HashMaps can differ based on insertion order, but in terms of the interface, they don’t). It’s important that the build itself be deterministic, for reproducibility reasons if nothing else, but on a user level, they won’t care about the specifics of the execution order, because it won’t affect the semantics of their program.

So no, I don’t like the argument that it’s a bad feature because it means your code is buggy and this is all for our own good. That doesn’t mean that removing the current affordances (which are, in fact, buggy in implementation) in order to build the next phase of the system isn’t a justifiable choice, it’s eminently justifiable.

While I doubt that this is so, maybe adding it back will make the compiler very slow, and that will affect every program, not just ones which make use of state which isn’t tightly scoped, and this can’t be fixed, and the juice isn’t worth the squeeze. But it’s a suspicious claim to make about a system which isn’t even implemented yet. Performance, specifically multi-threaded performance, isn’t an obvious thing which can be reliably reasoned about statically. There’s a ton of headroom in improving single-threaded comptime performance, a file watching incremental build system opens up all sorts of opportunities to cache comptime state, and so on. I don’t think this should be ruled out a priori.