Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pre-RFC describing mechanism to optionally loosen some orphan rule constraints #3482

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

jsgf
Copy link

@jsgf jsgf commented Sep 3, 2023

This RFC proposes a mechanism to allow impls for a type or trait to be in a
separate crate, while still meeting the soundness guarantees currently enforced
by the orphan rule. With this, the crate structure (and thus build system
dependency graph) can be decoupled from the actual implementations.

Rendered

@Diggsey
Copy link
Contributor

Diggsey commented Sep 3, 2023

The reason the orphan rules exist is precisely to avoid the situation where two crates cannot be used together because they have conflicting implementations. If there's a way to disable the orphan rules then you may as well just not have them, so I don't think this is a good idea.

@juntyr
Copy link

juntyr commented Sep 3, 2023

I think that the need to restrict this feature to a local workspace shows how dangerous disabling the orphan rule really is. I personally do not want to have to think about any two crates creating an impl conflict, since it massively increases the complexity of crate relationships and possible impl-overlap compilation errors.

Perhaps the trait-defining crate could be required to declare (but not use-depend on) all crates which can impl-depend on it. This at least puts the task of avoiding conflicts in the court of the crate author instead of any user of the crates. Even if such crates are then published on crates.io, any conflicts could be detected within the limited list of crates before allowing publication.

Overall, I still think that the increase in complexity and nasty compilation errors is not merited. I feel like using features and improving parallel compilation itself are better approaches here.

@ehuss ehuss added the T-lang Relevant to the language team, which will review and decide on the RFC. label Sep 3, 2023
@burdges
Copy link

burdges commented Sep 3, 2023

I'd think "automatic dependencies" would address the usual issue here better:

[dependencies]
unwanted_dependency = { ..., optional=auto }

This says I'll impl whatever traits unwanted_dependency creates, but only if you use unwanted_dependency elsewhere.

After these cases, we need some "classification" of the possible newtype deriving stories, so folks can finally feel like they know the proper solutions there.


If you really need this, then you might do roughly this under the second orphan rule. We've a type and trait like

pub struct Type { ... }

pub trait Trait<Localizer> { ... }

We then define impl Trait<MyLocalizer> for Type { } whenever we require some local flavor of the trait. We do not care about Localizer being uncoverd since it comes first.

A priori, we'd want OtherTrait<MyLocalizer>: Type too perhaps, vaguely like if we'd created some newtype, but there now exist other options. In particular, we could craft code to pass through different Localizers for Trait and OtherTrait, and then trait aliases could select the desired Localizers per trait.

@ahicks92
Copy link

ahicks92 commented Sep 4, 2023

With respect to features, if some crate foo enables serde in some other crate bar, foo probably depends on serde too, and so you don't add a dependency by enabling the feature (presumably foo would drop both if it dropped one, modulo typos in Cargo.toml). Something like auto dependencies (cool idea) would make it automatic, but I don't take it as a given that the features to impl traits approach we have today is responsible for the convergence problem, and I don't see how this RFC makes it better. It seems to me that the problem is that not bringing in tons of dependencies requires a lot of work either way, and for most people (including myself) there's a lot of bigger problems to address in our projects and not enough hours in the day. If I'm following this right, we replace one crate with optional features with a bunch of small ones published somehow, which is worse than playing around with cfg attrs.

That's not to say I haven't personally found orphan rules painful, but they'd need to be replaced and not just removed for the same reason that we don't have type inference beyond the function boundary. This doesn't sell me on it because it doesn't seem to make things take less work and it does definitely allow for spooky type action at a distance. I don't want to patch one or more deep chains of dependencies because two semver-compatible versions now happen to provide conflict impls. It's not just patch problem1 and/or problem2, it's patch those then climb up the chain patching everything between me and the problems. I prefer slightly slow clean builds to a day of lost time. Caching even without something like sccache covers the rebuilds most of the time, so I'm not concerned about those in terms of Rust compile time performance, and I suspect this RFC wouldn't move the needle much either way after the first build in regards to that either.

Anecdotally, I had to try to optimize build times for a large Rust codebase at work. Removing even large numbers of dependencies was worth very little once sccache was running. Surprisingly so in fact. It was worth maybe a couple percent no matter what I tried in that regard (in that case the most gains were found helping Warp not to monomorphize as much and getting CI using sccache with s3).

Now to be clear: I'd love something less restrictive than the orphan rules that solves the same problem. I'm not saying "O this is bad", more "O this will cause chaos". If something was coming in to replace them, I'd feel more confident.

@jsgf
Copy link
Author

jsgf commented Sep 4, 2023

Oh, wow wasn't expecting this to attract attention so quickly.

