HNHacker News
TopNewBestAskShowJobs

mlugg

343 karma · joined August 4, 2023

submissionscomments
mlugg··on Zig's New Async I/O
We don't know whether or not we'll have stackless coroutines; it's possible that we hit design problems we didn't foresee. However, at this moment, the general consensus is that we are interested in pursuing stackless coroutines.

While Andrew has the final say, as Loris points out, we always work to reach a consensus internally. The article lists this an an implementation that will probably exist, because we agree that it probably will; nobody is promising it, because we also agree that it isn't guaranteed.

Also, bear in mind that even if stackless coroutines don't make it into Zig, you can always use a single-threaded blocking implementation of `Io`, so you need not be negatively affected by any potential downsides to fibers either way.

This new `Io` approach has made it strictly more likely than it previously was that stackless coroutines become a part of Zig's final design.

mlugg··on Zig's New Async I/O
I'm not sure how you got the perception that we're going "all in" on green threads, given that the article in OP explicitly mentions that we're hoping to have an implementation based on stackless coroutines, based on this Zig language proposal: https://github.com/ziglang/zig/issues/23446

Performance matters; we're not planning to forget that. If fibers turn out to have unacceptable performance characteristics, then they won't become a widely used implementation. Nothing discussed in this article precludes stackless coroutines from backing the "general purpose" `Io` implementation if that turns out to be the best approach.

mlugg··on Zig's New Async I/O
The key difference to typical async function coloring is that `Io` isn't something you need specifically for asynchronicity; it's something which (unless you make a point to reach into very low-level primitives) you will need in order to perform any IO, including reading a file, sleeping, getting the time, etc. It's also just a value which you can keep wherever you want, rather than a special attribute/property of a function. In practice, these properties solve the coloring problem:

* It's quite rare for a function to unexpectedly gain a dependency on "doing IO" in general. In practice, most of your codebase will have access to an `Io`, and only leaf functions doing pure computation will not need them.

* If a function does start needing to do IO, it almost certainly doesn't need to actually take it as a parameter. As in many languages, it's typical in Zig code to have one type which manages a bunch of core state, and which the whole codebase has easy access to (e.g. in the Zig compiler itself, this is the `Compilation` type). Because of this, despite the perception, Zig code doesn't usually pass (for instance) allocators explicitly all the way down the function call graph! Instead, your "general purpose allocator" is available on that "application state" type, so you can fetch it from essentially wherever. IO will work just the same in practice. So, if you discover that a code path you previously thought was pure actually does need to perform IO, then you don't need to apply some nasty viral change; you just grab `my_thing.io`.

I do agree that in principle, there's still a form of function coloring going on. Arguably, our solution to the problem is just to color every function async-colored (by giving most or all of them access to an `Io`). But it's much like the "coloring" of having to pass `Allocator`s around: it's not a problem in practice, because you'll basically always have easy access to one even if you didn't previously think you'd need it. I think seasoned Zig developers will pretty invariably agree with the statement that explicitly passing `Allocator`s around really does not introduce function coloring annoyances in practice, and I see no reason that `Io` would be particularly different.

mlugg··on Zig breaking change – Initial Writergate
This is simply not true. See https://zigbin.io/f57b94/run.

