Skip to content

Perf: Speed up solver#11566

Open
parsonsmatt wants to merge 3 commits intohaskell:masterfrom
parsonsmatt:mattp/speed-up-solver
Open

Perf: Speed up solver#11566
parsonsmatt wants to merge 3 commits intohaskell:masterfrom
parsonsmatt:mattp/speed-up-solver

Conversation

@parsonsmatt
Copy link
Collaborator

This PR speeds up the solver part of cabal. On our codebase, this reduces solver time from 5.7s to 4.1s.


Please read Github PR Conventions and then fill in one of these two templates.


Template Α: This PR modifies behaviour or interface

Include the following checklist in your PR:


Template B: This PR does not modify behaviour or interface

E.g. the PR only touches documentation or tests, does refactorings, etc.

Include the following checklist in your PR:

  • Patches conform to the coding conventions.
  • Is this a PR that fixes CI? If so, it will need to be backported to older cabal release branches (ask maintainers for directions).

Copy link
Collaborator Author

@parsonsmatt parsonsmatt left a comment

Choose a reason for hiding this comment

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

Running locally, I'm seeing solver times between 3.75 and 4s with this patch, and 5-6.24s without. Pretty solid improvement, and nothing terribly complex for it.

Comment on lines +82 to +84
-- | Version range with an 'Ord' instance. The show string is cached to avoid
-- recomputing it on every comparison.
data OrderedVersionRange = OVR !String !VR
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This change is likely to reduce allocations at the cost of greater resident memory - rather than generating and throwing away the strings for every compare, we're holding on to them.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Could we use Distribution.Utils.ShortText instead of String? This should reduce resident memory and make instance Ord OrderedVersionRange even faster.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🚀 heck yes 🚀

instance Ord OrderedVersionRange where
compare = compare `on` show
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

funny - because we'd be doing "OrderedVersionRange ..." as the prefix of every string, a Set ORderedVersionRange would be a linked list for that prefix. This is delegating to show on the VR which may be similarly disadvantaged, but at least we're caching

Copy link
Collaborator

Choose a reason for hiding this comment

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

It would be, but a drop 20 on the String would resolve that cheaply?

Comment on lines +270 to +275
qo = defaultQualifyOptions idx

couldResolveConflicts :: QPN -> POption -> S.Set CS.Conflict -> Maybe ConflictSet
couldResolveConflicts currentQPN@(Q _ pn) (POption i@(I v _) _) conflicts =
let (PInfo deps _ _ _) = idx M.! pn M.! i
qdeps = qualifyDeps (defaultQualifyOptions idx) currentQPN deps
qdeps = qualifyDeps qo currentQPN deps
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

qo shared between invocations- possibly not required with let floating but 🤷🏻

@@ -301,7 +301,7 @@ checkComponentsInNewPackage :: ComponentDependencyReasons
-> Map ExposedComponent ComponentInfo
-> Either Conflict ()
checkComponentsInNewPackage required qpn providedComps =
case M.toList $ deleteKeys (M.keys providedComps) required of
case M.toList $ M.difference required providedComps of
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

should be algorithmically better here

Copy link
Collaborator

Choose a reason for hiding this comment

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

Nice!

@parsonsmatt parsonsmatt marked this pull request as ready for review March 3, 2026 06:15
Copy link
Collaborator

@Bodigrim Bodigrim left a comment

Choose a reason for hiding this comment

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

Exciting!

Comment on lines +82 to +84
-- | Version range with an 'Ord' instance. The show string is cached to avoid
-- recomputing it on every comparison.
data OrderedVersionRange = OVR !String !VR
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could we use Distribution.Utils.ShortText instead of String? This should reduce resident memory and make instance Ord OrderedVersionRange even faster.

instance Ord OrderedVersionRange where
compare = compare `on` show
Copy link
Collaborator

Choose a reason for hiding this comment

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

It would be, but a drop 20 on the String would resolve that cheaply?

@sebright
Copy link
Collaborator

sebright commented Mar 5, 2026

Is there an example of a package on Hackage that shows the performance improvement?

Copy link
Collaborator

@sebright sebright left a comment

Choose a reason for hiding this comment

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

I tried running the solver benchmarks with some packages from Hackage, but I wasn't able to find any that showed a significant performance improvement. What are the characteristics of the packages that showed the improvement? Was there any backtracking in the run that you timed?