The fundamental part of this proposal is the language-level mechanism, as a starting point for experimentation. The secondary part was a limited means to use it via Cargo so that there's something to work with. As I said numerous times in the RFC text, I do not think this should be considered for widespread use without a lot of extra discussion.

I'll reply to each person separately to help with discussion

@Diggsey:

If there's a way to disable the orphan rules then you may as well just not have them, so I don't think this is a good idea.

Right, but this does not propose disabling them; this RFC requires precisely the same coherence invariants that the current orphan rules do. All it proposes is implementing them with a bit more flexibility.

@jsgf
Copy link
Author

jsgf commented Sep 4, 2023

@juntyr:

I personally do not want to have to think about any two crates creating an impl conflict, since it massively increases the complexity of crate relationships and possible impl-overlap compilation errors.

One of the other key points of this proposal is that any conflict is detected and reported as close as possible to the conflicting crates. In principle one could do all coherence checking when linking the final binary crate. That would be fine from a correctness point of view, but a poor user experience since you'd get very late diagnostics about a problem. This design reports as soon as a conflict is possible to detect - in the common case, where impls and definitions are in the same crate as today, it would be indistinguishable from the status quo. Ideally, if you were using this mechanism, you'd set up unit tests which exercise all the valid combinations of defining and implementing crates within your workspace so that cargo check on them would give immediate feedback of problems.

Perhaps the trait-defining crate could be required to declare (but not use-depend on) all crates which can impl-depend on it. This at least puts the task of avoiding conflicts in the court of the crate author instead of any user of the crates.

Yes, I did consider that - that was the initial design. The problem is that since Rust has a flat crate namespace, there's no way to actually identify a crate without taking a dependency on it. Something of the form --allowed-impl my-serde-impl doesn't really do anything in particular, since it doesn't identify a specific crate. Currently the only place a crate name gets bound to a specific implementation is --extern my-serde-impl=/path/to/libmy_serde_impl.rlib which can't work here because it would be a cyclic dependency.

I also experimented with the idea of a "witness" crate-like object which takes a dependency on the defining and impl crates, and will only compile if the coherence invariants are correct. While that would work, it would also undermine all the other benefits of this proposal (since we'd still end up with binaries depending on everything even if they're not needed).

improving parallel compilation itself

There's two classes of parallel compilation - "internal", where rustc has internal parallelism to get better performance, and "external" where the build system can invoke multiple instances of rustc in parallel. The latter is much more scalable than the former. Internal parallelism can make use of at most one machine's cores, assuming there's parallelism to be successfully extracted.

External parallelism can make use of a whole build-farm's worth of machines. In large scale systems, a build system like buck2 can have many thousands parallel compiler running at once, so long as the dependency graph allows it. In C++ this is relatively easy because of the decoupling header files give, but Rust is currently strongly constrained here. This is one of the things I'd like this RFC to improve.

That said, one of the "future possibilities" I propose is auto-splitting a crate into multiple subcrates. This effectively converts internal parallelism to external, and also mitigates all the complexity and eco system problems, since it's necessarily purely local.

@jsgf
Copy link
Author

jsgf commented Sep 4, 2023

@burdges

I'd think "automatic dependencies" would address the usual issue here better:

This looks like a "weak dependency" analogous to a weak symbol - the dependency here is not enough to bring in the crate, but some other strong dependency will.

Unfortunately this idea only work for small-scale build systems like Cargo, where you only have a single program within the dependency graph.

If you have a build system for a large system, then these weak dependencies can be hard to implement, if its possible at all, and even if you do have them they're very hard to reason about. Neither Buck nor Bazel (I think) support this kind of dependency.

I haven't thought about it in detail, but it seems quite possible you could get paradoxical situations: eg where you have an acyclic dependency graph, then you add one new dependency, which causes a weak dependency to be brought in, which in turn causes a cyclic dependency. Avoiding this means that you still need to analyze the entire dependency graph even for the "optional" parts you're not using, which add constraints on what you can depend on even though they're from things you're not using.

It doesn't seem any simpler, at any rate.

@jsgf
Copy link
Author

jsgf commented Sep 4, 2023

@ahicks92

I don't take it as a given that the features to impl traits approach we have today is responsible for the convergence problem, and I don't see how this RFC makes it better.

Could you expand on this? What do you mean by "the convergence problem"?

If I'm following this right, we replace one crate with optional features with a bunch of small ones published somehow, which is worse than playing around with cfg attrs.

Yeah, in the environments I'm working in, small crates are vastly preferable, from the perspective of:

  • better build parallelism
  • easier IDE presentation (no need for RA to understand which code is configured)
  • cfg/features not really being an option, since it would require per-dependency-edge cfg settings, with a corresponding combinatorial explosion

On the other hand, there's no requirement to use this mechanism if it doesn't solve any problems. It might make debugging issues with upstream dependencies harder if they misuse it; it doesn't feel to me like it would be much harder than feature combinetrics (ie the "how did this feature get enabled?" game).

