Reducing Rust Incremental Compilation Times on macOS
jakedeichert.com
jakedeichert.com
Every release doesn't make every workload faster, but over a long time horizon, the effect is clear. Rust 1.34 was released in April 2019 and since then many crates have become 33-50% faster to compile, depending on the hardware and the compiler mode (clean/incremental, check/debug/release).
Interestingly, the speedup mentioned in OP won't show up in these charts because that's a change on macOS and these benchmarks were recorded on Linux.
What is expected to be a gamechanger is the release of cranelift in 2021 or 2022. It's an alternate debug backend that promises much faster debug builds.
I appreciate that they continue to speed up compilation/add flags. If the compiler gets slower every release it will eventually become unusable. I think the long compilation times will continue to get worse in the future as the rust compiler gets better at checking for errors, and I’m not sure what can really be done about that.
One thing that irks me is that they throw around the phrase zero cost abstraction a lot. And I just don’t buy it. Sure I get it, lots of things can be monomorphized so we don’t pay a runtime cost, but there is still a large compilation hit to do that, and it’s not zero cost to the programmer. One need only to look at Chandler Carruth’s cppcon talk “there are no zero cost abstractions” to understand that.
Incidentally your first question is one that's very amenable to testing. When does the Rust compiler spend most of it's time? Is it at the checking stage?
Yes, you're technically correct, but also being unreasonable.
It was pretty clear that "zero cost abstractions" refers to the runtime aspect. That is the default meaning, there's nothing to "get back to".
rustc has a self-profiler that can be used to answer this question [0], as well as a mode that times compiler passes [1].
There's no single reason the Rust compiler is slow, as it depends quite heavily on the code being compiled. For some codebases, LLVM code takes up most of the time; in other codebases (e.g., extremely generic-heavy codebases), it'll be checking-related passes.
[0]: https://github.com/rust-lang/measureme/blob/master/summarize...
The checks aren't the longest part of compile times, so that's not really it.
It is true that there are aspects of the design that lead to slower compile times than other languages, but it's more of what you're talking about at the end: monomorphization is great for a lack of runtime speed, but increases compile times. There's a lot more complexity here than simply "rust's safety checks are slow."
(And, "zero cost abstractions" was always speaking about runtime cost.)
Also, in general, we don't like flags that change language semantics. This one might be acceptable though, but I'm not on the lang/compiler teams.
Haskell's polymorphism is similar, it's true, but I can't speak to their semantics well enough to intelligently comment. They are much less concerned with performance than Rust is, so they tend to do less of the sorts of optimizations that gets Rust into trouble here.
Static dispatch:
fn show<T>(a: T) -> String
where T: Display
{
Display::fmt(a)
}
fn main() {
show(1_i32);
show(1.0_f32);
}
is (handwaves) first translated to a version with no generics fn show_i32(a: i32) -> String {
i32::fmt(a)
}
fn show_f32(a: f32) -> String {
f32::fmt(a)
}
fn main() {
show_i32(1_i32);
show_f32(1.0_f32);
}
You can probably see how the more permutations of functions you need to generate the slower it gets.Dynamic dispatch:
fn show(a: Box<dyn Display>) -> String {
Display::fmt(a)
}
fn main() {
show(1_i32);
show(1.0_f32);
}
This translates to (handwaves): fn show(a: Box<dyn Display>) -> String {
let fmt_fn = lookup_fn(Display::fmt, typeid_of(a))
fmt_fn(a)
}
fn main() {
show(1_i32);
show(1.0_f32);
}
This is simple to compile, but you need to do some extra work at runtime.I think the real reason is that rust gives additional guarantees about lack of allocation and object layout that are hard to implement with dictionary passing (BitC tried and failed).
However, I think these all operate on boxed objects, which is a little bit easier to do than for Rust, where only some objects are boxed.
In short, I agree.
A debug backend or interpreted based one, e.g. ocaml, GHC hugi, could help quite substantially.
That is not really true. Yes, it would help first compiles a bit, but only the initial compiles. That's a one-time improvement, that while important, doesn't solve the big problems.
With Rust is almost impossible, just to check a random project without investing the required time to wait for its build to finish.
Also maybe I am special, but it is quite typical to switch project branches quite often, or just get libraries from another project.
All of which I can easily do via binary libraries stagging.
After you first cargo build, you have all of the dependencies pre-compiled. Every single "cargo build" after that's compile time would be completely unaffected by pre-built dependencies. You could try sccache; builds are still slow. Most people care about those secondary builds, not about the initial build, so telling them that this solves their problem is simply not true. It's not downplaying it, as I said, the first builds are also important. Unfortunately, it's just not that simple.
If solving Rust builds were as easy as this, we'd have just done it long ago. We care tremendously about improving compile times. It is a constant request on surveys. We put a lot of engineering work into this.
So while it may seem like bashing, it is more trying to be a positive critic of something that real matters and is a show stopper for some environments.
In any case I look forward to any improvement in build times, and an eventual story regarding binary libraries.
Actually rustc has many modern features that say C++ compilers don't. For example, clang is not an incremental compiler, nor does it do parallel codegen. Rust also has had work on multithreading support. These features were introduced all after rustc already existed. It would have been unimaginably hard to retrofit clang to this, as C++ is way harder to refactor than Rust.
So I'd put it onto the design instead of the compiler. But it's not the safety features of Rust that cause the main slowdown, at least not directly. Yes, borrow checking is some additional step rustc has to do, and NLL introduction took a major engineering project to not regress compiler performance, but overall these safety checks don't make up such a large part of the compile process.
First, Rust has larger compile units than C. If you change one file in Rust, the entire crate has to be recompiled, while in C only the single file needs recompilation (unless it's a header, then everything that imports it needs recompilation). That's also why incremental compilation is much more important for Rust than it is for C (and it increasingly becomes important for modern C++ as compile units increase).
Second, in Rust you compile everything, including your dependencies. It's not like in C or C++ where you install *-dev packages for the heavy libraries. In fact, if you compiled everything yourself in the C/C++ world, often you'd get similar compile times or even worse ones. Rust has an unstable library format. It's literally just mmap'ed internal data structures. Two different versions of the compiler can't reuse the same library, after a compiler update you have to recompile everything from scratch, and if you want to be able to add new dependencies or cargo update, you need to always use the latest compiler, which gets released quite often. So many projects that I maybe touch once every 6 months I basically have to recompile from scratch again. This is all due to design and policy questions, not because the compiler itself is slow.
Third, in Rust you statically link everything. This puts greater load onto the linker which now has to copy large amounts of data to obtain large binaries. There is no stable ABI so you can't create a dynamically linked library with a safe interface (you can of course create one with an unsafe C interface but that's not nice). There is also no cargo support for it.
Fourth, heavy use of monomorphization. A lot of libraries in Rust are generic either on lifetimes or on types. Lifetimes can be stripped, but if your code is dependent on a type it gets copied and then recompiled for every different type. This is a design question and has benefits in the final program as the code can be optimized for the type. But it has to be optimized. This incurs a compile time cost.
C++ is not one trick pony, it is an ISO standard with multiple implementations.
No idea about C++ Builder. Do you have links to documentation to back it up? I'm curious :).
In any case, have a look here,
https://docs.microsoft.com/en-us/cpp/build/reference/cgthrea...
You get the same "zero cost abstraction" effect in lower-level languages like C (but you need to feed it "dumb code" to see a similar effect). Nobody talks about "zero cost abstractions" in the context of C programming because C doesn't encourage to "obfuscate" source code with high level abstractions.
Not sure if optimizer passes are the main reason for Rust being slow though, I'd guess it's more the static code analysis, e.g. a C/C++ compiler with static code analysis enabled is also many times slower than regular compilation (but Rust should have an advantage there, because Rust doesn't allow as much freedom as "less correct" languages, so the static analysis can be more focused).
So C programmers must agree that some abstraction is good and it is at least plausible that C is not the best abstraction layer for evry single application.
Our world is full of abstractions in basically every single thing we use.
Also to be clear, I mean "abstractions" not "obfuscation" which many people (common in Java and C++) call "abstraction" even though it is nothing of the sort.
Making a compiler fast is largely a goal you have to continuously strive for, and for the most part doesn't come for free, no matter the language. Saying "it's an issue with the compiler" makes it sound like the Rust team consciously made some horrible design decisions or something, but that isn't evidently clear from anything we can immediately observe.
The story I suspect is correct and vastly, vastly more boring is they probably focused on "the language" (features, APIs, etc) for a really long time and only started focusing on compiler performance in more recent memory, once users started getting more irritated, so it ran away from them a lot. That seems to happen to every compiler these days; features are what get monetary and mindshare support from users. Down-in-the-dirt .5% performance wins day in and day out, which you have to do a lot of to actually improve things most times, normally aren't fun, nor something people trot out the red carpet for.
> I think the long compilation times will continue to get worse
No, it's more likely they'll get better - that has been the trend, and as I mentioned there are projects in the works that can likely cut the compile times in half.
Anybody know of a 2000 vs 2020 C++ compilation comparison?
https://github.com/est31/cargo-udeps/commit/e550d93c7a6d756e...
I wish my dev builds only took 14 seconds...
This project is a very small hyper/tokio backend API. However, I also work on a CLI tool [1] I created which has very fast recompile times, usually less than a second. Mostly because it's pretty light on dependencies.
Where these incremental compilation?
apt-get install rustlibXYZ-dev can't come soon enough.
cargo install --no-default-features
To avoid building support for all storage backends. You can then add back any backend you're actually using.However, the point is actually how it is a big hurdle to overcome having to compile crates with npm like dependency trees all from scratch, vs other compiled languages integrated into the distribution package repositories.
If you do want to retain it entirely you can set `split-debuginfo` to `off` and it will remain in the final executable.
The advantage of the `unpacked` version is that the debug info just stays associated with the original object files instead of the executable. This makes handling them harder obviously but is good enough for a lot of use cases.
It's possible to get the linker to keep the debug info by tweaking the section attributes. By default all debug info related sections have the `S_ATTR_DEBUG` flag. If that is replaced with the `S_REGULAR` flag the linker will keep those sections. The DMD D compiler does this for the `__debug_line` section [1][2][3]. This allows for uncaught exceptions to print a stack trace with filenames and line numbers. Of course, DMD uses a custom backend so this change was easy. Rust which relies on LLVM would probably need a fork.
[1] https://github.com/dlang/dmd/pull/8168 [2] https://github.com/dlang/dmd/blob/33406c205b76a8c2b5fb918da1... [3] https://github.com/dlang/dmd/blob/33406c205b76a8c2b5fb918da1...
I'm not sure that's true. I was not getting line numbers in backtraces in a Rust program I developed because I copied the executable to another directory. I had to add a symlink to the dSYM directory to make it work.
https://github.com/rust-lang/rust/pull/79570
Maybe the default will be switched in the future.
https://github.com/rust-lang/cargo/pull/9298
https://doc.rust-lang.org/nightly/cargo/reference/profiles.h...