Zig's self-hosted x86 backend is now default in Debug mode
112 points by alichraghi
112 points by alichraghi
Something I realized the other day: when Zig language becomes stable, it will be an incredible compilation target (i.e., compiling your hobby language to Zig).
Zigs toolchain is absolutely incredible, and is better than the ones of post 1.0 languages. Zig’s low-level, so you can express more or less any machine code pattern you want, and you can use ReleaseFast if safety checks are handled by the type system. Zig’s also very expressive, so the generated code should be less of a mess. Finally, Zig should be a simple-to-implement language (more complex than C, but significantly simpler than C++ or Rust), so the resulting code should be rather portable in terms of alternative implementations!
One of my pipe dreams is to build a modern ML on top of Zig with first-class cross-compilation/platform support. Kind of like the language I thought Rust was going to be, and neither OCaml nor F# fit this.
The cross compilation stuff is pretty cool. Just a couple days ago, someone in the OpenD chatroom suggested using zig to cross compile for raspberry pis instead of messing with copying glibc and friends. See, in theory, the llvm linker can target almost anything from almost anything, but in practice, you need copies of the libraries for it to succeed and that’s a bit of a pain to set up. (Except for Windows, as is often the case, developing for Windows is easy. It is pretty much a solved problem for a stable target.)
But the zig people have a clever solution for this, and it seems - i literally just tested it yesterday so i shouldn’t be too confident yet, but it seems - to actually work pretty well! (except for arm32 which it has an open bug since 2020 so i don’t expect it to work, but arm64 seems to be very nice) So might make opend call zig when it is time to cross link…
See, in theory, the llvm linker can target almost anything from almost anything, but in practice, you need copies of the libraries for it to succeed and that’s a bit of a pain to set up.
Cross compiling hasn’t been hard for a long time, it’s literally one extra flag in clang (–sysroot=).
You need a sysroot: The contents of include and lib directories (if you’re writing D, maybe you don’t even need the include directories). For shared libraries you can strip almost everything except the dynamic symbol tables. For static libraries you need the actual libraries.
For FreeBSD, you can just untar base.txz and any packages you want. For most Linux distros, you can do the same (RPMs are cpio archives not tarballs, I can’t remember what .debs are). The base system is always the easy bit, the hard bit is depending on random packages on the target system, but all you need is the sysroot.
This has never been technically difficult since clang and lld existed. The tricky parts are legal. This doesn’t matter for F/OSS systems but the macOS SDKs come with an EULA that explicitly prohibits using them on anything other than an Apple system. Zig gets around this by building a minimal Darwin sysroot from the open-source bits but this lacks all of the Apple-proprietary things. You can cross compile for Darwin easily but if you want to link, say, the Cocoa libraries then there isn’t a legal way of doing this on a non-Apple system (probably. You might be able to argue that the symbols from their .dylibs are not copyrightable, and if you want to argue with Apple’s lawyers in court, go ahead).
The really tricky bits of cross compiling come from build systems that don’t separate build and host tools and try to collect information about the host system when configuring. That isn’t something a toolchain can fix, you need to patch the build system.
it’s literally one extra flag
….then you go on to describe a the extra required setup step, so no, it is not literally one extra flag. (setting up a sysroot is what i meant by “copying glibc and friends”, it isn’t that hard, but it is an extra step and not always obvious what you actually need to do unless you’ve done it before or have a script from someone else to run)
Zig, on the other hand, pretty much does make it literally one extra flag though, since it will automatically make a sysroot with enough stubs to make it work on-demand, with a (relatively speaking) small download you get once then it just works. There’s details of their approach on the same link as the OP, just scroll down a bit: https://ziglang.org/devlog/2025/?unique/#2025-05-20
If you’ve never tried it, you might want to, the description doesn’t do justice to the subjective feeling. (BTW two of the names I’ve seen doing this in Zig were former D contributors too, and some of their work on D I felt was among the most impactful for the same reason I appreciate what they’re doing in zig - removing user friction.)
Like I said, I only tried it for the first time yesterday, but I was legitimately impressed. Every end user step you can elide makes it feel nicer and nicer.
The tricky parts are legal
Yes, indeed, I looked at this cross compilation thing for Mac and it was like “read the EULA carefully before using this” and the EULA explicitly and clearly states that using this is a violation… and even offering it is a violation. so lol.
What’s interesting about the Objective-C stuff is that it does a lot of late binding. In the code, you can get away with pretty minimal shims; working without their headers is not hard at all (though of course it is a setup step so some friction but it isn’t bad). But yeah, actually generating the working executable means linking it and idk how to do that without Apple’s libs, which means Apple’s license, which means Apple’s hardware. Ugh. I expect there is something similar to mingw’s public domain clean reimpl Windows’ import libs that is possible in principle but as far as I’ve seen, nobody has actually done it.
Just reading the post you link:
We also ship FreeBSD/NetBSD system and libc headers with the Zig compiler. Unlike the stub libraries we produce, however, we always import headers from the latest version of the OS.
FreeBSD always has at least two supported major versions (and often two supported minor releases within each). The headers from the latest one will ship headers that contain symbols that are not present on the older one. If you compile for 14.3 (released today), depending on the headers you use, you are likely to have binaries that don’t work on 14.3, 13.5, and 13.4 (all supported releases).
This is why I don’t like the Zig approach. It has usability cliffs that you avoid if you do a fairly simple step yourself. tools that work well for the simple case and then stop working at a point in the complexity gradient are, to me, far worse than tools that are slightly harder for the simple case but then continue to work at any level of complexity.
To get a working FreeBSD sysroot for any FreeBSD release that contains all of the things that zig’s provides (but actually works for that release), just untar base.txz
from the version you want and point clang at it. It’s literally one fetch command, one tar command, and then one extra flag on the build system. If you want to download additional packages, that’s more work, but no matter where you get the other headers and libraries from, the sysroot is under your complete control and you can update, version, and create it based on your requirements.
Zig, on the other hand, pretty much does make it literally one extra flag though, since it will automatically make a sysroot with enough stubs to make it work on-demand
But only for libc, so if you have any other dependencies you still need to set up a sysroot by hand.
I wouldn’t say setting up a sysroot is needed nor recommended. The Zig way is what David Chisnall alluded to earlier: porting the build script to Zig Build System so that it no longer depends on the host at all.
For example, there is the “All Your Codebase” collaborative effort (see the readme there for more details).
It takes some work to wrangle your dependency tree, but in exchange you end up with an extremely reproducible, portable method to build from source, which has exactly one dependency: zig.
Also, to get ahead of the obvious criticism here, it doesn’t prevent e.g. Linux distributions from packaging projects. In fact quite the opposite, the build system has a mechanism to directly recognize “system integrations”. This means projects can help connect the dots between what dependencies can be fetched directly with Zig, and how they can be satisfied alternatively by the host system. There’s a flag that disables all fetching and enables all system integrations, which package maintainers are encouraged to enable. In summary, the Zig Build System recognizes “upstream builds” and “distribution builds” as related, but separate use cases.
Those are a lot easier to manage either because 1) you can live without them (e.g. use D libs instead of system C libs) or 2) you can still dynamic load them with dlopen/dlsym.
My stuff tends to dynamic load most things nowadays, even stuff pretty base to the system like Xlib, just because I can gracefully degrade if it isn’t found, or if the version mismatches, or other similar things, and heck it often builds faster too. You don’t need anything available at build time for that approach.
For Windows there’s xwin which downloads the SDK stuff directly from Microsoft. It might also be questionable in some way, but I don’t think MS ever puts that kind of terms in its EULAs (?)
I discussed this with some folks on the Visual Studio team many years ago. They believed this kind of thing was legal and, in particular, were quite happy that you’d build wherever as long as you were targeting Windows. For Apple platforms it’s a bit different because they really want to encourage developers to live in their ecosystem. Being able to do macOS or iOS development without owning a Mac is a bug, from their perspective. Being able to do Windows development without owning a Windows machine is not a problem for Microsoft as long as you’re writing things that lock people into the Windows world. They’d much rather you write a Win32 app on Linux than having you write a web app on Linux, for example.
arm32 which it has an open bug since 2020 so i don’t expect it to work
Do you have a link to the issue? I’ve used zig cc
to cross compile for 32-bit ARM at work quite a few times without issue. Granted, these were small projects, but they worked without any problems.
Yeah, this one: https://github.com/ziglang/zig/issues/4959 that’s the same issue I had compiling druntime for it (it uses those atomics in its garbage collector implementation). Simpler programs work with it though, but I like my GC.
Ah interesting. I’m compiling for ARMv7-A, not ARMv6-M which the Cortex M0 implements. I suspect that’s why I haven’t run into it.
I agree.
One cool thing is that you could even create a zig library with things in your language. Say your favourite dictionary/hashmap implementation. And instead of codegen outputting how to deal with a hashmap, codegen just references the zig hashmap API. And zig’s comptime/generics would make it fast (specialized to key/value types).
I imagine lots of high level concepts could be implemented this way, making codegen pretty 1:1 with your language’s IR or AST. And lots of the stuff you’d want to use already exist in zig’s stdlib or existing libraries…
It’s not a great target because everything is one compilation unit (which is not great even for directly writing Zig!).
I used to be a huge believer in separate compilation (see, e.g., this post from six years ago).
Working with Zig made me reconsider this stance. I still believe that, in terms of big-O, separate compilation and distributed builds matters eventually (corollary — Swift is the only “serious” language). But I now think that the N where this starts to matter is huge, assuming modern hardware and a compiler that’s engineered for single-CU performance. Separate compilation is cool, but if you need to compile Vec<i32>
afresh for every CU, the emperor has problems of sartorial nature.
Note that you can still do “manual” separate compilation in Zig by linking separately compiled object files via extern
fns.
I still see lots of complaints about Zig build time. Rust also started with whole program compiling by default and eventually gave up on it. Vec<i32>
needing to be instantiated over and over is separately bad, and new langs should try to avoid it.
Zig build times are not great (or, in the light of the announcement, used to be not great), because it was mostly LLVM. I haven’t done comparison with Rust, but, due to generic duplication issue, I’d bet that they still are significantly better than equivalent code in Rust or C++.
And that’s I think the crux of the issue — I think the shape of modern compilation pipeline is mostly defined by the way linkers worked back when we couldn’t load all the code in memory. I think doing from first-principles compilation model can give you massive improvements, and it seems that only Zig has enough hubris to try and do that (that = rewriting the language, the compiler, and the linker).
As long as your code fits on a single machine, not doing separate compilation should be not slower. You can do (zig does) incrementally and parallelism inside a single CU, and, if anything, there should be more opportunities for fine-grained parallelism. On top of that, I believe that a) a lot of code fits on a single machine these days, b) there are actually massive wins beyond “not slower” to be had, including avoiding monomorphisation duplication.
One issue here is that separate compilation in compiler generally requires language-level support for declaring ABI-stable interfaces. So, people writing code in languages “with header files” tend to write code that has fewer inter-dependencies. There’s design pressure to pay attention to physical architecture. This might outweigh benefits of single-CU compilation model. I don’t know, we’ll see how Zig compilation model would evolve. But I am somewhat skeptical that just design pressure would be enough to keep the code fast to compile: few Rust projects I’ve seen pay any attention to physical architecture.
As of now, I am genuinely undecided as to how and when separate compilation is a deal breaker.
It doesn’t seem strictly necessary to rewrite the entire language/compiler/linker to get this compilation model. Rust has talked about doing “MIR-only rlibs” for a long time, as a way to defer and thus deduplicate all the monomorphization to the final binary.
I think the end game here is fine-grained incremental re-linking, such that, if you change 92
to 93
in function body, only one byte is patched in the binary on disk, which I think does require pretty tight integration throughout the stack.
That being said, yes, MIR-only rlibs are an obvious massive improvement, can’t ship them too fast!!
I think that’s all reasonable, but I think pretty much all large commercial software projects grow faster than the number of cores. I’ve never worked at a successful place that didn’t benefit from distributed compilation after 2 years. Most commercial C++ projects will have more than 128 compilation units, and most developers are building on machines that have fewer cores than that.
Rust also started with whole program compiling by default and eventually gave up on it.
This happened so long ago it is not necessarily indicative of current performance.
I am not personally sold on which way I think is better, or the “right” way for a new language to behave here. Sometimes, “Rust tried this” is a good answer, but I don’t think that in this case, it’s a good data point.
AFAIK Zig still does not support async. How do you cope with that, handwritten state machines?
For hypothetical use of zig as compilation target? The CPU doesn’t support async, so the code to do state machine transformation needs to live somewhere anyway.
For using zig in production? TigerBeetle is very asynchronous. We use callbacks (in a language without closures). It works surprisingly fine for our use case, given that we also require static allocation.
Example of simple asynchronous control flow would be reading from disk, where you stitch a read of required size of of the smaller ones:
The most complex control flow would be compacting dispatch, where we manage a resumable, resource bounded graph of interdependent computations:
It feels a little weird to have debug and release builds use a totally different compiler backend. That feels like it’ll be some confusing situations waiting to happen.
Personally, it has caused issues on a project I worked on. We were doing Funky Stuff that it didn’t handle yet; it was a prerelease compiler build, so I didn’t exactly expect stability. However, the solution was easy: just add use_llvm = true
to the build. In the future when this isn’t new, it’ll probably be more insidious of a bug should problems arise. However, the speed improvement is undoubtedly the better option compared to absolute (read: pretty good) stability of llvm codegen.
However, the speed improvement is undoubtedly the better option
Once they’re done with paralellization and incremental compilation it’s going to seem magical. I wonder what % execution speed, if much, they’d sacrifice vs llvm. Even then, you could still run production builds off of llvm with all optimizations if you want… unless they’re planning to take that backend away eventually?
The LLVM backend is not going away. However, it is going to change form at some point. The Zig compiler is able to emit LLVM bitcode without linking libllvm (we essentially wrote a custom implementation of the LLVM Builder API), so one day we will stop linking libllvm into the Zig compiler, in favour of emitting LLVM bitcode from the compiler and having the user run that through llc
separately. But we’re going to make sure the use case is as seamless as possible by providing a package to achieve that neatly in the build system.