25 comments

[ 3.6 ms ] story [ 39.3 ms ] thread
Does this work for release builds or just debug builds now?
The zig compiler can compile C so will it work with C also?
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.
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.

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.

IME dynamic libraries ever only add more problems on the pile but never solve anything (they're basically only good for plugin systems, or as userland operating system interface).

Shared libraries work slightly differently on each operating system, and at least on Windows and macOS, having to load thousands of tiny shared libraries will almost definitely cause your startup time to explode. I once wrote an 'OOP system for C' where each class lives in its own DLL, and let's just say that this was probably the most stupid idea I ever came up with, and I came up with a lot of stupid ideas in my life ;)

Zig uses a "single compilation unit" compilation model, so even if we did what you're suggesting, pretty much everything in the post would still be relevant, especially the whole-file stuff and the semantic analysis stuff. Note that there's not really anything special about putting things in separate source files in Zig---files are just a unit of code organization, like namespaces. They don't help the compiler at all (aside from in that very first part of compilation, but that's fast regardless).

The only thing I discussed that we could potentially avoid using your suggested strategy is incremental linking, but AFAICT that would only be easily avoidable if we made a separate shared object for each individual function. I guess we could group them arbitrarily and then re-run codegen for everything in one shared library when the other parts change, but that frankly doesn't sound much less awkward than incremental linking!

But putting all that aside: the only thing this approach would really achieve is offloading the linking work from the static linker to the dynamic linker (aka runtime linker). So if it did make compilation faster, I'd expect all of the saved time to just become runtime execution time---which is kind of worse, because the average number of times you run a compiled program is probably >1!

Of course, there's also the obvious point that dynamic linking only works in cases where you can run dynamic executables. For instance, that approach wouldn't work when doing operating system development, while our approach should work completely fine for that use case.

Oh, I forgot to respond to the "corrupted binary" thing in my other reply, sorry.

Right now, yep, corrupting the binary that the compiler reads would crash the compiler. In future, we want to detect the corruption and force a clean build. Note however that the Zig compiler is writing the binary to its internal cache directory (typically `.zig-cache/`), and the build system then copies the final artifact to your output ("prefix") directory (`zig-out/` by default), so it doesn't matter if the user messes with the final binary in `zig-out/bin/my_program`, because that's just a copy. Lastly, this is not implemented yet, but we will definitely make sure that Ctrl+C leave the cache in a clean state. I'm pretty sure our incremental compilation system has a nice property that you can just cancel an update partway through and continue it later without too much effort. See also Zig's IO interface [0] for information about cancelation. The compiler should already support graceful-ish cancelation internally (I won't claim to have verified this, because we never actually do cancel compilation right now, but I'm not aware of any glaring issues!). I don't think it'd be a crazy amount of work to improve that so that it also leaves incremental compilation in a valid state.

[0]: https://kristoff.it/blog/zig-new-async-io/

I just looked up a Hello World program from the Zig Wikipedia article:

    const std = @import("std");
    const File = std.Io.File;
    
    pub fn main(init: std.process.Init) !void {
        _ = try File.stdout().writeStreamingAll(init.io, "Hello, World!\n");
    }
That's a lot to follow, just to output a plan-text message, especially after this line: "The primary goal of Zig is to be a better solution to the sorts of tasks that are currently solved with C. A primary concern in that respect is readability…"
That's the 'official' hello world which is indeed a bit verbose (it's "correct" in the way that it generally shows how to stream formatted text to stdout though).

Arguably this is the more beginner friendly version, this prints to stderr though:

hello.zig:

    const print = @import("std").debug.print;

    pub fn main() void {
        print("Hello World!\n", .{});
    }
...and then

    zig run hello.zig
I've always thought this was fascinating, but the only incremental compilation I knew was obscure programming languages and Rust. Oh yeah, I guess Roslyn?

Really fun and fascinating problem to work on.

> Dependencies on the body of a runtime function are impossible (at least in the simplified view I’m presenting here)

How does this work given that e.g. a constant can be computed by a comptime function?

The incremental linking part sounds pretty hackish to me and I wonder what the price is in increased code complexity and maintenance effort. It also does not mention how it deals with the patching of debug information.
This post is really interesting. As a member of the rust-analyzer team, I cannot avoid comparing it to the situation in Rust land. Rust famously has not less (or even more) sophisticated system for incremental compilation, yet its compilation is way slower. I attribute that to two main things:

- Language design. Zig was designed for fast and incremental compilation, Rust is just not. For instance, the post states that Zig has four properties (layout, type, value, body) that the compiler has to track for changes. Rust has much more, to the point that tracking them statically is just impossible, so the compiler uses a query system that tracks them dynamically, which adds overhead.

- Compiler implementation. Rust is much more complicated to compile than Zig, and rustc is both older and bigger (10x-20x LOC) than the Zig compiler, making changing it way harder.

Isn't most of Rust's compilation overhead from the llvm backend?
I'm becoming a big fan of Zig every since learning about `zig cc` as a way of dipping my toes in it. I was already impressed by the build caching so I'm keen to play with this