I don't want to patch one or more deep chains of dependencies because two semver-compatible versions now happen to provide conflict impls. It's not just patch problem1 and/or problem2, it's patch those then climb up the chain patching everything between me and the problems. I prefer slightly slow clean builds to a day of lost time.

Yes I agree entirely - I would not want this to be available in the form of "any package can implement traits for any other package for types of any package" - ie, the "general third-party impls" problem. That would be a disaster. But that's orthogonal to what this RFC discusses, which is a basic mechanism which could be (mis)used this way, but also for many other much less problematic uses.

Caching even without something like sccache covers the rebuilds most of the time

Yes, to an extent. The environment I'm most familiar with uses Buck, which caches much more aggressively than Cargo (even with sccache). Warm builds are fine, but coldish builds are much more common than one would like - in a large multi-developer environment, someone is always touching something, and so we need to make those builds as quick as possible. But even if everything is cached, even analyzing the dependency graph to work out what even needs to be considered can take a considerable amount of time, so shrinking the dependency graph can be a build speed win in itself.

See also my comments above about internal vs external parallelism, where having a wide dependency graph which allows many build actions to be run in parallel has much better scaling.

@jsgf
Copy link
Author

jsgf commented Sep 4, 2023

(cc @JakobDegen @stepancheg @dtolnay)

@ahicks92
Copy link

ahicks92 commented Sep 5, 2023

Sorry, convergence was a bad word. I mean the dependency explosion that happens when someone brings in (say) serde just to support its traits.

The thing is that the Rust parallel compilation model can be improved more and while it is indeed the case that clean builds happen more often than we would like, it's hard for me to see this as anything but a short-term hack to deal with such. Typically, there's a way to design so that even local crate hierarchies don't have such issues. For example, why not just pull your traits to a super-lightweight trait-defining crate, depend on that, and then implement them everywhere? If we ignore everything about good code design and we say that this is entirely for build times/parallelism, then a my-traits crate solves that problem by pushing the serialization point down as far as possible so that all other workspace members can compile, potentially at the cost of triggering more rebuilds (take it as a given that I understand you can't always do that, but it is still a solution that does often work).

If you're rebuilding from scratch all the time (including dependencies) then it seems to me that your build would always be dominated by external crates anyway, unless you're in an environment in which dependencies are disallowed, right?

Finally, with respect to parallel compilation and in full acknowledgement that I haven't done this, I would intuitively think that a build large enough to saturate more than one machine is large enough that the fact that there are dependency chains isn't going to be much of a factor. There's bottlenecks, but I'm skeptical it's as clear-cut as "C++ headers good". I also feel like there's probably a set of proper solutions to that which aren't allowing workspace-local breaking of the orphan rules.

I think if I was going to put it simply, what's the common use case this solves? Right now I'm seeing this as primarily build time for a set of somewhat niche use cases. Not invalid ones, it just feels like "turn off orphan rules" is really drastic to solve them.

I also don't personally see how this gets past experimentation to a point where it can interact with crates.io. The RFC says it's leaving crates.io for the future, but it's hard for me to see this as a general solution to ecosystem problems because I don't see that future working out. You're right that what replaces the orphan rules in that future could be deferred indefinitely, but without some idea of what that looks like I don't see how that's even a possible future; I'm not convinced the orphan rules can be relaxed, and convincing me that they could be would go a long way toward me seeing this as something general.

text/0000-extern-impl.md Outdated Show resolved Hide resolved
text/0000-extern-impl.md Outdated Show resolved Hide resolved
text/0000-extern-impl.md Outdated Show resolved Hide resolved
text/0000-extern-impl.md Outdated Show resolved Hide resolved
@joshtriplett
Copy link
Member

@jsgf This came up in today's @rust-lang/lang meeting. We have had some discussions in the past about a more general solution for orphan instances in the ecosystem (beyond just workspaces), and the requirements and tradeoffs of doing so. If @rust-lang/lang were able to provide some design constraints/requirements for this, would you be interested in working on the more general lang-level solution? (If so, we could start some further design conversations on this.)

@jsgf jsgf changed the title Pre-RFC describing mechanism to remove orphan rule constraints Pre-RFC describing mechanism to optionally loosen some orphan rule constraints Sep 6, 2023
@jsgf
Copy link
Author

jsgf commented Sep 6, 2023

@joshtriplett Yes, definitely. I'd really like to find a path forward here, but I also know it's important to get right. But

more general solution for orphan instances in the ecosystem (beyond just workspaces),

I have to say this is a secondary concern for me. My priority is to have a very solid solution for the local case, and then possibly build on that for a more general solution. I'm doubtful there's a path to a general solution without having a settled local solution first.

