We have fought with the same problems recently and thought the same, but had some promising findings after tracing Xcode itself with Instruments. Based on my investigations, most of the slowdown happens not in the "black box" of Xcode but in the open-source SPM tool it uses.
In fact, we had barely any issues with SPM before bringing in Crashlytics along with the Firebase SDK. Since then, we've seen Xcode go "Resolving Package Graph" for up to ~40 seconds after the smallest changes to the our package, such as simply adding or removing a file!
Sampling Xcode resolving package graph
It's pretty easy to test this with the Firebase SDK which depends on some particularly heavy monorepo-style dependencies, something SPM was not well prepared for. I've attached below a sample Package.swift file which (given an empty source file at Sources/SlowSPM/dummy.swift) reproduces the slowdown both when opened in Xcode as well as in SPM command line:
Package.swift
// swift-tools-version: 5.7.1
import PackageDescription
let package = Package(
name: "slow-spm",
products: [.library(name: "SlowSPM", targets: ["SlowSPM"])],
dependencies: [
.package(url: "https://github.com/firebase/firebase-ios-sdk", .upToNextMinor(from: "10.5.0")),
],
targets: [
.target(name: "SlowSPM", dependencies: [
.product(name: "FirebaseCrashlytics", package: "firebase-ios-sdk"),
]),
]
)
Recording Xcode with the Sampler instrument, the heaviest stack trace is seen where SPMWorkspace
(closed source?) calls to Workspace.loadPackageGraph
(part of SPM). As it turns out, a lot of time is spent on accessing files (and checking whether they are symlinks):
...
19.17 s partial apply for closure #2 in SPMWorkspace.processPackageGraphActionsInBackgroundIfNeeded()
19.17 s closure #2 in SPMWorkspace.processPackageGraphActionsInBackgroundIfNeeded()
19.15 s Workspace.loadPackageGraph(rootInput:explicitProduct:createMultipleTestProducts:createREPLProduct:forceResolvedVersions:customXCTestMinimumDeploymentTargets:observabilityScope:)
19.05 s static PackageGraph.load(root:identityResolver:additionalFileRules:externalManifests:requiredDependencies:unsafeAllowedPackages:binaryArtifacts:shouldCreateMultipleTestProducts:createREPLProduct:customPlatformsRegistry:customXCTestMinimumDeploymentTargets:fileSystem:observabilityScope:)
... (omit a few nested frames)
19.02 s PackageBuilder.createTarget(potentialModule:manifestTarget:dependencies:)
18.93 s TargetSourcesBuilder.run()
9.79 s TargetSourcesBuilder.computeContents()
5.67 s specialized LocalFileSystem.isSymlink(_:)
Testing SPM slowness outside Xcode
It is possible to test this code path outside Xcode too. First call swift package resolve
to install packages. Then, to hit the code path with static PackageGraph.load(...)
, run the command:
$ time swift package show-dependencies
...
real 0m30.877s
user 0m15.481s
sys 0m8.859s
With a fresh build of SPM from main
branch (running /path/to/spm/.build/release/swift-package show-dependencies
), I get similar (if not slightly worse) execution times: 38.354s/16.313s/9.311s, respectively.
Suggestion 1: Use lstat
directly
Instead of tediously retrieving all file attributes only to check if the .type
is equal to .typeSymbolicLink
, isSymlink(_:)
should preferably just call lstat(2)
. Trying that with a quick hack, the command execution time went down to 10.760s/7.093s/2.275s, or a 3x speedup.
Suggestion 2: Skip unwanted files if target declares sources
Tracing swift-package
with the File Activity instruments further reveals that SPM is indeed hitting a lot of files under Firebase and its dependencies. The biggest of those libraries consist of over 20,000 files, most of them (excluded code, tests and test data) irrelevant to the Swift package. TargetSourcesBuilder
starts by finding all files reachable under the target's directory. But that becomes a problem if a package is making deliberate use of .target(... path: exclude: sources: )
.
I'm probably making too simple an assumption here, but if TargetSourcesBuilder
only included declared source files (if any) in the search, then a lot fewer paths would need to be visited. If this change is right to do (it breaks some potentially obsolete unit tests!), it could provide up to a further 4.5x speedup, evaluating show-dependencies
for me in just 2.324s/0.767s/0.561s.
I'm sure there are further optimisations to be done to SPM. Testing with large real-world Swift packages could help us find and fix the bottlenecks, and directly improve the Xcode experience too. (In the meantime, we'll use Firebase via xcframeworks.)