Reported internally: tuist generate hangs for a very long time on a project that depends on the AWS SDK for Swift (~800 targets in one SwiftPM package). The last visible output is the per-target Target … of type test ignored lines, then a long silent stall. It was fast on 4.152.0 and slow on 4.192.0+.
What changed
Two fixes in PackageInfoMapper’s mapping path, both of which scale with the number of targets in an external SwiftPM package:
-
SwiftVersionProvider — dedupe concurrent swiftVersion() calls (the dominant fix). map(target:) runs over every package target via concurrentCompactMap, and each regular/test target calls SwiftVersionProvider.current.swiftVersion(). That value is cached by the AsyncThrowableCaching actor, but its value() checked cachedValue and then awaited builder() — the cache is only populated after the await resolves, so all concurrent first-callers pass the nil check before any of them caches. With ~800 concurrently-mapped targets this fans out into ~800 concurrent xcrun swift --version launches for one invariant value. The actor now coalesces callers onto a single Task stored synchronously before the first suspension point, so only one subprocess runs.
-
PackageInfoMapper — reuse the package targets dictionary in name resolution (secondary). wrapsProductNamedFramework (called from effectiveModuleName/effectiveProductName, 2-3× per target) rebuilt a Dictionary over all package targets on every call — O(T²) per package, copying the heavy PackageInfo.Target value type repeatedly. The dictionary that map(packageInfo:) already builds is now threaded through; the traversal logic and resolved names are unchanged.
Why / root cause
The regression came from swiftVersion() being converted from synchronous (which deduplicated naturally) to async behind an actor cache that doesn’t coalesce in-flight requests. The signature is a huge sys time from process spawning (forking ~800 swift --version). The O(T²) dictionary rebuild was a real-but-minor second contributor that PR #10309 amplified by removing the early-return guards in the common target == product case.
The “obvious” suspect (the O(T²) mapper) turned out to be the minor one — profiling the reproducer is what surfaced the subprocess storm as the dominant cost.
How to test locally
Reproduced and validated with a synthetic local SwiftPM package of 800 single-target/single-product modules (each module’s target name equals its product name, all depending on a shared Runtime), with the app depending on a single external so that mapping processes all 800 targets while generation stays small. Measured with /usr/bin/time -l tuist generate --no-open and sample:
|
wall |
user CPU |
sys |
| before (4.198.1) |
14.6s |
70.9s |
31.1s |
| after |
2.6s |
1.75s |
1.1s |
~40× less user CPU and ~29× less sys time, back to the 4.152.0 baseline, with all 800 modules still mapped and the project generated identically. A profiler confirms the wrapsProductNamedFramework, SwiftVersionProvider.swiftVersion, and CommandRunner.run frames are gone after the change.
Notes for reviewers
- A regression test asserting
SwiftVersionProvider invokes its builder only once under N concurrent swiftVersion() calls would be a good guard; happy to add it.
- A smaller secondary cost remains (Regex-based
FileSystem.glob/expandBraces runs per target, from the FileHandler→FileSystem migration). It lives in the external tuist/FileSystem package and is now a minor relative contributor — worth a separate look, out of scope here.