Hive Hive
Sign in

feat(cli): parallelize XCFramework creation during cache warm

GitHub issue · Closed

Metadata
Source
tuist/tuist #11474
Updated
Jun 24, 2026
Domains
Cache
Details

Resolves https://github.com/tuist/tuist/issues/10973

Describe here the purpose of your PR.

What changed

CacheWarmCommandService.buildXCFrameworks no longer creates XCFrameworks one at a time. The per-target work now runs through the existing Array.concurrentMap(maxConcurrentTasks:) helper from TuistSupport instead of a serial for loop.

  • The per-target body is unchanged: artifact resolution, xcodebuild -create-xcframework, and App Intents metadata embedding all stay inside the same per-target operation.
  • concurrentMap(maxConcurrentTasks:) preserves input order, so artifact storage stays deterministic.
  • Any xcodebuild -create-xcframework failure still propagates and fails the warm.
  • The bound scales with the host (activeProcessorCount) and is capped at 8.
  • Also drops the redundant -allow-internal-distribution argument that buildXCFrameworks appended, since XcodeBuildController.createXCFramework already appends it. Behavior-preserving (the flag is still passed exactly once).

Why

As reported in #10973, the “Creating XCFrameworks” phase can dominate a binary cache warm on projects with many cacheable framework targets, even after the platform-specific framework builds have finished. In one measured run this phase alone took roughly 9.8 minutes (589s).

Each xcodebuild -create-xcframework invocation is independent: it reads already-built framework slices and writes to a distinct, target-specific output path (<tmp>/xcframeworks/<target>.xcframework). There is no shared mutable state between targets, so they can run concurrently.

Why bounded concurrency (and why this bound)

Unbounded concurrency would spawn hundreds of xcodebuild processes at once and overload the machine, so the fan-out is bounded. Rather than a fixed value, the bound scales with the host and is capped at 8.

A concurrency sweep over 150 framework targets (real xcodebuild -create-xcframework, device + simulator slices) on an 11-core M3 Pro:

limit time speedup
1 111.4s 1.00x
2 55.9s 1.99x
4 33.3s 3.34x
6 25.4s 4.38x
8 26.2s 4.25x
11 23.6s 4.73x
16 25.1s 4.44x

Scaling is near-linear to 4, the knee is around 6, and past ~6 it plateaus into noise. Every pass produced identical successful output counts with zero failures. Scaling with activeProcessorCount adapts down on small CI shapes and up on dev machines; the cap of 8 keeps a very-high-core host from spawning a large number of concurrent, I/O-heavy xcodebuild processes (real framework slices copy far more data than the synthetic benchmark, so the disk is the real ceiling there).

This tracks the wall-clock reduction in the issue: bounded concurrency of 4 took the isolated phase from 564s to 167s (3.4x) on the same input set, with the same number of successful outputs and no failures.

Impact

Faster tuist cache warm on projects with many cacheable framework targets. No change to the produced artifacts, their ordering, or failure semantics.

How to test locally

  1. Run tuist cache warm on a project with many cacheable framework targets.
  2. Observe that the “Creating XCFrameworks” phase completes faster (the Creating XCFramework for <target> lines now interleave rather than running strictly one after another).
  3. Confirm the same set of XCFrameworks is produced and stored, and that a failure in any single create-xcframework still fails the warm.

Validation

  • Full EE build green: TUIST_EE=1 xcodebuild build -workspace Tuist.xcworkspace -scheme tuist -> BUILD SUCCEEDED, 0 errors (CacheWarmCommandService.swift compiled, TuistKit.swiftmodule emitted).
  • mise run cli:lint passes.
  • Order-preservation and error-propagation are guaranteed by concurrentMap(maxConcurrentTasks:), already covered by test_concurrentMap_withMaxConcurrentTasks_preservesInputOrder in Array+ExecutionContextTests.swift.
  • Benchmark above (sweep with real xcodebuild -create-xcframework).
Comments