From 041e6ab9f021475ad4b1f326bbbdef775c5c1e30 Mon Sep 17 00:00:00 2001 From: Till Adam Date: Sat, 25 Jul 2026 00:01:26 +0200 Subject: [PATCH] perf: avoid re-running the trait-solver fast path on unchanged obligations `FulfillmentCtxt::try_evaluate_obligations` re-processes every pending ambiguous obligation on every call. For obligations resolved by `compute_goal_fast_path`, the `Certainty::Maybe` branch discarded the obligation's `GoalStalledOn` (re-registering with `None`), which meant every subsequent call fully re-ran the fast path's predicate resolution (`shallow_resolve` + `is_trivially_wf`) instead of being able to skip it, even though the real solver's own internal stall-check exists precisely to avoid this. Reconstruct a `GoalStalledOn` for these obligations by walking the goal's predicate for the inference variables it depends on (`fast_path_stalled_on`), and check it up front with the same primitives the solver's internal fast path already uses (`is_still_stalled`), so an unchanged obligation is skipped before touching the solver at all. This is a constant-factor fix, not asymptotic (the per-call scan over all pending obligations remains O(n)). Measured: prefill 7.79s -> 4.35s (1.79x), full self-analysis inference 24.4s -> ~17-18s (~1.4x), up to 3.9x per-obligation on a synthetic worst case. Full-repo differential (unknown type / type mismatches / pattern unknown type / pattern type mismatches / mir failed bodies / failed const evals) identical before/after; `cargo test -p hir-ty` 1015/0 both ways; clippy clean. --- crates/hir-ty/src/next_solver/fulfill.rs | 102 +++++++++++++++++- .../next_solver/infer/opaque_types/table.rs | 9 ++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/crates/hir-ty/src/next_solver/fulfill.rs b/crates/hir-ty/src/next_solver/fulfill.rs index e422f75a0207..57fa4ade6012 100644 --- a/crates/hir-ty/src/next_solver/fulfill.rs +++ b/crates/hir-ty/src/next_solver/fulfill.rs @@ -8,15 +8,17 @@ use rustc_next_trait_solver::{ solve::{GoalEvaluation, GoalStalledOn, HasChanged, SolverDelegateEvalExt}, }; use rustc_type_ir::{ - Interner, TypeSuperVisitable, TypeVisitable, TypeVisitableExt, TypeVisitor, - inherent::IntoKind, - solve::{Certainty, NoSolution}, + InferConst, InferCtxtLike, InferTy, Interner, TyVid, TypeSuperVisitable, TypeVisitable, + TypeVisitableExt, TypeVisitor, + inherent::{IntoKind, OpaqueTypeStorageEntries as _}, + solve::{Certainty, Goal, NoSolution}, }; use crate::{ Span, next_solver::{ - DbInterner, SolverContext, SolverDefId, Ty, TyKind, TypingMode, + Const, ConstKind, DbInterner, GenericArg, Predicate, SolverContext, SolverDefId, Ty, + TyKind, TypingMode, infer::{ InferCtxt, traits::{PredicateObligation, PredicateObligations}, @@ -164,6 +166,19 @@ impl<'db> FulfillmentCtxt<'db> { let mut any_changed = false; self.try_evaluate_obligations_scratch.extend(self.obligations.drain_pending(|_| true)); for (mut obligation, stalled_on) in self.try_evaluate_obligations_scratch.drain(..) { + // If we've evaluated this goal before and it stalled on a specific set of + // inference variables, and none of those variables have been touched since, + // evaluating it again is guaranteed to produce the same `Maybe` result (goal + // evaluation is pure). Skip it entirely rather than re-running the solver, so + // that re-processing pending obligations is proportional to how much changed + // since the last call, not to the total number of pending obligations. + if let Some(stalled_on) = &stalled_on + && is_still_stalled(infcx, stalled_on) + { + self.obligations.register(obligation, Some(stalled_on.clone())); + continue; + } + if obligation.recursion_depth >= infcx.interner.recursion_limit() { self.obligations.on_fulfillment_overflow(infcx); // Only return true errors that we have accumulated while processing. @@ -178,7 +193,8 @@ impl<'db> FulfillmentCtxt<'db> { match certainty { Certainty::Yes => {} Certainty::Maybe { .. } => { - self.obligations.register(obligation, None); + self.obligations + .register(obligation, fast_path_stalled_on(infcx, goal)); } } continue; @@ -278,6 +294,82 @@ impl<'db> FulfillmentCtxt<'db> { } } +/// Mirrors the stalled-goal fast path that `evaluate_goal_raw` runs internally on every +/// evaluation: if none of the tracked variables changed (and the opaque type storage didn't +/// grow) since `stalled_on` was recorded, the goal is guaranteed to still evaluate to the same +/// `Maybe` result. Running this cheap check *before* touching the solver at all is what makes +/// `try_evaluate_obligations` proportional to the obligations actually woken since the last +/// call rather than to the total pending set. +fn is_still_stalled<'db>( + infcx: &InferCtxt<'db>, + stalled_on: &GoalStalledOn>, +) -> bool { + !infcx.disable_trait_solver_fast_paths() + && !stalled_on.stalled_vars.iter().any(|&value| infcx.is_changed_arg(value)) + && !stalled_on.sub_roots.iter().any(|&vid| infcx.sub_unification_table_root_var(vid) != vid) + && !infcx.opaque_types_storage_num_entries().needs_reevaluation(stalled_on.num_opaques) +} + +/// Best-effort reconstruction of a [`GoalStalledOn`] for goals resolved by +/// [`crate::next_solver::solver::SolverContext::compute_goal_fast_path`]'s `Certainty::Maybe` +/// result. +/// +/// That fast path (unlike the real solver) does not build a `GoalStalledOn`, so without this +/// we would lose all stall-tracking for the obligations it intercepts, forcing them through the +/// fast path again on every single call to `try_evaluate_obligations` for the rest of the body. +/// We over-approximate by collecting every inference variable mentioned in the goal's +/// predicate: extra entries only make `is_still_stalled` wake the goal on unrelated changes, +/// which is wasteful but never unsound, whereas missing one could permanently mask real +/// progress. +fn fast_path_stalled_on<'db>( + infcx: &InferCtxt<'db>, + goal: Goal, Predicate<'db>>, +) -> Option>> { + struct CollectInferVars<'a, 'db> { + infcx: &'a InferCtxt<'db>, + stalled_vars: Vec>, + sub_roots: Vec, + } + + impl<'db> TypeVisitor> for CollectInferVars<'_, 'db> { + type Result = (); + + fn visit_ty(&mut self, ty: Ty<'db>) { + match ty.kind() { + TyKind::Infer(InferTy::TyVar(vid)) => { + self.stalled_vars.push(ty.into()); + self.sub_roots.push(self.infcx.sub_unification_table_root_var(vid)); + } + TyKind::Infer(_) => self.stalled_vars.push(ty.into()), + _ if ty.has_infer() => ty.super_visit_with(self), + _ => {} + } + } + + fn visit_const(&mut self, ct: Const<'db>) { + match ct.kind() { + ConstKind::Infer(InferConst::Var(_)) => self.stalled_vars.push(ct.into()), + _ if ct.has_infer() => ct.super_visit_with(self), + _ => {} + } + } + } + + let mut collector = CollectInferVars { infcx, stalled_vars: Vec::new(), sub_roots: Vec::new() }; + goal.predicate.visit_with(&mut collector); + if collector.stalled_vars.is_empty() { + // We couldn't pin down what this goal is blocked on (e.g. the ambiguity came from the + // param-env rather than the predicate); fall back to always rechecking it. + return None; + } + Some(GoalStalledOn { + num_opaques: infcx.opaque_types_storage_num_entries().opaque_type_count(), + stalled_vars: collector.stalled_vars, + sub_roots: collector.sub_roots, + stalled_certainty: Certainty::AMBIGUOUS, + }) +} + /// Detect if a goal is stalled on a coroutine that is owned by the current typeck root. /// /// This function can (erroneously) fail to detect a predicate, i.e. it doesn't need to diff --git a/crates/hir-ty/src/next_solver/infer/opaque_types/table.rs b/crates/hir-ty/src/next_solver/infer/opaque_types/table.rs index 894fe5eb7b87..bce877fde8d7 100644 --- a/crates/hir-ty/src/next_solver/infer/opaque_types/table.rs +++ b/crates/hir-ty/src/next_solver/infer/opaque_types/table.rs @@ -27,6 +27,15 @@ pub struct OpaqueTypeStorageEntries { duplicate_entries: usize, } +impl OpaqueTypeStorageEntries { + /// The raw entry count, for constructing a [`rustc_next_trait_solver::solve::GoalStalledOn`] + /// outside of the solver's own canonicalization (which is where `num_opaques` is normally + /// computed from). + pub(crate) fn opaque_type_count(self) -> usize { + self.opaque_types + } +} + impl rustc_type_ir::inherent::OpaqueTypeStorageEntries for OpaqueTypeStorageEntries { fn needs_reevaluation(self, canonicalized: usize) -> bool { self.opaque_types != canonicalized