@Diggsey
Copy link
Contributor

Diggsey commented Sep 6, 2023

@jsgf

Right, but this does not propose disabling them; this RFC requires precisely the same coherence invariants that the current orphan rules do. All it proposes is implementing them with a bit more flexibility.

I don't agree with that framing of the RFC. The RFC allows any crate to essentially "opt out" of the orphan rules for a specific dependency, and as a downstream consumer of the crate I have no control over this.

For example:

  1. Bob starts writing his first crate, and tries to implement Serialize for some third party type Foo.
  2. Bob gets a confusing error about coherence rules, and decides to google it.
  3. He finds out that simply adding impl = True to his serde dependency will make the error go away.
  4. He publishes his crate, happy that everything is working.
  5. Some time later, the Foo gets a Serialize implementation.
  6. Everything that transitively depends on Bob's crate is now broken!

If Bob had instead made a newtype wrapper, then the code might be slightly more complex, but there would be no risk of breakage down the line.

I think there is an argument to be had about whether the orphan rules should exist (it's certainly a trade-off). However, having the orphan rules but also having an opt-out with no apparant downside to the opt-out is the worst of both worlds, since you get both the confusing error messages, and a high risk of future breakage.

Personally I think the orphan rules are better than no orphan rules, but that there is still room for improvement.

First: making it easier for maintainers to structure crates how they want. This can be solved with a similar approach to this RFC but in reverse: the upstream crate can "nominate" downstream crates who are allowed to add implementations - this way you add flexibility without introducing the possibility for breakage.

Second: a mechanism for "fallback implementations". Fallback implementations can be provided for any type/trait pair but will only be used as a last resort if a "real" implementation is not found. If multiple fallback implementations exist, then the "root crate" can disambiguate: failure to do so is a compiler error. This mechanism should still be considered a "last resort" though. Obeying the coherenece rules is better for everyone involved if at all possible.

@ahicks92
Copy link

ahicks92 commented Sep 6, 2023

@jsgf
The problem, as I see it, is that you're not really giving a lot of motivation for the local solution being good enough to add something kinda hacky to the language. I'm not saying you don't have valid concerns, merely that they're not yet at the level of "and we modified rust to address them". If build times are an issue, there are lots of other things that can be done today which will address a lot of it, for example getting sccache and pointing it at redis or s3 in your CI provider. Crates like prost provide ways to inject derive attributes, and while it's not necessarily ideal to go make trait impls live next to some protos, it does work out fine if you do so.

In my opinion, the crates.io/ecosystem solution is what moves this from hacky to cohesive. Otherwise, it's hard for me to see this as anything but a build time workaround or something like that. Locally, you have full control of what's where and I don't think I know of a case where this can't be made to work out. Can you more clearly articulate the thing you can't do at all without this proposal?

@Diggsey
They're not proposing crates.io/publishing support right now.

I kinda like your solution re: fallback implementations, though I find myself asking how often they would be used in practice. It sounds like specialization however. Isn't what you're saying isomorphic to adding a rule/attribute to specialization that says "this trait is of the lowest priority"?

@Diggsey
Copy link
Contributor

Diggsey commented Sep 6, 2023

They're not proposing crates.io/publishing support right now.

True, but that is the natural progression if this RFC goes forward, and without the ability to publish such crates several of the main motivations for the RFC are not solved.

I find myself asking how often they would be used in practice

Ideally as infrequently as possible...

Isn't what you're saying isomorphic to adding a rule/attribute to specialization that says "this trait is of the lowest priority"?

As it stands, specialization does not relax the orphan rules, so you still can't define implementations outside of the crate defining the type or trait.

@jsgf
Copy link
Author

jsgf commented Sep 6, 2023

@Diggsey

He finds out that simply adding impl = True to his serde dependency will make the error go away.
He publishes his crate, happy that everything is working.

My proposal here is that 1) impl = True will only work on dependencies within the same workspace, and 2) publishing crates with impl = True to crates.io is not allowed.

I don't have a solution to the general third-party implementation problem which would for crates.io, for all the reasons that you're concerned about. I'm putting this forward as a mechanism which is locally useful, that could be part of a more complete solution. But it also enables a bunch of interesting possibilities even if it's never directly exposed to users, such as splitting std or auto-crate-splitting.

@ahicks92

If build times are an issue, there are lots of other things that can be done today which will address a lot of it, for example getting sccache and pointing it at redis or s3 in your CI provider.

Yeah, I'm very aware of those solutions. My principle build system is Buck2 which aggressively distributes and caches, and does a very good job of extracting as much concurrency from a build as is possible to extract with the current model. It's just not enough. And more generally it's just very frustrating having a hard language-imposed bound on how I can factor functionality to crates.

Crates like prost provide ways to inject derive attributes, and while it's not necessarily ideal to go make trait impls live next to some protos, it does work out fine if you do so.

Having a separate ad-hoc mechanism for every code generator is a specific example of this.

Fundamentally, I see this proposal as being a bit like specialization: its in the language, but not generally available without enabling a feature. It can be used in, say, libstd with a particular awareness of the problems and limitations, and maybe eventually it will be a generally available feature once there's a coherent story around how it can be made to work in the large.

@ahicks92
Copy link

ahicks92 commented Sep 6, 2023

@jsgf
There's a difference between "I can do this but it's awkward, so let's try to start an initiative" and "I can't do this at all". In the former category I'm not happy with just saying no to crates.io and so, to me, it's important to explain how such an experimental initiative ends. Without that, then the initiative has no clear scope and will just go on forever. And it seems that the latter is not true: you can always get it working, even if it's ad-hoc. Showing that certain important things are impossible makes your case much stronger.

Perhaps I should clarify, however, that I have no decision-making power either way, and am speaking only from a general project planning perspective; if Rust folks are happy with an unscoped experimental initiative for this, more power to them.

@burdges
Copy link

burdges commented Sep 6, 2023

It's maybe helpful if people cc rust-lang/rust#29635 whenever they'd consider using #[fundamental], but do not do so for stability. This might help someone in future figure out in what form they could stabilize #[fundamental] (or if doing so is a good idea).

@tgross35
Copy link
Contributor

tgross35 commented Sep 6, 2023

Maybe an alternative is to make it easier to create a wrapper type, which is how the orphan rule is usually worked around:

#[wrapper]
struct Foo(crate1::Bar);

impl crate2::Baz for Foo { /* ... */ }

#[wrapper] would:

  • Work only on structs with a single field
  • Create the same layout as #[repr(transparent)]
  • Derive AsRef, RefMut, Deref, DerefMut, From/Into to the inner type
  • Give the outer type whatever traits the inner type has (Clone, Debug, etc)

This could be generally helpful as well.

@jsgf
Copy link
Author

jsgf commented Sep 6, 2023

@tgross35 Yeah I should add a wrapper types discussion to the "alternatives" section. I think the main problem is getting all the wrappers confused with each other and the wrapped type. I guess we'd end up with lots of signatures of the form impl Into<WrappedType> or impl AsRef<WrappedType>.

Give the outer type whatever traits the inner type has (Clone, Debug, etc)

Do you mean just libstd traits, or all traits? What would this look like for serde, for example?

@shepmaster
Copy link
Member

make it easier to create a wrapper type

The keyword there is delegation. For example, #2393 and #1406.

@tgross35
Copy link
Contributor

tgross35 commented Sep 6, 2023

Give the outer type whatever traits the inner type has (Clone, Debug, etc)

Do you mean just libstd traits, or all traits? What would this look like for serde, for example?

Not a very refined idea :) I was initially thinking all traits but know that would come with complications. However...

The keyword there is delegation. For example, #2393 and #1406.