(That link seems to show the "unused local variable" error line twice for me; that's some kind of bug with this zigbin service and does not reproduce when running the Zig compiler normally.)

mlugg··on Why is the Rust compiler so slow?
When targeting x86_64, the self-hosted backend is already enabled by default on the latest builds of Zig (when compiling in Debug mode). The self-hosted aarch64 backend currently isn't generally usable (so we still default to LLVM when targeting aarch64), but it's likely to be the next ISA we focus on codegen for.
mlugg··on Rust compiler performance
[edited to correct formatting]

Your points here don't really make sense. There are many ways you can apply DoD to a codebase, but by far the main one (both easiest and most important) is to optimize the in-memory layout of long-lived objects. I won't claim to be familiar with the Rust compiler pipeline, but for most compilers, that means you'd have a nice compact representation for a `Token` and `AstNode` (or whatever you call those concepts), but the code between them -- i.e. the parser -- isn't really affected. In other words, all the fancy features you describe -- macros intertwined with name resolution, parsing syntax from other languages, high-quality diagnostics -- don't care about DoD! Our approach in the Zig compiler has evolved over time, but we're slowly converging towards a style where all of the access to the memory-efficient dense representation is abstracted behind functions. So, you write your actual processing (e.g. your parser with all the features you mention) just the same; the only real difference is that when your parser wants to, for instance, get a token (as input) or emit an AST node (as output), it calls functions to do that, and those functions pull out the bytes you need into a lovely `struct` or (in Rust terms) `enum` or whatever the case may be.

Our typical style in Zig, or at least what we tend to do when writing DoD structures nowadays, is to have the function[s] for "reading" that long-lived data (e.g. getting a single token out from a memory-efficient packed representation of "all the tokens") in the implementation of the DoD type, and the functions for "writing" it in the one place that generates that thing. For instance, the parser has functions to deal with writing a "completed" AST node to the efficient representation it's building, and the AST type itself has functions (used by the next phase of the compiler pipeline, in our case a phase called AstGen) to extract data about a single AST node from that efficient representation. That way, barely any code has to actually be aware of the optimized representation being used behind the scenes. As mentioned above, what you end up with is that the actual processing phases look more-or-less identical to how they would without DoD.

FWIW, I don't think the parser is our best code here: it's one of the oldest "DoD-ified" things in the Zig codebase so has some outdated patterns and questionable naming. Personally, I'm partial to `ZonGen`[0] as a fairly good example of a "processing" phase (although I'm admittedly biased!). It inputs an AST and outputs a simple tree IR for a subset of Zig which is analagous to JSON. Then, for an example of code consuming that generated IR, take a look at `print_zoir`[1], which just dumps the tree to stdout (or whatever) for debugging purposes. The interesting logic is in `PrintZon.renderNode` in that file: note how it calls `node.get`, and then just has a nice convenient tagged union (`enum` in Rust terms) value to work with.

[0]: https://github.com/ziglang/zig/blob/dd75e7bcb1fe142f4d60dc2d...

[1]: https://github.com/ziglang/zig/blob/dd75e7bcb1fe142f4d60dc2d...

mlugg··on Self-hosted x86 back end is now default in debug mode
The whole "stage1/2/3" jazz is about our bootstrap process; that is, the way you get a Zig compiler starting from nothing but a C compiler. This is a tricky problem because of the fact that the Zig compiler is written in Zig. The bootstrap is unfortunately quite slow to run, for two main reasons:

* We want the final Zig binary it produces to be optimized using LLVM, and LLVM is incredibly slow.

* The start of the bootstrap chain involves a kinda-weird step where we translate a WASM binary to a gigantic C file which we then build; this takes a while and makes the first Zig compiler in the process ("stage1"/"zig1") particularly slow.

Luckily, you very rarely need to bootstrap!

Most of the time, you can simply download a recent Zig binary from ziglang.org. The only reason the bootstrap process exists is essentially so you can make that tarballs yourself (useful if you want to link against system LLVM, or optimize for your native CPU, or you are a distro package maintainer). You don't actually need to do it to develop the compiler; you just need to get a relatively recent build of Zig to use to build the compiler, and it's fine to grab that from ziglang.org (or a mirror).

Once you have that, it's as simple as `zig build -Dno-lib` in the Zig repository root. The `-Dno-lib` option just prevents the build script from copying the contents of the `lib/` directory into your installation prefix (zig-out by default); that's desirable to avoid when working on the compiler because it's a lot of files so can take a while to copy.

You can also add `-Ddev=x86_64-linux` to build a smaller subset of compiler functionality, speeding up the build more. For the other `-Ddev` options, look at the fields of `Env` in `src/dev.zig`.

mlugg··on Self-hosted x86 back end is now default in debug mode
I believe (although I won't claim to know details) that Rosetta is unable to handle the code our self-hosted backend emit. I don't know whether it's related to the machine code or the Mach-O object, nor do I know in what way it breaks. Feel free to try it out of course!-- my understanding could be wrong.
mlugg··on Self-hosted x86 back end is now default in debug mode
The other comments get the general idea, but here's a slightly more detailed explanation.

Code generation backends in the Zig compiler work by lowering an SSA-like structured IR called AIR to machine code (or actually first to another intermediate data structure called MIR, but don't worry about that). The thing is, AIR is intentionally quite high-level, the intention being that the code emitting AIR (which is complex and difficult to parallelize) doesn't have to waste loads of valuable single-threaded time turning e.g. a single line of Zig code into tens or hundreds of instructions.

However, this approach sort of just moves this work of "expanding" these high-level operations into low-level instructions, from the code producing AIR, into the codegen backend. That's good for compiler performance (and also actually for avoiding spaghetti in the compiler :P), but it makes it much more difficult to write backends, because you need to implement much more complex lowerings, and for a much greater number of operations.

To solve this problem, `Legalize` is a new system we've introduced to the compiler which effectively performs "rewrites" on AIR. The idea is that if a codegen backend doesn't want to support a certain high-level operation, it can set a flag which tells `Legalize` that before sending the AIR to codegen, it should rewrite all occurences of that instruction to a longer string of simpler instructions. This could hugely simplify the task of writing a backend; we don't have that many legalizations implemented right now, but they could, for instance, convert arithmetic on large integer types (e.g. u256) into multiple operations on a "native" integer size (e.g. u64), significantly decreasing the number of integer sizes which you need to handle in order to get a functional backend. The resulting implementation might not emit as efficient machine code as it otherwise should (generally speaking, manually implementing "expansions" like the one I just mentioned in the backend rather than in `Legalize` will lead to better code because the backend can sort of "plan ahead" better), but you can implement it with way less work. Then, if you want, you can gradually extend the list of operations which the backend actually supports directly lowering, and just turn off the corresponding legalizations; so everything works before and after, but you get better code (and possibly slightly faster compilation) from implementing the operation "directly".

mlugg··on Self-hosted x86 back end is now default in debug mode
I don't understand how you reached this conclusion.

Nothing is decided for sure, but the plan is most likely to re-introduce stackless coroutines as a set of lower-level primitives [0], and support implementing the planned `std.Io` abstraction [1] using them. The usage isn't quite as "pretty" as our old async/await syntax, but this approach sidesteps a lot of design problems, allows applications to seamlessly switching between different async implementations (e.g. stackless vs stackful), and simplifies the eventual language specification.

It's true that we're not doing this work right now, but that doesn't mean async is "never coming back", nor that you'll be waiting "till 2028".

[0]: https://github.com/ziglang/zig/issues/23446 [1]: https://github.com/ziglang/zig/blob/async-await-demo/lib/std...

mlugg··on Self-hosted x86 back end is now default in debug mode
[edited to fix a formatting problem, sorry!]

Well, one interesting number is what happens when you limit the compiler to this feature set:

* Compilation front-end (tokenizing/parsing, IR lowering, semantic analysis)

* Our own ("self-hosted") x86_64 code generator

* Our own ("self-hosted") ELF linker

...so, that's not including the LLVM backend and LLD linker integration, the package manager, `zig build`, etc. Building this subset of the compiler (on the branch which the 15 second figure is from) takes around 9 seconds. So, 6 seconds quicker.

This is essentially a somewhat-educated guess, so it could be EXTREMELY wrong, but of those 6s, I would imagine that around 1-2 are spent on all the other codegen backends and linkers (they aren't too complex and most of them are fairly incomplete), and probably a good 3s or so are from package management, since that pulls in HTTP, TLS, zip+tar, etc. TLS in particular does bring in some of our std.crypto code which sometimes sucks up more compile time than it really should. The remaining few seconds can be attributed to some "everything else" catch-all.

Amusingly, when I did some slightly more in-depth analysis of compiler performance some time ago, I discovered that most of the compiler's time -- at least during semantic analysis -- is spent analyzing different calls to formatted printing (since they're effectively "templated at compile time" in Zig, so the compiler needs to do a non-trivial amount of work for every different-looking call to something like `std.log.info`). That's not actually hugely unreasonable IMO, because formatted printing is a super common operation, but it's an example of an area we could improve on (both in the compiler itself, and in the standard library by simplifying and speeding up `std.fmt`). This is one example of a case where `comptime` execution is a big contributor to compile times.

However, aside from that one caveat of `std.fmt`, I would say that `comptime` slowness isn't a huge deal for many projects. Really, it depends how much they use `comptime`. You can definitely feel the limited speed of `comptime` execution if you use it heavily (e.g. try to parse a big file at `comptime`). However, most codebases are more restrained in their use of `comptime`; it's like a spice, a bit is lovely, but you don't want to overdo it! As with any kind of metaprogramming, overuse of `comptime` can lead to horribly unreadable code, and many major Zig projects have a pretty tasteful approach to using `comptime` in the right places. So for something like the Zig compiler, the speed of `comptime` execution honestly doesn't factor in that much (aside from that `std.fmt` caveat discussed above). `comptime` is very closely tied in to general semantic analysis (things like type checking) in Zig's design, so we can't really draw any kind of clear line, but on the PR I'm taking these measurements against, the threading actually means that even if semantic analysis (i.e. `comptime` execution plus more stuff) were instantaneous, we wouldn't see a ridiculous performance boost, since semantic analysis is now running in parallel to code generation and linking, and those three phases are faiiirly balanced right now in terms of speed.

In general (note that I am biased, since I'm a major contributor to the project!), I find that the Zig compiler is honestly a fair bit faster than people give it credit for. Like, it might sound pretty awful that (even after these improvements), building a "Hello World" takes (for me) around 0.3s -- but unlike C (where libc is precompiled and just needs to be linked, so the C compiler literally has to handle only the `main` you wrote), the Zig compiler is actually freshly building standard library code to handle, for instance, debug info parsing and stack unwinding in the case of a panic (code which is actually sorta complicated!). Right now, you're essentially getting a clean build of these core standard library components every time you build your Zig project (this will be improved upon in the future with incremental compilation). We're still planning to make some huge improvements to compilation speed across the board of course -- as Andrew says, we're really only just getting started with the x86_64 backend -- but I think we've already got something pretty decently fast.

mlugg··on Incremental compilation instantly rebuilds the Zig compiler [video]
The long-term plan is for Zig to expose some form of language server (not LSP) from the compiler itself, so it can use all the information it gets from doing the compilation. Incremental compilation is a key part of this plan, because it means we can get new up-to-date information in milliseconds rather than seconds or minutes when a file changes.
mlugg··on Incremental compilation instantly rebuilds the Zig compiler [video]
The compiler is set up so that this should be impossible! At the start of an "update", we mark parts of the dependency graph as "outdated" and "potentially outdated", based on which source code changed. From there, we repeatedly use a heuristic to find the "best" thing to analyze; each thing we analyze is guaranteed to remove at least one outdated (/potentially outdated) thing, and compilation terminates once nothing is marked outdated.

The way we do this does unfortunately mean that if our heuristic goes very wrong, we can end up doing much more work than is necessary. There are some real nasty cases with self-referential types right now. However, for simple things like changing function bodies, it works lovely!

mlugg··on Getting silly with C, part (void*)2
I'm afraid I'm not sure what you're referring to. For instance, I can build a simple Hello World in Zig using `zig build-exe`, and get a static executable, on which I can use `nm` to confirm that there aren't symbols from any kind of runtime. I can even trivially build the actual Zig compiler to a static binary.

(For context, by the way, I'm on the Zig "core team"; I'm a notable contributor to the project.)

mlugg··on Getting silly with C, part (void*)2
I'm unsure what you're referring to here -- Zig doesn't have any runtime, it doesn't even depend on libc.

The only thing I can think of that you might be referring to is compiler-rt: if so, this is a thing in C too! It's just a small collection of implementations for operations the code generator wants to call into (e.g. memset, arithmetic for integers larger than CPU word size). Clang uses compiler-rt when compiling C code, and GCC's equivalent is libgcc. Nonetheless, Zig lets you disable it using `-fno-compiler-rt`, in which case you'll need to provide the relevant symbols yourself somehow.

mlugg··on Zig's (.{}){} Syntax
Also note that on Zig master, initializing `GeneralPurposeAllocator` like this is deprecated -- it'll be removed after the next release. Instead, you should do this:

  var gpa: std.mem.GeneralPurposeAllocator(.{}) = .init;
mlugg··on Zig's (.{}){} Syntax
It's not actually about `*` -- for instance, declaring `const T = *T;` emits an error. The thing that makes this okay is that field types (for structs and unions) are evaluated in the "lazy" way you describe.
mlugg··on Ziglang.org migrates from AWS to self-hosting
> Neat that you casually get an entire subnet for a rented computer.

Here's a little more info on this, because it's fun: it looks like Hetzner give you a /64, which by convention is indeed the size of one IPv6 subnet. That's also the minimum size block any IPv6 provider will give you (the spec essentially requires this). My ISP gives me a /64 by default, but upon request will route a /56 or even an entire /48 to me, meaning I can actually get a block of 65k subnets, for free. Hell, if you're on an IPv4-only connection, you can set up a tunnel with Hurricane Electric and get a /48 for free -- that's what I did for several years!

IPv6 is pretty objectively amazing -- huge address space to the point where we can just give people a few hundred or even thousand subnets if they need them, no need for NAT or the accursed CG-NAT meaning IPs are actually globally unique, stateless client configuration (SLAAC), backwards-compatible at the software layer via IPv4-mapped addresses, probably more things I've forgotten...

Unfortunately, the world has been slow to figure this out; there's no real incentive to migrate. You can find the global IPv6 stats online, but as a personal data point which surprised me, I can tell you (unless it's changed in the past few months) that the University of Oxford's network (which you'd expect to be pretty modernized!) is still entirely IPv4-only.

mlugg··on Zig Roadmap 2024 [video]
We have written proof-of-concepts for how incremental rebuilds will work in the Zig compiler, which can achieve incredible speed. Let's say you change a single constant value in a function (e.g. you change a string). The rebuild process looks something like this:

* We re-run the `AstGen` pass on the modified file, which is responsible for generating our first IR. `AstGen` is pretty fast - on a ~38k line file, it takes 0.25s on my laptop, so we can guess that for a ~1k line file (much more typical) it'll take under 10ms.

* We identify changes and do some logic on the dependency graph of functions and declarations to figure out what might be outdated. In this case, we would find that only the changed function is outdated.

* We re-run semantic analysis (`Sema`) on that function. For a small function, this is a trivial amount of time, probably on the order of a single millisecond. This generates our second IR.

* We re-run `CodeGen` on the updated IR for this function. This will take a similar amount of time to `Sema`, perhaps around 1ms.

* We directly perform the necessary patches to the final binary; appending the new machine code to the binary, updating the GOT entry to refer to it, and updating any modified debug info.

This whole process is on the order of milliseconds. This is not hypothetical: this is just how long the compiler pipeline takes to do certain things. We have a lot of work to do with tying these bits together for incremental rebuilds to work, but I do not predict any big losses compared to this performance estimate.

mlugg··on Zig Roadmap 2024 [video]
> If anything this will further worsen LLVM-powered build-times, surely? What's the motivation here?

The key motivation is that this will allow Zig to drop its dependencies on the LLVM libraries, instead using a separate LLVM compilation to compile the bitcode file. This is nice because it simplifies the build process and drops the Zig compiler binary size by a full order of magnitude - see https://github.com/ziglang/zig/issues/16270 for more deatils on that. It also allows us to implement incremental compilation on the bitcode file itself to drop compile times a little, which isn't really possible to do through the LLVM API since it doesn't implement certain operations.

In terms of speed, there's no reason to expect this will worsen our build times; in fact, we expect it will be faster. As with any common C++ API, LLVM's IRBuilder comes with a lot of overhead from how LLVM is written. What we're going to do here is essentially the same work that IRBuilder is doing, but in our own code, for which we will be focusing on performance.

You can find more details on this at https://github.com/ziglang/zig/issues/13265.

> ...but that doesn't mean LLVM was easy to develop.

To be clear, we aren't saying it will be easy to reach LLVM's optimization capabilities. That's a very long-term plan, and one which will unfold over a number of years. The ability to use LLVM is probably never going away, because there might always be some things it handles better than Zig's own code generation. However, trying to get there seems a worthy goal; at the very least, we can get our self-hosted codegen backends to a point where they perform relatively well in Debug mode without sacrificing debuggability.

mlugg··on Zig Roadmap 2024 [video]
Hi, core team member here (I'm quoted in a parent comment!). The problem with LLVM is not that optimization is slow - it's perfectly acceptable for release builds to take arbitrarily long for optimal binaries. The problem is how long it takes to emit debug builds.

Take building the Zig compiler itself in Debug mode. This process takes about 30 seconds running through the Zig pipeline (semantic analysis and generating LLVM IR), and then 90 seconds just spent waiting for LLVM to emit the binary. OTOH, when using our self-hosted x86_64 backend (which is now capable of building the compiler, although is incomplete enough that it's not necessarily integrated into our development cycle quite yet), that 30 seconds is essentially the full build (there are a couple of extra seconds on the end flushing the ELF file).

I can tell you from first-hand experience that when fixing bugs, a huge amount of time is wasted just waiting for the compiler to build - lots of bugs can be solved with relative ease, but we need to test our fixes! Rebuilds are also made more common by the fact that LLVM has an unfortunate habit of butchering the debug information for some values even in debug builds, so we often have to rebuild with debug prints added to understand a problem. Making rebuilds 75% faster by just ditching LLVM would make a huge difference. Introducing incremental compilation (which we're actively working on) would make these rebuilds under a second, which would improve workflows a crazy amount. This would hugely increase our development velocity wrt both bugfixes and proposal implementation.

It's also important to note that we have quite a few compiler bugs which are [caused by upstream LLVM bugs](https://github.com/ziglang/zig/issues?q=is%3Aissue+is%3Aopen...). LLVM often ships with regressions which we report before releases come out and they simply don't fix. In the long term, eliminating the use of LLVM as our main code generation backend will mean that all bugs encountered are our own, and thus can be solved more easily.

mlugg··on Zig 0.11
I wrote a good chunk of these notes and can tell you that these are probably the least detailed/useful we've ever done, because they were only about 40% completed when release day came, so we spent a few hours doing quick & sloppy improvements :P you can hopefully expect them to improve a bit over the next few days

(although I think we still managed to write up all of the most important bits)

← PreviousPage 2 of 2