Profile overrides for a package *and dependencies*?

At work we care about code size as well as run-time performance, and the most obvious knob for that is opt_level = "s". Cargo profile overrides allow us to get more fine-grained by compiling some specific packages one way or the other in a workspace, but that’s not very dependable when a particular crate might be broken out into several implementation dependencies—if not now, then maybe in their next release.

What I’d really like to say is “default to ‘size’ (s), but go back to ‘speed’ (3) for anything in (e.g.) aes’s dependency subtree”. It’s not going to be perfect, but it’s close enough for defining a library where only some parts are performance-sensitive.

Of course this scheme can have conflicts: one package can be under two explicitly-specified subtrees. One way to resolve this is for Cargo to require that subtree to then itself be specified explicitly to break the stalemate; this does mean some notion of domination would affect the resolved opt-level (or other profile setting I guess). Another would be to only allow one value for the override—e.g. I can override the opt-level for the dependencies of both aes and sha2, but I must override them to the same value (whether or not they overlap in practice).

Non-dependency-based overrides would take precedent over dependency-based ones, which would take precedence over the * override and build-override.

(I do remember that generics add another layer on top of all this, since the codegen will happen in the client crate rather than the library defining the generic API—as written up in the Overrides documentation in the Cargo book. I don’t think Cargo can do anything about that though.)

Thoughts? Would anybody else use this?

5 Likes

Many times a single "product" is made up of multiple packages where being able to treat them as a group makes sense, including

  • bevy
  • gitoxide
  • pretty much any GUI

This seems like a real problem worth solving. My main area of concern is how to expose control over this to the user, behavior (e.g. conflicts), and overall meeting the design aims of Cargo (not thrilled with failure on conflict).

Two imperfect thoughts that I have

  • opaque dependencies
  • Globbing within a namespace package, e.g. profile.dev.package."bevy::*"
    • aligns with the feature as namespaces are meant to be cohesive
    • doesn't capture dependencies of these packages that are outside of the namespace
4 Likes

For more complex scenarios, it would be awesome if we could have a Rust program perform arbitrary manipulations on the dependency tree of packages. Right now, if this were to be built as an external tool, not sure what one could use - maybe the package resolution of Cargo is "library-ified" already?

We're working on transitioning to a library for dependency resolution but it doesn't include any cargo logic, so it isn't too helpful on its own.

Our focus for customization is GitHub - crate-ci/cargo-plumbing: Proposed plumbing commands for cargo

I needed this too in a couple of situations:

  1. To keep binary sizes minimal, cold code should be small, but hottest code still needs to be optimized.

  2. Most code should be quick to build, except bits that are too slow without optimizations. This is useful in debug builds, as well as in CI-oriented tools that could end up being installed via cargo install and lost after a single run due to uncached ephemeral CI/Docker environments.


I think the problem of one dependency existing in multiple places in the tree could be solved with some tie-breaking rules:

  • opt-level set explicitly on a named crate takes precedence over opt-level set via recursion into dependencies, which takes precedence over profile-default opt-level.
  • when a transitive dependency gets multiple opt-levels set via multiple parent crates, highest opt-level wins.

It won't be perfect, but opt-level is already imprecise due to inlining and generics.


There's #[optimize(speed)], but I don't know if it will be respected in deps and how it will end up used in practice. Will perf-sensitive deps use it on everything, making opt-level override in Cargo unnecessary? Or maybe library authors will overuse it, and Cargo will need opt-level setting that overrides #[optimize]?

1 Like

I feel that a more general [patch] mechanism to modify crates just as they're fetch would solve this issue and potentially others.

[patch] currently works as a hook executed before source resolution to redirect to a different source. It would be nice if [patch] could also have hooks just after the resolution so you can modify the content of a crate just after it's downloaded but before it's further processed.

This would allow you to change opt-level or other manifest metadata, or maybe also edit the files.

An extended [patch] section would also allow to have better rules to match which crate it applies to (the current one based on the name alone is insufficient as it applies to all versions, this breaks when you have both 1.x.y and 2.x.y in your tree but only want to patch 2.x.y).

Using [patch] also makes it clear that the patches are local to the workspace.

As a strawman, let's assume that [patch] can now be either the map from (registry,package_name) pairs to a new source, or an array of rules. You could write something like:

# edit the `[profile.dev]` manifest entry for all aes  with version 0.8.x
[[patch]]
match = {registry = "crates-io", name = "aes", version="^0.8.0"}
profile.dev = {opt-level = "3"}

# change the source for sha2 version 0.10.9, equivalent to `[patch.crates-io] sha2 = {...}`,
# but does not apply to `0.9.x`
# also it edits the opt-level afterwards 
[[patch]]
match = {registry = "crates-io", name = "sha2", version="=0.10.9"}
source = {git = "...", rev = "..."}
profile.dev = {opt-level = "2"}

Once you have this base frameworks of rules that can match a crate and edit it, adding more matchers is a more incremental process. For example you can have something to match all transitive dependencies of aes using match = {transitive-dependency-of = {registry = "crates-io", name = "aes", version="0.8.4"}}.

The most famous mechanism with such a list of rules with matchers used to update properties is CSS. "set the opt-level of all transitive dependencies of aes" is not much different conceptually from "set the text color to red to all descendant text nodes of this paragraph".

Regarding priority of application, I feel that the simplicity is important. When you start patching dependencies, you're already into advanced territory so there's not a lot of remaining "complexity budget". I'd recommend a model where all matching rules are applied and conflicts are resolved at the individual field level. On conflict the priority is always given to the last value set. Users can always reorder the rules to fix the application order if something goes wrong. I would recommend avoiding complex priority rules. In particular, I'd caution against priority rules being different per-field. For example, giving priority to the "highest opt-level" would mean that you have to decide who is highest among "s", "0" and "3".

In the world of package management, this approach is used by Yarn with its packageExtension config.

There are some practical consideration to deal with such as handling path dependencies or figuring out which fields are actually safe to modify. You probably don't want to modify the fields used to find the source (e.g. package.name) after the fetch. I feel however that this framework is generally a good way to override the responses from the registry.