|
|
Log in / Subscribe / Register

Rust's next-generation trait solver

By Daroc Alden
March 30, 2026

Rust's compiler team has been working on a long-term project to rewrite the trait solver — the part of the compiler that determines which concrete function should be called when a programmer uses a trait method that is implemented for multiple types. The rewrite is intended to simplify future changes to the trait system, fix a handful of tricky soundness bugs, and provide faster compile times. It's also nearly finished, with a relatively small number of remaining blocking bugs.

Trait solving

Traits in Rust are similar to typeclasses in Haskell or interfaces in Java: they are a set of associated functions that can be implemented for different types. Library code can use the functions from a trait on any type that implements the trait, even if that type is foreign to the library. When it comes time to generate code, however, the compiler needs to know which specific type is being used, and where its trait implementation lives, so that it can call the correct function. Trait implementations can be generic, and therefore apply to multiple concrete types. Figuring out which trait implementation is relevant to a particular type is the job of the compiler's trait solver.

Most of the time, this is straightforward: the compiler just needs to find a trait implementation of the form impl Trait for Type {...}. The problem grows more complex when considering generic types. For example, a Vec can be cloned if and only if the type of elements it contains can be cloned. In the standard library, there is a trait implementation block that is equivalent to the following (after expanding some syntactic sugar and disregarding the irrelevant A parameter):

    impl<T> Clone for Vec<T>
    where T: Clone
    { ... }

This declaration can be viewed as an instruction for the trait solver: in order to find the Clone implementation for a Vec<Foo>, first find the Clone implementation for Foo. That implementation can be used to construct a working implementation for Vec<Foo>. The current trait solver follows these chains of instructions in a straightforward way. For each trait implementation that must be found, it determines whether it is best resolved through finding an impl statement or through using a where-bound (i.e. a where clause asserting that some generic type implements a trait). These can depend on other trait implementations existing, as in the Vec example, in which case the compiler adds them to a work list. In logic programming, these kinds of prerequisites that must be satisfied for another goal to be reached are called obligations; the compiler keeps taking obligations from the work list and processing them until they have all been eliminated. If they cannot be, an error is indicated.

This approach breaks down with more complicated types, however. It is possible to construct a situation where the obligations to be discharged form a loop. Doing so is a bit complicated, however, so even a minimal example is somewhat long:

    // Create a trait that has an associated generic type. Types that implement
    // Wrap correspond to a particular choice of Wrapper.
    trait Wrap {
        type Wrapper<T>;
    }

    // A singly linked list structure, with the payload omitted for brevity.
    // Each 'next' pointer goes to another list node wrapped in the Wrapper type
    // for some generic type W that must implement Wrap
    struct Link<W: Wrap> {
        next: Option<Box<W::Wrapper<Link<W>>>>
    }

    // Link structures can be formatted for debug print statements when their
    // contents can be — the omitted implementation calls the Debug
    // implementation for next's type, hence the second requirement.
    impl<W> Debug for Link<W>
    where W: Wrap,
          Option<Box<W::Wrapper<Link<W>>>>: Debug {
        ...      
    }

    // My Wrapper is a structure that wraps some other type T as its only field.
    struct MyWrapper<T = ()>(T);

    // And its associated wrapper type is just itself.
    impl Wrap for MyWrapper {
        type Wrapper<T> = MyWrapper<T>;
    }

    // Finally, wrappers can be formatted for debug print statements when the
    // wrapped value can be.
    impl<T> Debug for MyWrapper<T>
    where T: Debug { ... }

In order for the compiler to prove that Link<MyWrapper> implements Debug, it needs to show that a whole chain of requirements holds:

    Link<MyWrapper>: Debug ->
    Option<Box<MyWrapper::Wrapper<Link<MyWrapper>>>>: Debug ->
    Box<MyWrapper::Wrapper<Link<MyWrapper>>>: Debug ->
    MyWrapper::Wrapper<Link<MyWrapper>>: Debug ->
    Link<MyWrapper>: Debug

... but that chain eventually leads the compiler right back to where it started. Attempting to compile a Rust program that uses the Debug implementation of Link results in the error: "overflow evaluating the requirement `Link<MyWrapper>: Debug`".

In this case, however, it should be possible in theory for the compiler to generate an implementation of Debug for the same reason that assemblers can handle instructions that refer to their own address: the compiler can start generating the implementation, leaving a placeholder for the address, and then fill the address in once it knows it. At the stage where the trait solver runs, it isn't dealing with actual addresses, but the same idea applies; writing the Debug implementation for Link<MyWrapper> is a matter of "tying the knot" in a data structure representing the code to produce a structure that refers to itself.