It seems like this idea has already been floating around (thanks for sharing today's vocab word)

@jsgf
Copy link
Author

jsgf commented Sep 6, 2023

@ahicks92

There's a difference between "I can do this but it's awkward, so let's try to start an initiative" and "I can't do this at all".

Well take this for example. In C++, when I want to depend on another module I do #include <othermodule.h>, which gives me all that module's interface definitions, so I can then compile mymodule.o. This parallelizes perfectly - if I have 1000 modules, I can kick off their compilation in parallel, with no regard to whatever dependency relationship they may have. The only blocker is that the final link step needs to wait for the 1000 compilations to complete.

In addition to this, it is straightforward to factor the code for a given class's member functions into as many compilation units as one likes, either for code organization purposes, build speed and/or whatever other criteria one desires.

In Rust, the maximum compilation parallelism is limited by the shape of the dependency graph - at greatest the amount of parallelism is the widest part of the dependency graph, which often isn't all that wide. But worse, there are often bottleneck dependencies, which depend on lots of things, and are depended on by lots of things - if they need to be rebuilt then everything comes to a standstill while that happens.

Rustc has a few techniques to mitigate this:

  • pipelined builds, to generate the metadata (equiv to a header file in the C++ case), which ideally gets the dependents building faster
  • parallelize codegen in llvm, which can reduce the path for linking
  • incremental builds which help with iterative builds
  • caching, which can often avoid rebuilding
  • (future) more internal parallelization within rustc

These all help to some degree. In many cases they help enough that one might not consider this to be a big issue.

But at a larger scale they are literally orders of magnitude away from being able to achieve the same build throughput that's possible with C++. Caching aside, none of those techniques - individually or collectively - are going to come anywhere close, because they're fundamentally limited by the structure of the dependency graph.

Now obviously C++ has a bunch of disadvantages here - the primary one being that you need to manually keep your headers and your implementations in sync, it's easy to cause UB by violating the One Definition Rule, etc - we absolutely do not want to let Rust be subject to any of these problems.

So what I'd like to be able to do is define "interface" crates, which just have a bunch of type/struct/trait definitions in them, and ideally no implementations at all. Any crate which needs those definitions need only depend on this crate. Since it does not contain code, its effectively equivalent to a .rmeta. The implementations are all split into separate crates in any organization that makes sense to the developer. Consumers can depend on any subset of those implementation crates that they actually need, and so take on only the build cost and transitive dependency cost of the functionality they need.

This structure not only inherently improves build performance, it also enhances all the other speedup techniques that rustc employs, mostly by letting them all operate on a finer grain:

  • pipelining smaller crates lowers the latency to kick off dependant builds
  • more llvm parallelization across multiple crates rather than within just one
  • easier to identify incrementality within smaller implementation crates
  • caching at the crate level is more effective, because the downstream effects are more contained

This last point is particularly important - changing a type definition is clearly going to cause a lot of downstream fallout to adjust. But changing the implementation of a seldom used function can be limited to that crate and its dependencies, even if the rest of the associated functionality is widely used.

This still has the coupling to the shape of the dependency graph, but its limited to the interface crates. It's equivalent to C++ with modules. The other limitation here is that #[derive] is based on access to the AST, and so can't be separated from the definitions. I don't have a good answer for that one.

(Note here I'm talking about a more general form of this RFC that also applies to intrinsic implementations as well as trait implementations. I included that in earlier drafts but it raised enough secondary complexity that it can be deferred to later.)

So, no, this change isn't essential in the sense that there's some lost expressiveness at the language level. But at some point, the effect of being able to improve the practical experience of using Rust counts for something - if we went the other way, and removed the notion of separate compilation entirely it would certainly simplify things, and open up a lot of opportunities for internal parallelism and incrementality and so on. And similarly incremental building is a fairly brittle, somewhat ad-hoc hack to improve inner loop compilation speed, which is nevertheless probably worthwhile.

@ahicks92
Copy link

ahicks92 commented Sep 6, 2023

@jsgf
But why is the solution to this problem "add a hack to the orphan rules"? One could, for example, propose something more akin to the C++ model where (via some mechanism--idk what) rustc can process multiple files at once. For example, perhaps we introduce Rust compiler-generated "headers" which can be generated cheaply and then allow the rest of rustc to run per-file. Or perhaps you could split off the LLVM IR somehow and distribute that to multiple boxes instead. I don't have an easy proposal here, but this RFC feels like an easy proposal and not a good proposal, and that's why I keep asking (though probably in an ambiguous way) for a non-build-time reason to do it.

A few things I think are worth noting:

  • In many ways this is the "I'm lazy" button: people will get frustrated at orphan rules and just use it because it's there. Few people are going to only use it for build times. We can expect new Rust coders to find it and use it without fully understanding the implications because it makes errors go away.
  • If you use this liberally in a large codebase at a company large enough for whom build times are a problem, then it invisibly adds tech debt that both prevents open sourcing or ever splitting the giant death repo with all the Rust code in it (the latter splits workspaces, too).
    • Put another way, in C++ I get to play with include paths and can split things up to give to different teams or etc. any way I want. You can in Rust. You can't in Rust once you use this flag.
  • This will require changes in rust-analyzer and whatever other tools might exist in the ecosystem, not just rustc itself.

It's not like a giant drop in code quality but using this is still generally a drop in code quality especially given all the ways you can use it wrong/too much in a given codebase.

I think it should be considered a very big open question whether or not this even fixes the build time problem as well. What evidence exists that this is a large gain? Looking through the RFC I don't see that addressed. If it was (say) a 50% gain for lots of common codebases, I'd be more for this. But everyone will probably be very sad if this is added and 6 months down the line it turns out it's only a 10% gain or something, but now we're stuck with it. Also, what evidence is there that this time next year there won't have been some big initiative for compile times that renders this use obsolete? Even if neither of those happens, this feels like a limited-time feature that "expires" at least as regards the original motivation whenever Rust fixes build times as a whole.

@jsgf
Copy link
Author

jsgf commented Sep 7, 2023

@ahicks92

I'm focusing on local use simply because I agree that it could cause deep problems at scale. I think the tooling around it should at the very least encourage, and probably enforce local use, in order to keep refactoring and so on tractable. That's one of the reasons I'm hesitant to tie this to a "do this at crates.io scale" because I'm doubtful that there's a way to do this - I think the current orphan rule works pretty well ecosystem wide, on balance.

this is the "I'm lazy" button

This is the "everyone will use unsafe to avoid the borrow checker" argument. I don't think it follows.

If you use this liberally in a large codebase at a company large enough for whom build times are a problem

Right, so don't do that. There's lots of ways to influence how a feature gets used to make sure it gets used in the ways one would like, and avoid bad patterns. There's a reason I keep saying "small scale", "limited", "local scope", "opt in", precisely because this is something that should be used carefully, and all the tooling should push users in that direction. But also because it hasn't been implemented yet, so it will take some practical experience to really understand the implications.

I understand your concerns, I really do. I'd also push back if this were "let's stabilize this tomorrow". I posted this because I want feedback on the technical aspects, to see if there's a fundamental soundness problem with the proposal, or something that makes it surprisingly hard to implement within current rustc. If it passes that bar, and we get to prototyping it, and merging it, it ends up as one of the many experiments currently live within rustc. Either we'll find value for it, in which case it stays (perhaps perma-unstable) or we drop it. I just don't think we're at the "this is an inherently flawed idea that is morally wrong" phase of the conversation yet.

I think it should be considered a very big open question whether or not this even fixes the build time problem as well. What evidence exists that this is a large gain? Looking through the RFC I don't see that addressed.

Yeah, that will need a working prototype to answer. Or maybe a local hack to just disable the orphan rule altogether so we can try it out, but that's later.

And while build perf isn't the only consideration for this feature, there's examples like this where useful functionality in a common crate is dropped in service of build time. Something like this RFC would make it much easier to have opt-in functionality.

This will require changes in rust-analyzer and whatever other tools

Sure.


```
[dependency]
my_definitions = { version = "0.1", path = "../my_definitions", impl = True }
Copy link
Contributor

@teor2345 teor2345 Sep 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: booleans are lower case in toml
https://toml.io/en/v1.0.0#boolean

Edit: This applies to every "True" in the RFC.

Suggested change
my_definitions = { version = "0.1", path = "../my_definitions", impl = True }
my_definitions = { version = "0.1", path = "../my_definitions", impl = true }

fine at small scalle, but at large scales it would mean that if there were two
crates with conflicting implementations, then could never appear in the same
dependency graph. That is a change to private dependencies, which wouldn't
usually be considered compatibility breaking, would cause downstream build
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be clearer phrasing?

Suggested change
usually be considered compatibility breaking, would cause downstream build
usually be considered compatibility breaking, but can now cause downstream build

@Diggsey
Copy link
Contributor

Diggsey commented Sep 7, 2023

This is the "everyone will use unsafe to avoid the borrow checker" argument. I don't think it follows.

Unsafe doesn't disable the borrow checker though. If it did, then people using unsafe incorrectly would be a much bigger issue than it is.

@ahicks92
Copy link

ahicks92 commented Sep 7, 2023

It's not the "everyone will use unsafe" argument because unsafe is hard to use even if you're doing it right, and the people who find Rust come to Rust to avoid unsafe. Someone can't easily decide to use unsafe because using unsafe is hard. Additionally using unsafe is local: it might reduce the code quality of one file/module/crate but it doesn't change anything much about project organization. All operations one might wish to perform at the crate level can still work. Additionally it's very easy to understand why unsafe introduces bugs. On top of all of that, it's trivially easy to explain why unsafe is necessary and why Rust as a language would be impossible without it.

This flag takes an error that is hard to explain to new Rust users which exists for good-yet-esoteric reasons and silently makes it go away. It doesn't introduce bugs today. It's not syntactic in the code; everyone who comes after will "use" it without knowing that it was turned on. I would agree that it's the "everyone will use unsafe" argument if it wasn't entirely invisible and/or was a holistic solution to the problem.

Also, that said, for something which appears to be mostly about build times, I think an RFC without some sort of prototype/proof that it will help isn't a great idea. A proper implementation is likely not a small change in the compiler even though it seems like it should be, and (for example) probably means modifying the outputted crate metadata. Perhaps there is a hacky way to prototype it instead.

We have one of these large monorepos at work which takes 12 minutes or so to build from scratch in CI, and we wouldn't benefit from this RFC for build times because:

  • This assumes heavy use of traits. Only some codebases need heavy use of traits.
  • This assumes a bottleneck in the dependency graph in your own code which is so problematic that this RFC can fix it. In our case we do microservices, and so there's one bottleneck at the bottom and then like 7 or 8 services that build fully in parallel.
  • This assumes you're doing cargo build at the workspace root. You probably should be, but if you don't the build semantics change.
  • Even assuming you hit all of those, this assumes you don't make firm choices like "only support Python 3" or "always use Postgres" or etc. and that the less-firm choice where everyone does what they want would require significant orphan rule breaking.
    • failing that, this assumes that one uses a lot of generated code in a way that requires significant orphan rule breaking, or some other code structure along those lines that makes orphan rules the problem.
  • This assumes your org decided to use a monorepo rather than Cargo's support for git dependencies in the first place (we're a monorepo but the point is important to point out).
  • This assumes that your bottleneck isn't the final linking step (ours is, for incremental builds)
  • This assumes you're not using lots of dependencies. Even if you've got a large codebase locally it's entirely possible most of the time goes to dependencies especially if you e.g. bring in a web crate, some db crates, etc.
  • And, as regards CI specifically, this assumes your tests aren't the bottleneck.

Which is why I'm somewhat skeptical. It requires a lot of assumptions before we can take it as a given that build times go down.

@pellico
Copy link

pellico commented Sep 7, 2023

I would propose a very different approach that could solve the basic requirements and treats impl block as first citizen entity like trait and types.
The main idea is to allow overriding locally to the module the implementation of the trait/type by selecting explicitly alternate implementation.

/// Module in pkg3
pub mod my_mod {
use pkg1::my_trait
use pkg2::my_type
// This implementation override ONLY LOCALLY to this module the implementation in my_trait or in my_type (if it exists)
// This impl block has no effect outside this module and it cannot be referenced by any other module.
impl my_trait for my_type {
....
}
....
}

It should be possible to make public the implementation to allow usage in different modules/packages using pub keyword

/// Module in pkg3
pub mod my_mod {
use pkg1::my_trait
use pkg2::my_type
// This implementation override ONLY LOCALLY to this module the implementation in my_trait or in my_type if it exist
// Now it can be used in other modules
pub impl my_trait for my_type {
....
}
....
}

If a developer would like to previous implementation it has to declared explicitly

/// Module in pkg4
mod another_mod {
// Override the implementation in my_trait or my_type and bring automatically in scope  pkg1::my_trait and pkg2::my_type
use pkg3::impl(pkg1::my_trait,pkg2::my_type) // Likely there is a different nicer syntax :-)

// Equivalent code
use pkg1::my_trait
use pkg2::my_type
use pkg3::impl(my_trait,my_type)

}

I see the following benefits:

  • adding other implementation is not breaking any code due to the explicit usage inside the module.
  • locally explicit declaration and dependencies
  • allow usage of different implementation in the same crate
  • backward compatible.
  • no issue with crates.io (?)

@burdges
Copy link

burdges commented Sep 7, 2023

I think "local impls" should be done by delegation, but..

There is a large design space for delegation, so someone should explore this space. In particular you've this flavor:

All traits of ForeignType work on LocalType, and even perceive LocalType as ForeignType, but new traits can be defined on LocalType too, and they perceive LocalType as being a different type from ForeignType.

type LocalType = delegate ForeignType;

@Ixrec
Copy link
Contributor

Ixrec commented Sep 10, 2023

For the very specific goal of optimizing build times of code which can be theoretically built in parallel after certain shared trait/type definitions are built first... I feel like crates and orphan rules are a red herring, and we should be asking if the compiler can be told that information directly. The same way we use #[inline] because that manual annotation is often just more practical than making the compiler spend time guessing what's more efficient.

As a pure strawman, I'm imagining a #[build_this_module_first] attribute, and maybe a Cargo.toml flag like build_my_modules_in_parallel where for build purposes only cargo tries to draw a module dependency graph within the crate (which errors if there's a cycle), and parallelize its build of that crate accordingly. Is that completely ridiculous? How far off is that from rustc's current parallelization logic of crates/codegen units/etc?

@clarfonthey
Copy link
Contributor

Very, very weird case for this that came up when discussing the situation where ToOwned is currently defined in alloc, but technically could be defined in core. This comment is an absolute mess but hopefully it contains some insight, even though it may actually be impractical.

It might be interesting to loosen this to "deferred impls" where the crate explicitly states that the implementation is "deferred" to some dependency. This both strengthens and weakens the constraints, by allowing you to place restrictions on arbitrary types while requiring you to explicitly enumerate them.

Ultimately, the way this would be handled on the compiler side is by using either negative impls or reserved impls in the crate that can then be overridden in the explicitly allowed crate with a proper implementation.

Of course, the main issue here is that it defeats the original purpose: depending on crates which have these types. So, it would require some shenanigans where cargo lets you reference this crate from the same workspace for the sake of listing the allowed impls, but doesn't actually compile that code unless the dependency is visible anywhere in the tree. Something along the lines of a special, automatically enabled crate feature.

@Houtamelo
Copy link

What if, at least, developers could entirely disable the orphan rules for crates that will never be a dependency to another?

The idea: Orphan rules should only be enforced on crates published on crates.io

There's no reason why the "end-stream" user should have to abide to such rules.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
T-lang Relevant to the language team, which will review and decide on the RFC.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet