Zig's Incremental Compilation Internals

(mlugg.co.uk)

97 points | by garyhtou 2 hours ago

6 comments

  • steveklabnik 1 hour ago
    Zig's toolchain work is continually impressive. While I still don't plan to write software in it, given that I believe memory safety is table stakes, all of this stuff is very, very good. Before the incremental work, it was the toolchain and cross-compiler work. The toolchain stuff has continually been fantastic. I'm very curious to see what they come up with next!

    > Semantic analysis is the most difficult part of the compiler to handle incrementally. Perhaps unsurprisingly then, this is where language design starts to matter a lot: while I am pretty confident that most modern languages could support incremental compilation similar to how we do, certain design decisions can make that much more difficult. Zig has had its design tweaked over the years (sometimes controversially) specifically so that it is easier to support fast incremental compilation.

    This is something I wish that we had done with Rust. It is impossible to do all of the things at once, though, and we already had a tremendous amount of things to do. This is also part of the "when do you ship 1.0" tradeoff; for our goals with the language, 2015 was the right moment to launch, but if had a few more years to bake things, maybe we could have made compile times way faster. Software engineering is hard.

    • pron 1 hour ago
      > I believe memory safety is table stakes

      I'm not sure what that means. Java lets you do many things programs may want to do in a memory-safe way but not everything. Rust lets you do fewer things than Java in a memory-safe way, but more things than Zig. Zig lets you do fewer things in a memory-safe way than Rust, but more than C. So among these four languages we already have four levels of memory safety, none of them is 100%, all of them give up something in exchange for what they offer, and different programmers have different preferences for the compromise they prefer, and even that preference is context-dependent. Which of those less-than-100% memory safety compromises is the table stakes? And given that all of these compromises require something that could be quite substantial, depending on the circumstance, in exchange, and consequently programmers with the highest level of knowledge and expertise choose every one of those four in different situations, to me it seems pretty obvious that none of these is "table stakes".

      • steveklabnik 1 hour ago
        I'll say the same thing I said to you as I said to Andrew, last time he and I talked about this: the way that everyone talks about memory safety (with maybe two exceptions, one okay (go) and one I dislike (fil-c)) is that "memory safe language" is about there being a clear delineation between what is memory safe and what is not, and that the unsafe aspect is a superset. Rust and Java both are memory safe, except where explicitly demarcated as not (unsafe in Rust, JNI or sun.misc.unsafe or whatever in Java). Zig and C have no such separation. When I (and others) talk about wanting memory safety, this is the important aspect of the design. This is what enables the "I know statically that a large part of the code is safe, and I also know where to check if something goes wrong" aspect of things.
        • mi_lk 50 minutes ago
          Would you mind sharing some thoughts about fil-c? AFAICT its claims mostly check out so besides implementation details (GC?) it seems directionally good.
          • steveklabnik 47 minutes ago
            I'll come back and say some more in a bit, but in short: I like the idea of fil-c, but I also have some issues. I think it's overall a good and interesting project, but with some caveats.
          • norman784 40 minutes ago
            Not OP, but AFAIK one big issue with FIL-C it does the checks at runtime, adding overhead, don't quote me on this, but IIRC is around 20% slower.
          • steveklabnik 6 minutes ago
            Okay so: in general, as a rule of thumb: anything that makes stuff have more memory safety is good. And experiments towards that end are also good.

            What I do not like, primarily comes down to how the project is talked about and marketed. First, because it promotes an "us vs them" mindset, instead of a "we're all trying to improve memory safety" mindset, and second, because in doing so, it also overstates its case.

            These things are sort of intertwined. Let's talk about the overstatement first. Fil-c has its own definition of memory safety that is slightly different than others. For example, I saw this recently:

                #include <stdlib.h>
                #include <stdio.h>
                #include <string.h>
                
                struct User {
                    char name[8];
                    int is_root;
                };
                
                int main(int argc, char*argv[]) {
                    struct User* user = malloc(sizeof(struct User));
                    strcpy(user->name, argv[1]);
                    if (user->is_root) {
                        printf("I am root!\n");
                    } else {
                        printf("I am not root :(\n");
                    }
                    return 0;
                }
            
            This, when invoked with "012345678" passed in, will print "I am root!". In my understanding, this is deliberately allowed.

            But beyond corners like this, fil-c's author will go on about "Rust has unsafe as a hatch, fil-c does not" while if you control-f for "zunsafe_" on https://fil-c.org/stdfil you get ... escape hatches.

            The author regularly erases the difference between "traps at runtime" and "is prevented at compile time", which are legitimate tradeoffs where one or the other may be better depending on what you're doing. But they're presented as either equivalent, or one is superior, and I find this muddles the discourse. The performance issues also tie into this, "add a GC" is absolutely a valid way to handle these sorts of issues, but it is not the same thing as what Rust does. And that's okay! But presenting it as purely superior means that it's just hard to talk about.

            Speaking of muddling the discourse, the author regularly trolls on X, providing tons of bad faith arguments and generally trying to rile up a "fil-c vs Rust" war that I think reduces our ability to talk about these differences in a calm, engineering focused context.

            Finally, due to its design, fil-c is effectively Linux only. That's great for Linux, but many people also use other systems, and so it is not a meaningful option for them.

            Anyway, after saying all that: I still think that it is a good project, and that it should exist and continue to be worked on. I just wish that the heat was turned down, and people could talk about the various approaches and their tradeoffs without turning it into a culture war.

            • pron 1 minute ago
              > it promotes an "us vs them" mindset, instead of a "we're all trying to improve memory safety" mindset

              But you did the exact same thing when, on the spectrum that ranges from C to ATS, with Zig, Rust, and Java somewhere in the middle (though all closer to C than to ATS), you declared the exact compromise that Rust makes "table stakes"!

              (Now, you may argue that you're only talking about "memory safety" and not correctness in general, but what gives memory safety value is that violations are causes of many dangerous vulnerabilities; but once, say, Java eliminates all of them, 100% of bugs/vulnerability - which are still numerous - will be caused by other problems, all potentially avoidable with ATS, so why isn't ATS table stakes? Of course, the answer is cost, but all the languages on the spectrum differ in their costs.)

        • pron 44 minutes ago
          So that could be a clear definition, but for it to be "table stakes" it needs to have some universal value and it doesn't (in fact, that very same definition could also classify even C as "memory safe"): https://news.ycombinator.com/item?id=49087458
          • steveklabnik 4 minutes ago
            I can say that something is "table stakes" for me without demanding that everyone else adhere to my values.
      • saghm 1 hour ago
        > I'm not sure what that means. Java lets you do many things in a memory-safe way but not everything. Rust lets you do fewer things than Java in a memory-safe way, but more things than Zig.

        I don't think I agree with this framing. The question to me isn't "what can you do while being memory safe", it's "can you accidentally do something memory unsafe without noticing?" Rust and Java are the same here; you need to explicitly opt into using the language's mechanism for relaxing restrictions (Rust's `unsafe` blocks, Java's `Unsafe` class APIs), whereas from what I understand, neither Zig or C offers anything strict in that way.

        • pron 46 minutes ago
          That framing may seem intellectually satisfying, but it's not useful in practice. Consider the extreme edge case of C: We can clearly mechanically delineate between the empty program and a non-empty one, we call the empty program safe and any program that isn't empty unsafe (i.e. C is memory-safe if you want to do nothing and not if you want to do anything). And so, we also have this property that in C you can't do anything unsafe without noticing.

          Now, that's ridiculous, but something not too different happens to me with Rust. I reach for a low-level language when I want to do low-level things in a more convenient way than in Java, but the very things that would make me reach for a low-level language in the first place are unsafe in Rust. So in ~100% of the programs I want to write in a low-level language, Rust and Zig offer the same level of memory safety (but I need to pay a higher price for Rust). That Rust reminds me that what I want to do is unsafe doesn't help me.

          Of course, other people may want to reach for a low-level language in other situations and their perspective could be different, but if I pay the price and get little in return I can't see how that would be "table stakes". Table stakes imply some universality that is obviously not here.

          • saghm 26 minutes ago
            > That framing may seem intellectually satisfying, but it's not useful in practice.

            Honestly, that's exactly how I feel in reverse. The framing you gave is more intellectually interesting, but it doesn't help explain the actual real-world outcomes where in practice, Rust and Java both don't have much problem with unsafety, whereas C does, and at least from what I've heard, Zig does as well.

            > I reach for a low-level language when I want to do low-level things in a more convenient way than in Java, but the very things that would make me reach for a low-level language in the first place are unsafe in Rust. So in ~100% of the programs I want to write in a low-level language, Rust and Zig offer the same level of memory safety (but I need to pay a higher price for Rust). That Rust reminds me that what I want to do is unsafe doesn't help me.

            I mean, sure, if you want to do things that are fundamentally not possible to validate because you think you're smart enough not to screw up, that's going to make Rust a tough sell. My issue with it is that history has shown that the best C and C++ programmers in the world still write code where memory safety rears its head, so I'm distrustful of the claim that being smart and diligent is enough to prevent the sort of bugs that we're still dealing with after half a century of us learning how not to write C. You need to have an excess of either talent or hubris to consider that a reasonably safe path, and given that the amount of talent needed is a lot higher than the amount of hubris, it seems way more likely that it's the latter.

            The alternative is just learning how to write code that doesn't require expressing things in a way that can't be validated. While there are some things that fundamentally are not possible to, I'm dubious that it's anywhere close to as high as you seem to expect if your experience is that you literally can't reduce the amount of unsafe code you need in Rust below "literally my entire program is unsafe".

            • pron 6 minutes ago
              > Rust and Java both don't have much problem with unsafety, whereas C does, and at least from what I've heard, Zig does as well.

              I'm not interested in the definition so much as I am in calling it "table stakes", and so the fact that these languages satisfy their promises is uninteresting in isolation. What matters is the value of their promises. The majority of Rust programs I see, I wouldn't have written in a low-level language, so the fact that it offers memory safety for the things I don't need it to do does nothing for me.

              Now, clearly, Rust's originators didn't consider what Java offers (or at least what it offered 20 years ago when Rust was first conceived) to be table stakes or they wouldn't have wanted Rust. Java exacted some price in exchange for its memory safety that was unacceptable to Rust's originators and trumped its memory safety. But the same thing happens with Rust vs Zig. Rust exacts a heavy price for its memory safety, that - just as in Rust's case vs Java - is sometimes unacceptable. So I can't see how any of these could be "table stakes".

              > I mean, sure, if you want to do things that are fundamentally not possible to validate because you think you're smart enough not to screw up, that's going to make Rust a tough sell.

              What Rust can validate and what can fundamentally be validated are two very, very different things. Compared to what ATS can validate, what Rust can validate is almost indistinguishable from C. In Rust you have to do lots and lots of things that require you to be "smart enough not to screw up" that you could prove in ATS, and still no one (including Rust programmers) would say that what ATS offers is "table stakes" because, obviously, it comes at a high price that the people who choose Rust don't want to pay.

              So clearly different languages offer different capabilities and charge a price for them. Sometimes the price is worth it and sometimes it isn't.

              > so I'm distrustful of the claim that being smart and diligent is enough to prevent the sort of bugs that we're still dealing with after half a century of us learning how not to write C

              But Java or Rust programs still suffer from a lot of bugs that ATS could eliminate, if you're willing to pay the price, and you're clearly unwilling. ATS programmers could say about Rust programmers what you say about C++ programmers. Clearly there's no universal table stakes here.

      • vlovich123 58 minutes ago
        Java and Rust have actually very similar memory safety profiles. Rust let's you within the language escape the memory safety requirements whereas Java does not but both are considered memory safe languages. Rust also enforces thread safety as well which Java does not, but the slower JVM memory model doesn't let race conditions become memory safety issues whereas Rust is lower-level like Zig/C/C++ and thus thread safety could be a memory safety issue.

        Zig has an identical memory safety profile to C. It has facilities to make it easier to stay memory safe, but those facilities are basically equivalent to what you have in C++ and that's equivalent memory safety profile as C.

        > So among these four languages we already have four levels of memory safety, none of them is 100%

        No, you've pretended like there's four when really it's Java / Rust which are safe by default and Zig/C/C++ which are unsafe by default.

        One effective metric to evaluate is memory safety per LoC. Rust is ~0.2 vulnerabilities per MLoC. Java is effectively 0. C and C++ both seem to be about 1,000 vulnerabilities per MLoC. Zig is too new and hasn't had any analysis done on it, but generously it's likely at least 10-100.

        So the table stakes could be defined as 1 memory safety vulnerability per MLoC.

        • pron 41 minutes ago
          > Java and Rust have actually very similar memory safety profiles

          They really don't. Look at how many basic data structures (in the standard library or outside it) require unsafe features in Java vs Rust.

          > Zig has an identical memory safety profile to C

          It really doesn't. Zig gives you the same spatial memory safety as Rust and very much not like C (and violations of spatial memory safety are a bigger cause of vulnerabilities than violations of temporal memory safety).

        • xedrac 38 minutes ago
          Java may be memory safe, but no memory is safe from the JVM. :)
        • applfanboysbgon 54 minutes ago
          > Rust is ~0.2 vulnerabilities per MLoC. Java is effectively 0. C and C++ both seem to be about 1,000 vulnerabilities per MLoC. Zig is too new and hasn't had any analysis done on it, but generously it's likely at least 10-100.

          You have just described six orders of magnitude in your attempt to rebut pron pointing out the four languages have four levels of memory safety.

    • nh2 1 hour ago
      10 years ago, I commented on the Rust issue for "Incremental recompilation", where it was suggested that Rust could at least adopt Haskell GHC's model of incrementality, which is currently file-level:

      https://github.com/rust-lang/rust/issues/2369#issuecomment-1...

      This would already help a lot.

      I recommend anybody who's interested in incremental recompilation to read what GHC does, because the effort to achieve that is relatively low.

      Of course there's always desire for more:

      GHC currently needs to parse+typecheck+codegen a file before it can process other files that import it. Codegen is slow. Thus, there's currently demand split compilation into "stages", so that the next file can be typechecked after its imports have been just typechecked (not codegenned).

      I would also enjoy if recompilation avoidance were to happen at the function level, not the file level.

      Macro systems are a key language feature that can destroy incremental recompilation. In theory, Haskell is well set up for that, as its macro system (TemplateHaskell) is fully AST based and _theoretically_ could distinguish "fully pure" macros from side-effectful macros (such as splicing the current git commit in as a string literal). But the recompilation avoidance system does not currently exploit such differences.

      • steveklabnik 1 hour ago
        Just to be clear about it, Rust today does do some amount of incremental compilation, and there is more work being done to continue to make it moreso. It's just very difficult to re-architect such a large and heavily used codebase. People are putting in heroic amounts of effort to improve things.

        An example that's being funded right now: https://rust-lang.github.io/rust-project-goals/2026/expansio...

      • amelius 1 hour ago
        Can't we have a system where we trade some performance for quick incremental compilation?

        We can always compile with full optimization just before shipping?

        • steveklabnik 1 hour ago
          The article gestures at (and the author has made a comment in this thread about) how this is the case for Zig. You are right that there is tension here, and so that's exactly what you do: accept less performance for the gains in incremental, and then don't do incremental for final builds. It's a fine way to go about it, assuming that the lack of performance doesn't make the program unusuable.
        • pjmlp 1 hour ago
          Of course we can, C++ even REPL and hot reloading tools.

          The main issue is that so far such tools haven't been a priority for Rust.

    • hoppp 1 hour ago
      The thing with rust is that you get safety with slow compilation, it's a tradeoff.

      Zig doesn't have the same safety guarantees, it's on the dev to use safe coding patterns, so the tradeoff for safety is discipline or experience.

      • steveklabnik 1 hour ago
        Rust's safety checks have basically nothing to do with its slow compile times. This is something that sounds intuitive but is just completely incorrect.

        In particular, Rust made several good design decisions around this stuff that keeps those checks fast, like keeping checks local rather than being global.

        • jamiejquinn 1 hour ago
          That's interesting. Coming from C++ and Zig, the massive time "wasters" are metaprogramming features, i.e. Templates and comptime. Are Rust's macros the compile-time culprits?
          • norman784 35 minutes ago
            The issue with Rust proc macros is that they are impure, so even with incremental compilation, you need to expand them every time.
          • steveklabnik 1 hour ago
            Macros can be, but in part because they can produce new items (top level declarations, to sort of make the same handwave as the article does) and so that means you have to do macro expansion and stuff before you can even start to check some things, and similar issues. See the link I posted above for some details on a related issue.

            There's also stuff around name resolution.

            Proc macros are just an inherently very slow way to do what they do.

            Because Rust commits to the traditional compilation model and pipleine (which I think is overall a good thing, or at least, a good thing to support), it does a lot of work that will eventually be thrown away. Consider this example: I have a library with a function foo that returns a simple 42. I have a binary which calls foo from that library and prints the result. Now imagine the library is a hundred thousand lines of unrelated code to what the binary needs, but is useful for other people. Because compilation works in the "produce libraries, produce binary, link them all together" style model, you have to compile the entire library with all of that code, when all you need is really one function. That intermediate work is useful, and I'm picking an example that's deliberately extreme, of course.

            There's a bunch of stuff like this, and I do not have time to really say more than that right now. But yeah, monomorphized generics also produce a lot of compile time pressure too, in various ways.

            Anyway I just also want to reiterate a few things: first of all, all of these decisions were made for good reasons, and there are pros to what Rust does and why. It's just that compile times suffer because of it. What I wish was that we had taken compile times into more consideration when deciding what to do and why in a more serious way. The same decisions might have been made, but at least it would have been known, rather than the situation now, where there's just a tremendous amount of work to try to optimize what exists, rather than having the freedom to maybe tweak some things to make that job way easier.

            • saghm 59 minutes ago
              > Because Rust commits to the traditional compilation model and pipleine (which I think is overall a good thing, or at least, a good thing to support), it does a lot of work that will eventually be thrown away. Consider this example: I have a library with a function foo that returns a simple 42. I have a binary which calls foo from that library and prints the result. Now imagine the library is a hundred thousand lines of unrelated code to what the binary needs, but is useful for other people. Because compilation works in the "produce libraries, produce binary, link them all together" style model, you have to compile the entire library with all of that code, when all you need is really one function. That intermediate work is useful, and I'm picking an example that's deliberately extreme, of course.

              I feel extremely validated reading this! I've had a pet theory for a while that compile times would be drastically reduced if every single item in a crate were implicitly under a cargo feature flag and then they only got enabled if they were imported (and used in non-dead code). I know that making something like that work isn't anywhere close to as simple as I'm describing it, and there are probably a million edge cases, but I've long felt that the ergonomics of Cargo features basically making it too annoying to expose everything conditionally (and then transitively expose all of the features from all of the direct dependencies as well so that things depending on your library could also only conditionally enable them) is secretly the reason that people think Rust compile times are slow, and seeing someone who has way more direct knowledge than me of how all of it works under the hood give a similar take makes me more confident that I might have been on to something all along.

              • steveklabnik 56 minutes ago
                Yes, this idea is sort of similar, just like, more complicated in a sense than the clean design, which is what Zig does.

                Incidentally, you might want to look at gc-sections, which Rust already does. As well as https://rust-lang.github.io/rust-project-goals/2025h2/relink... which is kinda related.

                The sort of key here is understanding that "produce a library" means that every public item is "used" in the sense of "do we need to compile it." The real trick is to do demand-driven compilation starting from the actual final program's needs, and this is inherently at odds with the idea of producing standalone libraries and combining them into the final artifact.

                • saghm 32 minutes ago
                  Makes sense! I think what's most surprising to me about the library compilation model is that for the most part, it doesn't seem like it's actually that useful to how Rust does things by default. Without something like sccache, the intermediate libraries aren't going to be reused across all of my projects, so unless I'm compiling multiple binaries in the same project, I'm not really getting much out of having the entire library sitting there in my target directory rather than just the subset that I'm using. I'm guessing this is related to what you mean by potentially being able to do something differently if there were more time before 1.0?
                • skavi 18 minutes ago
                  > The real trick is to do demand-driven compilation starting from the actual final program's needs

                  this is not so far off from hint-mostly-unused, no?

          • pjmlp 1 hour ago
            Yet that doesn't prevent C++ to have REPL and hot reloading tools, use binary libraries instead of compiling the world from scratch, all of which allow for a much better experience.
      • stock_toaster 14 minutes ago
        (tongue in cheek) It seems recent history has shown that zig can get you to working software faster, then you can port it to rust once you are acquired or find market fit?

        Maybe at some point in the future zig could add a rust compilation target ( like with `-ofmt=c` )...

  • thefaux 12 minutes ago
    There is something that I don't fully understand about this design: why are they insisting on building a giant binary for debug builds that contains all of the code? From my perspective, a simpler approach is to generate many smaller shared libraries (perhaps at the file level) and link them in to the final binary. With this approach, the program binary would have a tiny text section and a (potentially long) list of shared libraries to load. But even with thousands of shared libraries to load, the resulting program binary would not be all that long and there would be no need for binary patching.

    I understand that for a release mode a single giant binary may be desirable, but I am struggling to understand this design for debug builds. Moreover, while reading this article, I found myself wondering what happens if the main binary becomes corrupted. Maybe the user cancels compilation with ctrl+c while it is patching the binary. Even if they have a story for avoiding and/or detecting corruption, it is simpler to not patch in the first place and always generate a new main binary. Again, this is reasonable because the new binary is mainly just a list of shared libraries to link which will not take up much space and can be written to disk quickly. Moreover, this process can be done recursively, e.g. at the subdirectory level, so that during incremental linking a few quite small shared libraries may be produced rather than patching in the new code and writing cascading relocations.

  • hoppp 2 hours ago
    The zig compiler can compile C so will it work with C also?
    • bpavuk 1 hour ago
      not quite! if your project is mixed C/Zig, then editing Zig would work but editing C would not. the Zig compiler caches Zig AIR but no information from C. plus, the C compilation can only really be done with LLVM and writing a self-hosted backend would be PITA for C
      • dundarious 39 minutes ago
        Not quite true, there is already a capable C compiler written in Zig (Aro/arocc), and a plan to transition to it for C compilation: https://codeberg.org/ziglang/translate-c

        Using this native (written in Zig) C compiler to translate C source into Zig source as a part of the build, would presumably lend itself trivially to all the incremental logic in TFA, as updating C would update the generated Zig, and the incremental logic would detect differences just like it detects differences made by a human in an editor. Maybe there are aspects of the generated Zig that would complicate that somewhat, but I don't know -- just a warning about my ignorance.

        This is part of plans to remove the hard LLVM dependency. AFAIK, the LLVM dependency will still be a variant many will use for the convenience of Zig as a much better clang, but removing the hard dependency is part of enabling all these great features like incremental compilation.

  • remywang 2 hours ago
    Does this work for release builds or just debug builds now?
    • mlugg 1 hour ago
      It only works with our self-hosted code generation backends (the main one being for x86_64), which right now don't have any optimisation passes. It's planned that they will in future, but that's a long-term goal. Also, any optimisations which propagate information between functions (the most obvious and important one is inlining) are more-or-less incompatible with incremental compilation (I touch on this in the post IIRC), so once this does work it'll probably still be limited to a subset of optimisations.

      TL;DR: only debug builds for now, could extend to release builds once we have our own optimisation passes one day, but some optimisations will still be inapplicable.

    • minraws 2 hours ago
      I don't think it will work with llvm/release yet but it might some day maybe.

      Getting incremental linker to work with llvm is kinda hard atm. Maybe the zig team has a plan dunno.

  • applfanboysbgon 1 hour ago
    It disappoints me how unseriously the industry has taken compilation speed for so long. I'm glad to see Zig doing incredibly valuable, high-impact work here.
    • deepsun 24 minutes ago
      Well, I remember back in the day Java was winning a lot of love from C++ devs over its much-much faster compilation than C++. It was important, even 15+ years ago. People praised faster iteration time while coding.
    • pjmlp 59 minutes ago
      Yes, we get people amazed to rediscover Modula-2, Object Pascal compiler execution speed.
    • logicchains 1 hour ago
      Golang's reason for existence is pretty much compilation speed.
  • khanhnguyen8386 1 hour ago
    [flagged]