The above example is contrived, but that's because this limitation of the language tends to come up in complex, highly generic code. Self-referential types are nothing new; it's only trying to abstract over them with traits that causes the problem. Code like that is not something that every programmer will need to write, but it is something that can make Rust libraries more flexible. Currently, crate authors need to work around this trait resolution problem by restructuring their interfaces to avoid creating loops in the trait solver.

A new approach

The trait solver's behavior isn't an inherent aspect of the problem, however. It's an implementation choice that was made essentially by default as the language's trait system slowly became more complex. The project to rewrite the trait solver began in 2015 with Chalk, an attempt to formalize Rust's type system. It compiles the rules of the type system — such as the mechanics of trait solving — into a Prolog-like system of logical inference rules that can then be solved generically with well-known logic-programming techniques. Chalk has two internal implementations of logical inference with their own strengths and weaknesses: the SLG solver (which uses an approach based on Prolog tabling) and the recursive solver, which more closely matches the operation of Rust's original trait solver. The SLG solver can handle some kinds of loops that the recursive solver cannot.

Rust developers determined that Chalk wasn't a good fit for the Rust compiler, however. One major concern was with maintaining the usefulness of error messages; the current implementation produces good error messages for simple mistakes, but Chalk's error messages have lagged behind. In 2023, the project decided to rewrite the trait solver within the compiler, using lessons from the first implementation and from Chalk.

The rewrite, called the next-generation trait solver, makes use of caching to both speed up trait solving and to defeat cycles. When called upon to start finding a trait implementation for a type, it starts by assuming that such an implementation will be found, and making a spot for it in the cache — it is marked as provisionally true, but cannot yet be fully relied on. Then the solver follows the chain of logical inferences in much the same manner as the current solver, with the exception that it looks results up in the cache when possible. When it derives a new piece of information, that is added to the cache; if deriving the new information relied on any provisional cache entries, the new entry is also marked as provisional.

If the trait solver ever reaches a point where the only remaining logical obligations refer to provisionally true cache entries, the entries are upgraded to actually true and can be referenced by other type-checking operations. On the other hand, if it doesn't reach such a point, the provisional cache entries are invalidated, and that branch of the computation is considered failed. It will either fail entirely (and show an error to the user), or continue trying some alternative potential solution.

In the case of the example above, when the new solver reached the state of needing to prove that Link<MyWrapper> implements Debug for the second time, it would see that the trait implementation had been cached as provisionally true by the previous exploration and (lacking other logical obligations) conclude that Link<MyWrapper> really does implement Debug.

Currently, the next-generation trait solver only extends this notion of provisional cache entries to a limited number of built-in traits, such as Send, but the plan is to eventually enable the behavior for all traits. The Rust developers are fairly sure that enabling this feature will just allow some code (like the above example) that doesn't currently compile to work, but since this is a logically intricate piece of code on which the correctness of the type system depends, they don't want to be too hasty. The next-generation trait solver itself is currently marked experimental for much the same reason.

One other interesting aspect of the next-generation trait solver is canonicalization. Since its design relies heavily on caching, the more types that can be cached, the faster compilation using the new trait solver will be. For that reason, logical obligations encountered in the course of trait solving are rewritten into a format that removes as many type variables and intermediate results as possible. This also has the nice effect of making it easy to reconstruct proof trees explaining how the compiler reached a particular answer. These are used to create more detailed error messages by explaining which options were tried and why they didn't work. Since the proof trees don't need to be generated unless an error message needs to be shown, being able to reconstruct them from the cache avoids the overhead of constructing them for code that does compile.

Trying it out

The next-generation trait solver is already used in stable Rust for coherence checking (that is, checking that trait implementations do not overlap), but is not yet used for general trait solving. In the nightly release of the compiler, the -Znext-solver=globally flag uses the next-generation trait solver for all trait-related type-system computations. The Rust developers have already used Crater to check that the flag does not cause compile errors for crates publicly released on crates.io, but would like to know if the flag causes problems for anyone's code prior to stabilization.

There are currently 76 open bugs related to the new trait solver, and 78 closed ones. The feature has made more progress than that suggests, however, since several of the closed bugs are rather large, and the remaining bugs are mostly internal compiler errors or performance problems that the Rust developers would like to see addressed. The new trait solver is intended to be both faster and more maintainable than the previous implementation, but whether that promise bears out remains to be seen. In either case, Rust developers may soon have the option to write more-complicated self-referential trait-based interfaces.



to post comments

circular reasoning is a potential source of unsoundness

Posted Mar 30, 2026 15:05 UTC (Mon) by garyguo (subscriber, #173367) [Link] (10 responses)

> In this case, however, it should be possible in theory for the compiler to generate an implementation of Debug for the same reason that assemblers can handle instructions that refer to their own address: the compiler can start generating the implementation, leaving a placeholder for the address, and then fill the address in once it knows it.

This can be quite worrying because it can expose the type system to circular reasoning. Trait bounds are obligations that the trait system needs to prove, and if the trait system can make use of a bound that it is proving, would the corresponding mathematical reasoning with Curry–Howard correspondence be flawed as well?

Having said that, I've tried out the example in article and it is still rejected with `-Znext-solver=globally` with "overflow evaluating the requirement `Link<MyWrapper>: Debug`", so it's a relief.

circular reasoning is a potential source of unsoundness

Posted Mar 30, 2026 15:22 UTC (Mon) by willy (subscriber, #9762) [Link]

I believe the error you're seeing is explained by the part of the article that says this is only enabled for some built-in traits now, but will be extended to all traits in the future?

circular reasoning is a potential source of unsoundness

Posted Mar 30, 2026 15:29 UTC (Mon) by daroc (editor, #160859) [Link] (5 responses)

I didn't go as deep into the weeds as I could have with the details of when circular reasoning is or isn't sound; the Rust compiler documentation that I linked goes into more detail. There are two different kinds of cycles that one can have in a type system: inductive and coinductive. Inductive cycles are always unsound (and the plan remains to have the Rust type system reject them). Coinductive cycles can be sound, with the right logical foundations.

Via the Curry-Howard correspondence, an inductive cycle is an infinite loop, or a recursive function without a base case. A coinductive cycle, on the other hand, corresponds to something like a generator: a process that continues indefinitely, but does some additional work at each step such that, when consumed by a process that doesn't try to reach the "end" of the sequence, it all works out.

The main thing is that, when attempting to find a coinductive proof, it's necessary to ensure that the cycle has a length greater than one (or, to use type theory jargon, that the loop is productive). Note that that doesn't prevent the actual generated code from being an infinite loop (because Rust doesn't consider that to be unsound, and a programmer could already write a trait implementation that way), but it does prevent a "impl<T> Trait for T where T: Trait" situation, which is probably undesirable.

Right now, the next-generation solver will only do coinductive reasoning for a limited number of autotraits (Send, etc.). If/when it can be shown safe to extend this to user-defined traits, then it will be. But since this whole area is complex and subtle, the types team is moving slowly and making sure they don't introduce unsoundness.

circular reasoning is a potential source of unsoundness

Posted Mar 30, 2026 17:10 UTC (Mon) by NYKevin (subscriber, #129325) [Link] (4 responses)

The "obvious" concern I would have with this is user-defined unsafe traits.

In the case of Send and Sync, the traits impose a negative obligation. Types in Rust are thread-safe by default, and they can only become thread-unsafe through the use of interior mutability or raw pointers. So it's entirely reasonable to coinductively infer Send and Sync. If you never encounter something manifestly !Send or !Sync in your chain of reasoning, then there can't be any source of thread-unsafety, so inferring Send or Sync is fine.

On the other hand, the unsafe trait TrustedLen imposes a positive obligation. Specifically, if a type implements TrustedLen, then unsafe code may soundly assume that its len() method returns the right value, and execute UB if that assumption turns out to be wrong. In pragmatic terms, that means the Source of Truth for len() must be hidden behind some visibility annotation so that foreign safe code cannot mess with it. If len() is computed by calling into something else, then that callee is under the same obligation, transitively all the way down to the ground truth.

In the case of TrustedLen, I don't think there exists a counterexample where coinduction actually produces an unsound implementation. Coinduction implies, to my mind, that you end up with TrustedLen-or-some-equivalent all the way down, and there's no actual soundness hole. But it is the sort of thing that would keep me awake at night if I were responsible for trait-solving in Rust. Users can define arbitrary unsafe traits with arbitrary soundness requirements, and those requirements typically exist only in documentation or comments, not in actual code the compiler can read and understand. Who is to say whether any one of those unsafe traits might be incompatible with coinduction?

circular reasoning is a potential source of unsoundness

Posted Mar 30, 2026 17:34 UTC (Mon) by daroc (editor, #160859) [Link] (2 responses)

I suspect the answer is the same as any other unsafe thing that breaks the language: "don't do that". You can't have a coinductive loop without at least one trait implementation block, so for an unsafe trait, that unsafe trait implementation is the specific use of unsafe that could break things.

But yes, one of the remaining open items to work on before the next-generation trait solver can be stabilized is documentation, which will presumably include documenting how any proposed changes to the type system interact with unsafe code.

circular reasoning is a potential source of unsoundness

Posted Mar 31, 2026 1:22 UTC (Tue) by NYKevin (subscriber, #129325) [Link] (1 responses)

Sure, but what happens if someone has already constructed an incomplete loop, which is only sound in the current version of Rust because coinduction is impossible (and therefore the loop cannot be closed)? You could imagine a situation where the "missing link" of the loop is implementable in safe code, and that would retroactively render the existing (unsafe) implementations unsound.

I don't think you can fix it with an edition, either. Trait solving is semantic, not syntactic.

circular reasoning is a potential source of unsoundness

Posted Mar 31, 2026 11:49 UTC (Tue) by daroc (editor, #160859) [Link]

Oh, I see your point! That's an interesting thought.

You're right; I'm not sure how that ought to be handled. I don't think the list of considerations for unsafe code to be aware of have changed very much over time.

Perhaps it would still be possible to make it an edition change by tagging each trait implementation with which edition it comes from, and disallowing coinductive cycles that dip into previous editions, but that's pure speculation on my part.

circular reasoning is a potential source of unsoundness

Posted Mar 31, 2026 3:36 UTC (Tue) by theemathas (guest, #183008) [Link]

This exact issue has been raised in https://github.com/rust-lang/rust/issues/149743

There's currently no good answer to this yet.

circular reasoning is a potential source of unsoundness

Posted Mar 30, 2026 18:57 UTC (Mon) by iabervon (subscriber, #722) [Link]

I think it would be unsound if you could say "impl Debug for Link where Link: Debug {}", but that's not allowed, and the impl thing that is allowed is really saying: this type has Debug, and I'll tell you how, but I need to be able to call its methods if so. It's more of an induction step than circular reasoning: an object of this type has this trait if any other objects of this type that it references have the trait, and any other obligations are met.

circular reasoning is a potential source of unsoundness

Posted Apr 1, 2026 15:43 UTC (Wed) by smoofra (subscriber, #86163) [Link]

I think the right way to think about it is not that circular reasoning, or impredicativity is a source of unsoundness, but that predicativity is just a really powerful tool to constrain logics so they don't accidentally become unsound.

Just like it's absurdly easy for a programming language to accidentally become Turing-complete, it's absurdly easy for a logic to accidentally become unsound. But I don't think that impredicativity is the source of the unsoundness. There's impredicative logics like say, second order arithmetic that are sound. There's probably whole worlds of them nobody has thought about yet. But it's easier to think about soundness and prove it for predicative logics.

circular reasoning is a potential source of unsoundness

Posted Apr 12, 2026 1:31 UTC (Sun) by suckfish (guest, #69919) [Link]

In mathematical terms, the "circular reasoning" appears to arise from taking the greatest-fixed-point of an operator or some sort or partially ordered structure as opposed to the more common least-fixed-point.

How to do this soundly and then reason correctly about the results has been well understood for over 30 years in the functional programming community.

Soundness bugs

Posted Mar 30, 2026 16:00 UTC (Mon) by mb (subscriber, #50428) [Link] (1 responses)

Is there more information about the soundness bugs that the new trait solver enables fixing?

Soundness bugs

Posted Mar 30, 2026 17:40 UTC (Mon) by daroc (editor, #160859) [Link]

They keep coming up occasionally, and typically get patched as they're found, but there are a handful open at any given time. The hope is that the simpler, more logical next-generation solver code will be less prone to these things than the incrementally grown current implementation. Here are some bugs that I believe should be addressed by the rewrite that are currently open:

https://github.com/rust-lang/rust/issues/135011
https://github.com/rust-lang/rust/issues/152489

Faster

Posted Mar 30, 2026 16:38 UTC (Mon) by claudex (subscriber, #92510) [Link] (2 responses)

It says it's faster but what's the order of magnitude? 3% ? 30% ? (Not that's important, but since it's mentioned, I'm curious)

Faster

Posted Mar 30, 2026 17:42 UTC (Mon) by daroc (editor, #160859) [Link] (1 responses)

If anyone has done a specific head-to-head performance comparison, I was unable to track it down. I believe the hope is that it will be faster because it is (conceptually) simpler and affords more opportunities for caching intermediate results. Hopefully a rigorous performance comparison is part of the plan for stabilization.

Faster

Posted Apr 3, 2026 19:27 UTC (Fri) by mathstuf (subscriber, #69389) [Link]

There are performance metrics in "This Week in Rust" posts (linked from the Weekly Edition here) as things land. I wouldn't be surprised to see a bit more analysis there when this lands.

Superb article

Posted Mar 30, 2026 23:58 UTC (Mon) by CChittleborough (subscriber, #60775) [Link] (1 responses)

This is a great piece of technical writing.

It was a good day for LWN and its readers when Daroc and JZB were hired.

Superb article

Posted Mar 31, 2026 0:49 UTC (Tue) by jake (editor, #205) [Link]

> It was a good day for LWN and its readers when Daroc and JZB were hired.

You are not alone in thinking that :)

jake


Copyright © 2026, Eklektix, Inc.
This article may be redistributed under the terms of the Creative Commons CC BY-SA 4.0 license
Comments and public postings are copyrighted by their creators.
Linux is a registered trademark of Linus Torvalds