Skip to content

Commit 95ebea1

Browse files
authored
dependency resolution performance (#4007)
motivation: improve dependency resolution performance changes: * add informational diagnostics on manifest cache hit/miss * disable expensive optimization in pubgrub resolver that has no functional value given changes from #3985 * adjust resolver workspace delgate to be less noisy given the difference in resolution callbacks
1 parent beac985 commit 95ebea1

File tree

5 files changed

+23
-181
lines changed

5 files changed

+23
-181
lines changed

Sources/PackageGraph/Pubgrub/PubgrubDependencyResolver.swift

Lines changed: 7 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,13 +1035,6 @@ internal final class PubGrubPackageContainer {
10351035
/// Reference to the pins map.
10361036
private let pinsMap: PinsStore.PinsMap
10371037

1038-
/// The map of dependencies to version set that indicates the versions that have had their
1039-
/// incompatibilities emitted.
1040-
private var emittedIncompatibilities = ThreadSafeKeyValueStore<DependencyResolutionNode, VersionSetSpecifier>()
1041-
1042-
/// Whether we've emitted the incompatibilities for the pinned versions.
1043-
private var emittedPinnedVersionIncompatibilities = ThreadSafeKeyValueStore<DependencyResolutionNode, Bool>()
1044-
10451038
init(underlying: PackageContainer, pinsMap: PinsStore.PinsMap) {
10461039
self.underlying = underlying
10471040
self.pinsMap = pinsMap
@@ -1189,55 +1182,17 @@ internal final class PubGrubPackageContainer {
11891182
continue
11901183
}
11911184

1192-
// Skip if we already emitted incompatibilities for this dependency such that the selected
1193-
// falls within the previously computed bounds.
1194-
if emittedIncompatibilities[node]?.contains(version) != true {
1195-
for node in dep.nodes() {
1196-
constraints.append(
1197-
PackageContainerConstraint(
1198-
package: node.package,
1199-
requirement: dep.requirement,
1200-
products: node.productFilter
1201-
)
1185+
for node in dep.nodes() {
1186+
constraints.append(
1187+
PackageContainerConstraint(
1188+
package: node.package,
1189+
requirement: dep.requirement,
1190+
products: node.productFilter
12021191
)
1203-
}
1204-
}
1205-
}
1206-
1207-
// Emit the dependencies at the pinned version if we haven't emitted anything else yet.
1208-
if version == pinnedVersion, emittedIncompatibilities.isEmpty {
1209-
// We don't need to emit anything if we already emitted the incompatibilities at the
1210-
// pinned version.
1211-
if self.emittedPinnedVersionIncompatibilities[node] ?? false {
1212-
return []
1213-
}
1214-
1215-
self.emittedPinnedVersionIncompatibilities[node] = true
1216-
1217-
// Since the pinned version is most likely to succeed, we don't compute bounds for its
1218-
// incompatibilities.
1219-
return try constraints.flatMap { constraint -> [Incompatibility] in
1220-
// We only have version-based requirements at this point.
1221-
guard case .versionSet(let constraintRequirement) = constraint.requirement else {
1222-
throw InternalError("Unexpected unversioned requirement: \(constraint)")
1223-
}
1224-
return try constraint.nodes().map { dependencyNode in
1225-
var terms: OrderedSet<Term> = []
1226-
// the package version requirement
1227-
terms.append(Term(node, .exact(version)))
1228-
// the dependency's version requirement
1229-
terms.append(Term(not: dependencyNode, constraintRequirement))
1230-
return try Incompatibility(terms, root: root, cause: .dependency(node: node))
1231-
}
1192+
)
12321193
}
12331194
}
12341195

1235-
let (lowerBounds, upperBounds) = try self.computeBounds(
1236-
for: node,
1237-
constraints: constraints,
1238-
startingWith: version
1239-
)
1240-
12411196
return try constraints.flatMap { constraint -> [Incompatibility] in
12421197
// We only have version-based requirements at this point.
12431198
guard case .versionSet(let constraintRequirement) = constraint.requirement else {
@@ -1249,13 +1204,6 @@ internal final class PubGrubPackageContainer {
12491204
return nil
12501205
}
12511206

1252-
// Make a record for this dependency so we don't have to recompute the bounds when the selected version falls within the bounds.
1253-
if let lowerBound = lowerBounds[constraintNode], let upperBound = upperBounds[constraintNode] {
1254-
assert(lowerBound < upperBound)
1255-
let requirement: VersionSetSpecifier = .range(lowerBound ..< upperBound)
1256-
self.emittedIncompatibilities[constraintNode] = requirement.union(emittedIncompatibilities[constraintNode] ?? .empty)
1257-
}
1258-
12591207
var terms: OrderedSet<Term> = []
12601208
// the package version requirement
12611209
terms.append(Term(node, .exact(version)))
@@ -1266,110 +1214,6 @@ internal final class PubGrubPackageContainer {
12661214
}
12671215
}
12681216
}
1269-
1270-
/// Method for computing bounds of the given dependencies.
1271-
///
1272-
/// This will return a dictionary which contains mapping of a package dependency to its bound.
1273-
/// If a dependency is absent in the dictionary, it is present in all versions of the package
1274-
/// above or below the given version. As with regular version ranges, the lower bound is
1275-
/// inclusive and the upper bound is exclusive.
1276-
private func computeBounds(
1277-
for node: DependencyResolutionNode,
1278-
constraints: [PackageContainerConstraint],
1279-
startingWith firstVersion: Version
1280-
) throws -> (lowerBounds: [DependencyResolutionNode: Version], upperBounds: [DependencyResolutionNode: Version]) {
1281-
let preloadCount = 3
1282-
1283-
// nothing to do
1284-
if constraints.isEmpty {
1285-
return ([:], [:])
1286-
}
1287-
1288-
func preload(_ versions: [Version]) {
1289-
let sync = DispatchGroup()
1290-
for version in versions {
1291-
DispatchQueue.sharedConcurrent.async(group: sync) {
1292-
if self.underlying.isToolsVersionCompatible(at: version) {
1293-
_ = try? self.underlying.getDependencies(at: version, productFilter: node.productFilter)
1294-
}
1295-
}
1296-
}
1297-
sync.wait()
1298-
}
1299-
1300-
func compute(_ versions: [Version], upperBound: Bool) -> [DependencyResolutionNode: Version] {
1301-
var result: [DependencyResolutionNode: Version] = [:]
1302-
var previousVersion = firstVersion
1303-
1304-
for (index, version) in versions.enumerated() {
1305-
// optimization to preload few version in parallel.
1306-
// we cant preload all versions ahead of time since it will be more
1307-
// costly given the early termination condition
1308-
if index.isMultiple(of: preloadCount) {
1309-
preload(Array(versions[index ..< min(index + preloadCount, versions.count)]))
1310-
}
1311-
1312-
// Record this version as the bound if we're finding upper bounds since
1313-
// upper bound is exclusive and record the previous version if we're
1314-
// finding the lower bound since that is inclusive.
1315-
let bound = upperBound ? version : previousVersion
1316-
1317-
let isToolsVersionCompatible = self.underlying.isToolsVersionCompatible(at: version)
1318-
for constraint in constraints {
1319-
for constraintNode in constraint.nodes() where !result.keys.contains(constraintNode) {
1320-
// If we hit a version which doesn't have a compatible tools version then that's the boundary.
1321-
// Record the bound if the tools version isn't compatible at the current version.
1322-
if !isToolsVersionCompatible {
1323-
result[constraintNode] = bound
1324-
} else {
1325-
// Get the dependencies at this version.
1326-
if let currentDependencies = try? self.underlying.getDependencies(at: version, productFilter: constraintNode.productFilter) {
1327-
// Record this version as the bound for our list of dependencies, if appropriate.
1328-
if currentDependencies.first(where: { $0.package == constraint.package }) != constraint {
1329-
result[constraintNode] = bound
1330-
}
1331-
}
1332-
}
1333-
}
1334-
}
1335-
1336-
// We're done if we found bounds for all of our dependencies.
1337-
if result.count == constraints.count {
1338-
break
1339-
}
1340-
1341-
previousVersion = version
1342-
}
1343-
1344-
return result
1345-
}
1346-
1347-
let versions: [Version] = try self.underlying.versionsAscending()
1348-
1349-
guard let idx = versions.firstIndex(of: firstVersion) else {
1350-
throw InternalError("from version \(firstVersion) not found in \(node.package.identity)")
1351-
}
1352-
1353-
let sync = DispatchGroup()
1354-
1355-
var upperBounds = [DependencyResolutionNode: Version]()
1356-
DispatchQueue.sharedConcurrent.async(group: sync) {
1357-
upperBounds = compute(Array(versions.dropFirst(idx + 1)), upperBound: true)
1358-
}
1359-
1360-
var lowerBounds = [DependencyResolutionNode: Version]()
1361-
DispatchQueue.sharedConcurrent.async(group: sync) {
1362-
lowerBounds = compute(Array(versions.dropLast(versions.count - idx).reversed()), upperBound: false)
1363-
}
1364-
1365-
// timeout is a function of # of versions since we need to make several git operations per tag/version
1366-
let timeout = DispatchTimeInterval.seconds(60 + versions.count)
1367-
guard case .success = sync.wait(timeout: .now() + timeout) else {
1368-
throw StringError("timeout computing '\(node.package.identity)' bounds")
1369-
}
1370-
1371-
return (lowerBounds, upperBounds)
1372-
}
13731217
}
13741218

13751219
/// An utility class around PackageContainerProvider that allows "prefetching" the containers

Sources/PackageLoading/ManifestLoader.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ public final class ManifestLoader: ManifestLoaderProtocol {
247247
at: path,
248248
packageIdentity: packageIdentity,
249249
packageKind: packageKind,
250+
version: version,
250251
toolsVersion: toolsVersion,
251252
identityResolver: identityResolver,
252253
delegateQueue: queue,
@@ -528,6 +529,7 @@ public final class ManifestLoader: ManifestLoaderProtocol {
528529
at path: AbsolutePath,
529530
packageIdentity: PackageIdentity,
530531
packageKind: PackageReference.Kind,
532+
version: Version?,
531533
toolsVersion: ToolsVersion,
532534
identityResolver: IdentityResolver,
533535
delegateQueue: DispatchQueue,
@@ -569,6 +571,7 @@ public final class ManifestLoader: ManifestLoaderProtocol {
569571
do {
570572
// try to get it from the cache
571573
if let result = try cache?.get(key: key.sha256Checksum), let manifestJSON = result.manifestJSON, !manifestJSON.isEmpty {
574+
observabilityScope.emit(debug: "loading manifest for '\(packageIdentity)' v. \(version?.description ?? "unknown") from cache")
572575
return completion(.success(try self.parseManifest(
573576
result,
574577
packageIdentity: packageIdentity,
@@ -587,6 +590,7 @@ public final class ManifestLoader: ManifestLoaderProtocol {
587590
let closeAfterWrite = closeAfterRead.delay()
588591

589592
// shells out and compiles the manifest, finally output a JSON
593+
observabilityScope.emit(debug: "evaluating manifest for '\(packageIdentity)' v. \(version?.description ?? "unknown") ")
590594
self.evaluateManifest(
591595
packageIdentity: key.packageIdentity,
592596
manifestPath: key.manifestPath,

Sources/Workspace/Workspace.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,19 @@ private class WorkspaceRepositoryManagerDelegate: RepositoryManagerDelegate {
128128
}
129129

130130
private struct WorkspaceDependencyResolverDelegate: DependencyResolverDelegate {
131-
unowned let workspaceDelegate: WorkspaceDelegate
131+
private unowned let workspaceDelegate: WorkspaceDelegate
132+
private let resolving = ThreadSafeKeyValueStore<PackageIdentity, Bool>()
132133

133134
init(_ delegate: WorkspaceDelegate) {
134135
self.workspaceDelegate = delegate
135136
}
136137

137138
func willResolve(term: Term) {
138-
self.workspaceDelegate.willComputeVersion(package: term.node.package.identity, location: term.node.package.locationString)
139+
// this may be called multiple time by the resolver for various version ranges, but we only want to propagate once since we report at pacakge level
140+
resolving.memoize(term.node.package.identity) {
141+
self.workspaceDelegate.willComputeVersion(package: term.node.package.identity, location: term.node.package.locationString + " " + term.description)
142+
return true
143+
}
139144
}
140145

141146
func didResolve(term: Term, version: Version, duration: DispatchTimeInterval) {

Tests/PackageLoadingTests/PD_5_0_LoadingTests.swift

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -426,22 +426,11 @@ class PackageDescription5_0LoadingTests: PackageDescriptionLoadingTests {
426426
observabilityScope: observability.topScope
427427
)
428428

429-
guard let diagnostic = observability.diagnostics.first else {
430-
return XCTFail("Expected a diagnostic")
431-
}
432-
XCTAssertMatch(diagnostic.message, .contains("warning: initialization of immutable value"))
433-
XCTAssertEqual(diagnostic.severity, .warning)
434-
let contents = try diagnostic.metadata?.manifestLoadingDiagnosticFile.map { try localFileSystem.readFileContents($0) }
435-
XCTAssertNotNil(contents)
436-
437-
// FIXME: (diagnostics) bring ^^ back when implemented again
438-
/*guard let diagnostic = diagnostics.diagnostics.first else {
439-
return XCTFail("Expected a diagnostic")
429+
testDiagnostics(observability.diagnostics) { result in
430+
let diagnostic = result.check(diagnostic: .contains("initialization of immutable value"), severity: .warning)
431+
let contents = try diagnostic?.metadata?.manifestLoadingDiagnosticFile.map { try localFileSystem.readFileContents($0) }
432+
XCTAssertNotNil(contents)
440433
}
441-
XCTAssertMatch(diagnostic.message.text, .contains("warning: initialization of immutable value"))
442-
XCTAssertEqual(diagnostic.behavior, .warning)
443-
let contents = try (diagnostic.data as? ManifestLoadingDiagnostic)?.diagnosticFile.map { try localFileSystem.readFileContents($0) }
444-
XCTAssertNotNil(contents)*/
445434
}
446435
}
447436
}

Tests/SPMBuildCoreTests/PluginInvocationTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ class PluginInvocationTests: XCTestCase {
259259

260260
// Load the package graph.
261261
let packageGraph = try workspace.loadPackageGraph(rootInput: rootInput, observabilityScope: observability.topScope)
262-
XCTAssert(observability.diagnostics.isEmpty, "\(observability.diagnostics)")
262+
XCTAssertNoDiagnostics(observability.diagnostics)
263263
XCTAssert(packageGraph.packages.count == 1, "\(packageGraph.packages)")
264264

265265
// Find the build tool plugin.

0 commit comments

Comments
 (0)