Making the case that Cargo features could be improved to alleviate Rust compile times
Two common criticisms of Rust development are the long compile times and the large number of dependencies that end up being used in projects. While people have drawn connections between these issues before, I've noticed that most discussions don't end up talking much about a specific tool that ostensibly should help mitigate this issue: Cargo features. For those not already familiar with Cargo features, I'd recommend the Rust book as an authoritative source of documentation for how they work; for those not interested in having to read something external to understand the rest of this post, here's a brief summary of how they work:
- When creating a Rust package...
- you can define any number of "features"
- a feature can be tied to one or more "optional" dependencies, which will get included if the feature is enabled by downstream users and will not be included if disabled by downstream users
- a feature can depend on other features (either from within the same package or its dependencies), which will transitively include them whenever the feature that required them is enabled
- code inside the package can be conditionally included or excluded based on whether certain features are enabled or disabled
- the package defines which subset of the features are enabled by default
- When depending on a Rust package that uses features...
- without specifying any additional details, the default set of features defined by the package will be used
- individual features can be enabled by manually listing them in the details of a dependency configuration
- default features can be disabled completely for a given dependency, meaning that only the individually listed features will be enabled
- a dependency that is specified more than once (either transitively by multiple direct dependencies or both directly and transitively) using versions that are considered compatible in terms of SemVer will be "unified", which means the union of sets of specified features will be enabled
- In case this is confusing, here's a concrete example: imagine a package called
D
has features calledfoo
,bar
, andbaz
. PackageA
depends directly on version 1.1.1 ofD
and specifies it uses the featuresfoo
andbar
from it.A
also depends directly on packagesB
andC
.B
depends directly on version 1.2.0 ofD
and uses the featurebaz
. Finally,C
depends on version 1.5.0 of packageD
but doesn't specify any features. When compiling packageA
and its dependencies, packageD
will have featuresfoo
,bar
, andbaz
enabled for all ofA
,B
, andC
- In case this is confusing, here's a concrete example: imagine a package called
At a high level, Cargo features give package authors a way to allow users to opt into or out of parts of their package, and in an ideal world, they would make it easy to avoid having to compile code from dependencies that you don't need. For those familiar with other languages that provide similar functionality, this might be recognizable as a form of conditional compilation. It's worth noting that one of the common uses of feature flags is giving users the ability to opt out of coding using procedural macros, which often have an outsized impact on compile times. However, there are some quirks in the ways that features work that at least to me seem to get in the way of this happening in practice, and I've increasingly started to feel like they're a key piece of why the Rust ecosystem hasn't been able to improve the situation around compilation times and dependency bloat significantly.
Problems with "default-features"
In my opinion, the ergonomics around defining and using the default set of features get in the way of trying to reduce bloat and compile times. For example, by default, cargo doesn't show anything about what features are enabled in a dependency you've added. Here's an extremely contrived demonstration of how this might end up happening in a package I've defined a locally:
# my-package/Cargo.toml
[package]
name = "my-package"
version = "0.1.0"
edition = "2021"
[dependencies]
my-dependency = { path = "../my-dependency" }
It imports another package that I've defined locally alongside it as a dependency. Currently, there's absolutely no code in this package other than the dependency:
# my-package/src/main.rs
fn main() {
}
Let's compile it!
$ time cargo build
Compiling my-dependency v0.1.0 (/home/saghm/.scratch/my-dependency)
Compiling my-package v0.1.0 (/home/saghm/.scratch/my-package)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.10s
real 0m3.155s
user 0m1.929s
sys 0m1.281s
Hmm, over three seconds to build a seemingly empty package in debug mode. Let's take a look at my-dependency
to see what's going on.
# my-dependency/Cargo.toml
[package]
name = "my-dependency"
version = "0.1.0"
edition = "2021"
[features]
default = ["foo"]
foo = []
[dependencies]
my-dependency
has a feature called "foo". We definitely didn't make any explicit choice to include it in my-package
, and the cargo build
output didn't mention it at all, but it's still going to be included by default because it's in the default feature list. What does the feature do though?
# my-dependency/src/lib.rs
#[cfg(feature = "foo")]
pub static BYTES: &'static [u8] = include_bytes!("./big_file");
Whoops! Turns out someone defined a static array of 400,000 bytes of zeroes and exported it under the foo
feature flag. What happens if we disable that feature in our original package?
diff --git a/Cargo.toml b/Cargo.toml
index 8e39c10..52bc348 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,4 +4,4 @@ version = "0.1.0"
edition = "2021"
[dependencies]
-my-dependency = { path = "../my-dependency" }
+my-dependency = { path = "../my-dependency", default-features = false }
$ cargo clean && time cargo build
Removed 32 files, 1007.6MiB total
Compiling my-dependency v0.1.0 (/home/saghm/.scratch/my-dependency)
Compiling my-package v0.1.0 (/home/saghm/.scratch/my-package)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.20s
real 0m0.255s
user 0m0.152s
sys 0m0.107s
A fifth or a quarter of a second, depending on if you ask cargo
or time
. Either way, much better!
This example is obviously very silly, but at a high level, there's nothing stopping something similar from happening with real-world code because there's no obvious feedback given when using default features from dependencies.
The fact that default features can only be opted out of entirely rather than disabled individually can also be mildly annoying. If a package exposes 10 default features, and you want to disable only one of them, the only way to do this currently is to disable all default features and then manually enable the nine that you don't want to disable. (As an aside, this also means that introducing new default features won't necessarily cause all packages that depend on it to get them by default; in the previous example, increasing the number of default features to 11 would cause the above strategy to disable both the feature it previously disabled and the newly default feature. While this isn't necessarily a bad thing from the perspective of compile times, I'd still argue that this happening in a mostly hidden way to users who upgrade isn't ideal, and that this problem would be better avoided by having a more granular mechanism for disabling default features.)
Problems with transitive dependencies
It might sound like the issues with bloat from features would be mitigated by avoiding marking features as default, but there's an additional issue that would still prevent this from improving things very much. The only mechanism that currently exists for a library to expose the features of its dependencies transitively is to define its own features that each "map" to the features of its dependencies. Using the contrived example from above, my-package
could define a feature that depends on the foo
feature of my-dependency
, and end-users of my-package
could choose whether to include that feature or not. Without that, users of my-package
will always end up with the exact set of features from my-package
that my-package
defines; either all users of my-package
get the foo
feature from my-dependency
or none of them would. In other words, Cargo doesn't provide any way to configure the set of features included from transitive dependencies.
Imagine if you've created a library that has five dependencies, and none of those have any dependencies of their own. Not too bad compared to a lot of Rust packages! In order to do their part to combat bloat and compile times, each of those libraries define five optional features, with the idea that users of the package can avoid compiling the parts they don't need. If you don't necessarily need those features in your own library, but you happen to expose types from all five of those crates in your own API, you'd need to define twenty-five features in your own crate to give your users the option to avoid the bloat. The situation gets even worse when you consider transitive dependencies; if each of those five dependencies even had a single dependency of their own with two optional features, and they followed the same strategy of exposing these as well, you'd need to add another ten features to your package after the initial just to avoid forcing users of your own code to include code they don't need that you haven't written—on top of any features you define for users to avoid unnecessary bloat from your own code!
This is why I don't think that cleaning up the situation on how default dependencies are specified would end up being sufficient to alleviate the current situation. Even if we had a magic wand that we could wave and "fix" every library in the ecosystem to define a bunch of features to disable arbitrary code (both from themselves and mapping to the features of their dependencies), the number of transitive features that would need to be disabled transitively to actually eliminate all of the unnecessary code currently would be absolutely massive. To me, this is a fundamental flaw with what otherwise could be an effective way to reduce compile times in Rust without having to drastically change the way people use dependencies today.
What might help with these issues?
I think there are a number of possible changes that could be made that would mitigate or potentially even eliminate the issues I've described here. Some of the ideas I have would probably cause incompatibilities with the way things currently work, and while there are some existing strategies that might make them less disruptive (like tying them to a bump in the feature resolver version), I don't have enough expertise to know the exact details of how that would work. I'm also not entirely certain that the ideas I have would even be possible to implement, or that they would actually improve the compile times and bloat rather than make them worse due to consequences that haven't occurred to me. Given all of that, I'd characterize the remainder of this post as brainstorming rather than recommendations or even realistic suggestions. If the issues I've outlined above resonate with others who read this post, hopefully smarter people than me with far more domain expertise will come up with an effective way to deal with them.
With that said, these are some of the potential mitigations for these issues I've come up with along with my extremely unscientific attempt to quantify how much I'd expect them to improve the situation, the amount effort to implement them, and my confidence that my assessment of their impact is correct:
Providing a mechanism to manually disable individual default features when specifying a dependency
- Low impact - This wouldn't drastically improve the status quo, but it would make trying to avoid bloat slightly easier in some situations
- Low effort - I'd expect this to be mostly straightforward to implement
- High confidence - The scope of this change is small enough that I don't think it's likely there are drastic unintended consequences that I haven't considered (although that doesn't necessarily mean that everyone would be happy with the consequences that are intended!)
Providing a less verbose way for libraries to expose the features of their direct dependencies to other packages that depend on them directly
- Low impact - The direct impact of this change would essentially just be ergonomic, and it would only affect packages that reexport parts of their dependencies to their own users. If this included a way to disable transitive features that aren't needed, this could potentially make a large impact in the long run, but only if enough features ended up being exposed from libraries for people to disable enough code to make a difference
- Medium effort - At minimum, this would require augmenting the Cargo manifest format to define a way to configure this, and I don't have enough expertise in the way the feature resolver works to feel safe in assuming that this would be possible without changes there as well
- Medium confidence - I do think there's a small chance that this might not be feasible for some reason, but I also think there's a small chance that this change could have an outsized impact in alleviating the issues; eliminating the need to account for an exponential growth in feature count makes the "magic wand" to give us a world where all existing Rust APIs are sliced into bite-sized features much more enticing, so maybe we'll be lucky and giving the ecosystem enough incentive could cause people to start working towards making that hypothetical situation a reality
Providing a way to disable features from transitive dependencies
- Low impact - This is essentially the same as the previous idea, only configured from the package inheriting features transitively rather than the one exposing them
- Medium effort - I wouldn't be surprised if there was some additional work compared to the previous idea around handling conflicts when someone tries to disable a transitive feature that's required by the dependency they inherit it from, but this might not end up being hard to solve in practice
- Low confidence - Overall, I think this would end up being a messier way to achieve the same results as the previous idea. However, there's some value in allowing people to fix bloat in their own packages without requiring changes from every dependency along the transitive chain, and it's possible that I'm underestimating the magnitude of that additional value
"Zero-config" features that allow enabling/disabling code in a library without the author having to manually define it
- High impact - This would be the "magic wand" that I mentioned a few times above. The exact impact would depend on the granularity of the features it defines, but at the extreme end, automatically defining a separate feature for every individual item that gets exposed as part of a library's API could provide away to avoid including any code that isn't used, like a compile-time version of the Unix
strip
utility - High effort - The amount of work needed to implement this would be substantial at pretty much every step of the process: designing how it should work, implementing the design, testing that it works correctly, and benchmarking the results on real-world codebases to validate that it actually helps
- Low confidence: It's not clear to me whether this would be possible to do in a way that ended up being beneficial in practice.
Of the ideas listed here, this is definitely the most radical, so I wouldn't be surprised if some people react strongly to it. However, it's the idea that I think would have the most potential to improve things, so I think it deserves some additional elaboration on my part.
The first objection I'd expect to hear to this idea would be feasibility; it might not be obvious whether this can even be done in practice. I do think there are at least two potential ways that this would be at least possible to implement correctly: "one feature for every item in the crate" and "one feature for every module in the crate". At least from a computability perspective, it seems like it would be possible to enumerate each of these for a given library and define a corresponding feature, and then determine which (if any) of the others each of them depends on. Once that graph of feature dependencies is obtained, resolving the features that actually get used would presumably follow the same rules as resolving explicitly defined features.
The other objection I'd expect to hear is whether this would actually end up reducing compile times in practice. This concern is much harder for me to dismiss, and it's the reason I listed my confidence in the idea as "low". Any time saved by avoiding compilation of unused code would be offset by cost of having to determine how the features depend on each other, and there would be a tradeoff when deciding the amount of code in each of these features; having a larger number of "smaller" features would increase the amount of code that could be eliminated from compilation, but it would increase the amount of work needed to determine which of these features depend on each other. The amount of compilation that could be avoided could vary dramatically based on what parts of the library's API are being used, and the dependency graph of features might end up being so deep that the extra work to split into smaller features wouldn't end up eliminating more code than if a smaller set of "larger" features were picked instead.
Despite not being super confident that this would end up as a net improvement in compile times, this is still the idea I'm most interested in seeing discussed. Maybe someone will make a compelling enough argument against it that I'll change my mind, and most likely the idea won't end up going anywhere regardless of what my opinion is, but there's always a small chance that I was lucky enough to come up with a useful idea, and then we can all enjoy the benefits of having lower compile times finally.