EDIT: I'm also wondering if the solver had to skip many versions, with lines containing "has the same characteristics that caused the previous version to fail". The backtracking and conflicts are only shown with -v3.

Comment on lines -81 to +86
-- | Version range with an 'Ord' instance.
newtype OrderedVersionRange = OrderedVersionRange VR
deriving (Eq, Show)
-- | Version range with an 'Ord' instance. The show string is cached to avoid
-- recomputing it on every comparison.
data OrderedVersionRange = OVR !ShortText !VR
Copy link
Collaborator

Choose a reason for hiding this comment

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

It looks like #8269 added an Ord instance to VersionRange, so OrderedVersionRange could be removed, unless it leads to a significant regression.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I would be surprised if it is a significant difference either way - compare on VersionRange may involve more pointer chasing, particularly on Union or Intersect cases, but compare @Version I'd expect to be very fast in the PV0 case, which may overcome the textual prefix comparison. Removing OrderedVersionRange feels more invasive than optimizing it transparently.

Comment on lines 265 to 272
@@ -267,24 +267,31 @@ exploreLog mbj enableBj fineGrainedConflicts (CountConflicts countConflicts) idx
-- is true, because it is always safe to explore a package instance.
-- Skipping it is an optimization. If false, it returns a new conflict set
-- to be merged with the previous one.
qo = defaultQualifyOptions idx

couldResolveConflicts :: QPN -> POption -> S.Set CS.Conflict -> Maybe ConflictSet
Copy link
Collaborator

Choose a reason for hiding this comment

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

The comment should be above couldResolveConflicts.

Comment on lines +277 to +281
-- Pre-index: map from QPN to intersected version range (Constrained only)
depVRs :: M.Map QPN VR
depVRs = M.fromListWith (.&&.)
[ (qpn, case ci of Constrained vr -> vr; _ -> anyVersion)
| Simple (LDep _ (Dep (PkgComponent qpn _) ci)) _ <- qdeps ]
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's a little confusing that the alternative to Constrained, Fixed, is treated as anyVersion. Could this cache be split into two variables, one specialized for each of its two use cases, to improve readability?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sure - since we only use the M.member function, I can do M.keysSet here to differentitae the use sites.

@@ -301,7 +301,7 @@ checkComponentsInNewPackage :: ComponentDependencyReasons
-> Map ExposedComponent ComponentInfo
-> Either Conflict ()
checkComponentsInNewPackage required qpn providedComps =
case M.toList $ deleteKeys (M.keys providedComps) required of
case M.toList $ M.difference required providedComps of
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nice!

@parsonsmatt parsonsmatt requested a review from sebright March 10, 2026 16:18
Copy link
Collaborator

@sebright sebright left a comment

Choose a reason for hiding this comment

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

The way that I was thinking about this PR is that each change could be justified as either a code cleanup or a performance improvement. Since I can't reproduce the performance improvement, I was reviewing each of the changes as a cleanup. That's why I suggested simplifying the code by removing the OrderedVersionRange type and splitting the depVRs cache.

I'm also interested in looking into why the solver ran for so long with the packages that you mentioned. It might help to open a separate issue if there is a publicly available reproducer. I hadn't realized that the code for skipping conflicting packages could be so important for performance. It's possible that the use case could be optimized in a different way, such as adding a new case for skipping packages (constructor for CS.Conflict) or avoiding the conflicts entirely. If there's no good way to avoid skipping so many packages, and it's a common use case, then maybe we could find a way to add it to the test suite.

Comment on lines +284 to +285
depsWithVRs :: S.Set QPN
depsWithVRs = M.keysSet depVRs
Copy link
Collaborator

Choose a reason for hiding this comment

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

This variable can just be called deps now, because it doesn't matter whether the dependencies are constrained for resolving CS.GoalConflict.

Comment on lines +277 to +282
-- Pre-index: map from QPN to intersected version range (Constrained only)
depVRs :: M.Map QPN VR
depVRs = M.fromListWith (.&&.)
[ (qpn, case ci of Constrained vr -> vr; _ -> anyVersion)
| Simple (LDep _ (Dep (PkgComponent qpn _) ci)) _ <- qdeps
]
Copy link
Collaborator

Choose a reason for hiding this comment

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

This index should only contain dependencies that are Constrained now, since CS.VersionConstraintConflict doesn't handle the others.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants