Hacker News

How Our Rust-to-Zig Rewrite Is Going

How Our Rust-to-Zig Rewrite is Going For the past year and a half, the team building Roc's compiler has been rewriting our 300,000 lines of Rust code into Zig, for reasons I'll recap below. We recently passed an exciting milestone: feature parity with the original compiler! Since the Bun project recently shared an experience report of their rewrite in the other direction (from Zig to Rust, although that's only the tip of the iceberg of differences between our rewrites), this seems like a nice time to reflect on how our move from Rust to Zig is going. Passing Feature Parity Hitting this milestone made it possible to update Brendan Hansknecht's charming 2024 WASM-4 game, Rocci Bird (with art by Luke DeVault) to use the new compiler. It's a nice example because the whole game is under a thousand lines of Roc code, and you can play it on itch.io or right here via WebAssembly: Rocci Bird's updated source code is a bit more concise than the original, and roc build --opt=size now outputs a 31KB wasm binary. (The original compiler produced a binary more than double that size.) Rocci Bird is by no means a large code base, but getting it to run at all required landing a lot of features in the new compiler. Seeing those chunky purple pixels brought a smile to my face when we finally got there! To be clear, this is a milestone but not a formal release. (We aim to land version 0.1.0 later this year.) That said, it's a wonderful milestone to have reached, and I'm extremely grateful to all the people who came together to make this happen! I want to thank some in particular who have been especially helpful in getting the language and compiler to this point: - Anthony Bullard and Sam Mohr for collaborating on the new parser - Jared Ramirez for the new type-checker (among many other things!) - Ayaz Hafiz for the new lambda set resolution system, plus tons of the original compiler - Aurélien Geron for hand-updating 108 (!) beginner exercises in the Roc Exercism course he originally created - Stephan for getting the compiler's new "echo" platform running in the browser, so that anyone can now write and run basic Roc programs from the roc-lang.org homepage via a 2.5MB WebAssembly binary! - Niclas Åhdén, Roc's most prolific production user, for patiently filing helpful bug reports and giving actionable feedback about the upgrade process - JRI98 for methodically reproducing and investigating fuzzer errors and other bugs, closing out issues that no longer reproduced, and more - Jasper Woudenberg for iterating on API designs for userspace packages using the new compiler - Folkert de Vries, Brendan Hansknecht, Brian Carroll, Josh Warner, Agus Zubiaga, and Jelle Teeuwissen for building the foundation of the original compiler, without which the new compiler never would have existed - I've saved the undisputed biggest contributors to the new compiler for last: Anton-4 and Luke Boswell for so many things I can't even keep track of them all-compiler work, builtins, platforms, packages, examples, fixing bugs, helping beginners on Roc Zulip…enumerating it all could take up a whole second post! It's been incredible seeing how much you've built. Thank you all so much! I feel honored that you've put so much of your valuable time into this project. Also thanks to our past and present sponsors-rwx, Lambda Class, ohne-makler, martian, tweede golf, Vendr, NoRedInk, and many generous individual sponsors-who have helped get us to this point by supporting our contributors. Speaking of time: our 487-day rewrite took 476 days longer than Bun's 11-day rewrite from their ~500K lines of Zig into Rust. There are many reasons for this difference which have nothing to do with Rust or Zig, including the fact that theirs was a direct port whereas we'd decided to rewrite because of how much we were going to change. The techniques they used wouldn't have worked in our case. The laundry list of changes we made also means comparing our original Rust code base and new Zig code base won't be apples-to-apples. Still, we've reached a nice point to reflect on how the rewrite has gone, both in terms of what new features it has unlocked for Roc programmers, as well as how our experiences with Rust and Zig have compared. Let's get into it! Hot Code Loading + Cross-Compiled Binaries Roc's new compiler automatically does hot code loading during development. For example, I can run roc server.roc to start a Web server, then change some of its code while it's running. The next time that server handles a request, it'll automatically be handled using the new code. Here it is in action, both in a server and in a simple 2D game: Hot loading is standard behavior for interpreted languages like Python, but not so much for high-performance compiled languages like Roc. When I'm ready to deploy, roc build server.roc gets me an LLVM-optimized, self-contained binary that I can drop onto a machine and run. Roc also cross-compiles; building a static binary that runs on Alpine Linux is as simple as roc build --target=x64musl , and that command will produce the same output bytes (for the same input source code bytes) when run on a Mac or any other system-which not all compilers guarantee. Pattern Matching with String Interpolation The HTTP request-handling logic from that video looks like this: match (verb, path) { ("GET", "/users/${id}/${page}") => match page { "" | "profile" => ok(id) "settings" => ok(with_default(user_agent, id)) "posts/${post_id}" => ok("Post ID: ${post_id}") _ => not_found } ("GET", "/users/${id}") => ok(id) ("POST", "/posts/new") => created(with_default(…)) _ => not_found } This uses several features we introduced in the new compiler. For example, that "/users/${id}" syntax is not implemented with parsing template strings at runtime, but rather with a new language feature: string interpolation inside pattern matching. Not only is this type-safe at compile time, this entire code snippet performs zero heap allocations. I'd expect the typical language that ships with hot code loading to average closer to 1 allocation per line of code here…but Roc is aiming high on ergonomics, type safety, and performance! You can play around with this syntax on the new roc-lang.org homepage - if you scroll down a bit, there's an WebAssembly build of the compiler right there on the page that you can use to try out the language. By the way, if you're interested in a post on the technical details of how we used the new compiler's compile-time execution of pure functions to get HTTP request routing down to zero allocations, let me know on Roc Zulip. Why a Scratch-Rewrite? Unlike Rust, C, and Zig, Roc is not a systems language; it has automatic memory management (using reference counting, both to avoid tracing collector pauses and also for Perceus optimizations and opportunistic mutation like Koka's). Roc would have way more heap allocations if it needed one heap allocation per closure capture (like most non-systems languages do), but our closure captures don't heap-allocate because Roc is the first non-academic language to implement polymorphic defunctionalization through lambda set specialization. This might sound like a niche optimization, but in a functional language like Roc, defunctionalization turns out to be similar to inlining in that it unlocks a treasure trove of follow-up optimizations. Although this system proved incredibly beneficial to Roc's runtime performance, it also proved incredibly difficult for us to implement correctly. We struggled with nasty bugs in the original implementation, and only after Ayaz Hafiz prototyped a new architecture in OCaml were we able to finally get it right in the new compiler. Ayaz's prototype showed that the root of our problems was architectural across several compiler phases, and fixing it would require rewriting most of the compiler. This was one reason we decided to rewrite in the first place-that, and several contributors independently mentioning they planned to rewrite various parts of the compiler for other reasons. We realized we were about to rewrite almost all of the compiler anyway, so it made sense to consider a full rewrite as an alternative to the Ship of Theseus approach. Compilers are unusual in that scratch-rewrites are the norm among successful projects. It's often the only way to self-host, although not all compilers rewrite into their own language; see for example TypeScript's rewrite to Go. My position has always been that Roc's compiler should not self-host, so the idea that someday the benefits of a rewrite might seem to outweigh their notorious costs had frankly never occurred to me. The more we talked about it, the more sense it made to do what basically every mainstream compiler today has done at some point: rewrite from scratch. Why Zig? Once we'd decided to scratch-rewrite, the next question was whether to choose Rust again. Based on our experiences with both Rust and Zig (we were already using Zig for a bunch of primitives in our standard library), we decided to build the entire compiler in Zig this time. I enjoy Rust, I've taught a course on it, and I happily use it daily for my work at Zed. Despite what Internet comments might have us believe, it's extremely normal for one language to be the best fit for one project, while a different language turns out to be the best fit for a different project. One size does not actually fit all! I've talked in depth about our reasons for going with Zig elsewhere-in writing, on podcasts, and so on-and we only seriously considered Rust and Zig, because those were the only systems languages our team knew well enough. The biggest considerations on our minds when deciding between Rust and Zig were: - Build times. Our cargo build times were a major pain point, even for incremental builds, and getting worse as our code base grew. We expected build times in a Zig rewrite to be much faster. - Memory control. We use a variety of different memory allocators throughout compilation, especially

Comments

No comments yet. Start the discussion.