Skip to content

Commit 6245fe9

Browse files
committed
Cache computed target build graphs across repeated builds
Xcode issues multiple TargetBuildGraph requests per build action with different parameters (dependency graph vs build, index prep vs normal). For large workspaces (2000+ targets), computeGraph() takes 7-37s per call. Without caching, this cost is paid on every build even when nothing has changed. Add a process-level multi-entry cache keyed by a signature of the normalized PIF workspace signature, build parameters, and request flags. Each distinct request type gets its own cache slot (up to 8 entries). The cache is static because WorkspaceContext is recreated on every PIF transfer even when nothing has changed. On cache hit, unapproved target diagnostics are re-emitted to preserve correctness.
1 parent 945c3b0 commit 6245fe9

2 files changed

Lines changed: 189 additions & 5 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import SWBUtil
14+
import Foundation
15+
16+
/// Process-level cache for computed target build graphs.
17+
///
18+
/// Xcode issues multiple `TargetBuildGraph` requests per build action
19+
/// with different parameters (e.g. dependency graph vs actual build,
20+
/// index preparation vs normal build). A multi-entry cache ensures
21+
/// these don't evict each other.
22+
///
23+
/// The cache is static (process-level) because `WorkspaceContext` is
24+
/// recreated on every PIF transfer, even when nothing has changed.
25+
public enum TargetBuildGraphCache {
26+
/// The data we cache — everything needed by the
27+
/// `TargetBuildGraph` memberwise init except the live context
28+
/// objects (workspaceContext, buildRequest, buildRequestContext).
29+
struct CachedTopology: @unchecked Sendable {
30+
let allTargets: OrderedSet<ConfiguredTarget>
31+
let targetDependencies:
32+
[ConfiguredTarget: [ResolvedTargetDependency]]
33+
let targetsToLinkedReferencesToProducingTargets:
34+
[ConfiguredTarget:
35+
[BuildFile.BuildableItem: ResolvedTargetDependency]]
36+
let dynamicallyBuildingTargets: Set<Target>
37+
}
38+
39+
/// Maximum number of cached entries. Xcode typically issues 2-4
40+
/// distinct graph requests per build action; 8 gives headroom.
41+
private static let maxEntries = 8
42+
43+
private static let _entries =
44+
SWBMutex<[Int: CachedTopology]>([:])
45+
46+
/// Look up a cached topology by signature.
47+
static func lookup(signature: Int) -> CachedTopology? {
48+
_entries.withLock { entries in
49+
entries[signature]
50+
}
51+
}
52+
53+
/// Store a computed topology for the given signature.
54+
static func store(signature: Int, topology: CachedTopology) {
55+
_entries.withLock { entries in
56+
// Evict all entries if we exceed the limit (simple reset
57+
// policy). This only happens when the PIF changes or the
58+
// user switches between different build configurations.
59+
if entries.count >= maxEntries {
60+
entries.removeAll()
61+
}
62+
entries[signature] = topology
63+
}
64+
}
65+
66+
/// Compute a cache signature from the inputs that determine the
67+
/// dependency graph.
68+
///
69+
/// The dependency graph is a pure function of the PIF structure
70+
/// and the build request parameters. File contents (source files,
71+
/// resources) do not affect which targets exist or how they depend
72+
/// on each other — only the PIF does.
73+
static func computeSignature(
74+
workspaceSignature: String,
75+
buildRequest: BuildRequest,
76+
purpose: TargetBuildGraph.Purpose
77+
) -> Int {
78+
var hasher = Hasher()
79+
80+
// Normalized PIF signature (strip volatile subobject GUIDs)
81+
if let range = workspaceSignature.range(
82+
of: "_subobjects="
83+
) {
84+
hasher.combine(
85+
workspaceSignature[..<range.lowerBound])
86+
} else {
87+
hasher.combine(workspaceSignature)
88+
}
89+
90+
// Global build parameters
91+
hasher.combine(buildRequest.parameters)
92+
93+
// Top-level build targets and their per-target parameters.
94+
// Sort by target GUID for order independence.
95+
for targetInfo in buildRequest.buildTargets.sorted(
96+
by: { $0.target.guid < $1.target.guid }
97+
) {
98+
hasher.combine(targetInfo.target.guid)
99+
hasher.combine(targetInfo.parameters)
100+
}
101+
102+
// Flags that affect graph topology
103+
hasher.combine(buildRequest.useImplicitDependencies)
104+
hasher.combine(buildRequest.useParallelTargets)
105+
hasher.combine(buildRequest.skipDependencies)
106+
107+
// Dependency scope affects pruning
108+
switch buildRequest.dependencyScope {
109+
case .workspace:
110+
hasher.combine(0)
111+
case .buildRequest:
112+
hasher.combine(1)
113+
}
114+
115+
// Build command affects the early-return for
116+
// assembly/preprocessor
117+
switch buildRequest.buildCommand {
118+
case .build:
119+
hasher.combine("build")
120+
case .generateAssemblyCode:
121+
hasher.combine("asm")
122+
case .generatePreprocessedFile:
123+
hasher.combine("preprocess")
124+
case .singleFileBuild:
125+
hasher.combine("single")
126+
case .prepareForIndexing:
127+
hasher.combine("index")
128+
case .cleanBuildFolder:
129+
hasher.combine("clean")
130+
case .preview:
131+
hasher.combine("preview")
132+
}
133+
134+
// Purpose affects diagnostic behavior
135+
switch purpose {
136+
case .build:
137+
hasher.combine("build")
138+
case .dependencyGraph:
139+
hasher.combine("depgraph")
140+
}
141+
142+
return hasher.finalize()
143+
}
144+
}

Sources/SWBCore/TargetDependencyResolver.swift

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,53 @@ public struct TargetBuildGraph: TargetGraph, Sendable {
109109
///
110110
/// The result closure guarantees that all targets a target depends on appear in the returned array before that target. Any detected dependency cycles will be broken.
111111
public init(workspaceContext: WorkspaceContext, buildRequest: BuildRequest, buildRequestContext: BuildRequestContext, delegate: any TargetDependencyResolverDelegate, purpose: Purpose = .build) async {
112-
let (allTargets, targetDependencies, targetsToLinkedReferencesToProducingTargets, dynamicallyBuildingTargets) =
113-
await MacroNamespace.withExpressionInterningEnabled {
114-
await buildRequestContext.keepAliveSettingsCache {
115-
let resolver = TargetDependencyResolver(workspaceContext: workspaceContext, buildRequest: buildRequest, buildRequestContext: buildRequestContext, delegate: delegate, purpose: purpose)
116-
return await resolver.computeGraph()
112+
let signature = TargetBuildGraphCache.computeSignature(
113+
workspaceSignature: workspaceContext.workspace.signature,
114+
buildRequest: buildRequest,
115+
purpose: purpose
116+
)
117+
118+
let allTargets: OrderedSet<ConfiguredTarget>
119+
let targetDependencies: [ConfiguredTarget: [ResolvedTargetDependency]]
120+
let targetsToLinkedReferencesToProducingTargets: [ConfiguredTarget: [BuildFile.BuildableItem: ResolvedTargetDependency]]
121+
let dynamicallyBuildingTargets: Set<Target>
122+
123+
if let cached = TargetBuildGraphCache.lookup(signature: signature) {
124+
allTargets = cached.allTargets
125+
targetDependencies = cached.targetDependencies
126+
targetsToLinkedReferencesToProducingTargets = cached.targetsToLinkedReferencesToProducingTargets
127+
dynamicallyBuildingTargets = cached.dynamicallyBuildingTargets
128+
129+
// Re-emit unapproved target diagnostics on cache hit
130+
// (these are important for correctness since they gate
131+
// whether the build proceeds). On cache miss,
132+
// computeGraph() emits them.
133+
for target in allTargets {
134+
if !target.target.approvedByUser, purpose != .dependencyGraph {
135+
let behavior = buildRequest.enableIndexBuildArena ? Diagnostic.Behavior.warning : .error
136+
delegate.emit(SWBUtil.Diagnostic(behavior: behavior, location: .path(workspaceContext.workspace.project(for: target.target).xcodeprojPath, fileLocation: .object(identifier: target.target.guid)), data: DiagnosticData("Target '\(target.target.name)' must be enabled before it can be used.", component: .targetMissingUserApproval)))
137+
}
138+
}
139+
} else {
140+
(allTargets, targetDependencies, targetsToLinkedReferencesToProducingTargets, dynamicallyBuildingTargets) =
141+
await MacroNamespace.withExpressionInterningEnabled {
142+
await buildRequestContext.keepAliveSettingsCache {
143+
let resolver = TargetDependencyResolver(workspaceContext: workspaceContext, buildRequest: buildRequest, buildRequestContext: buildRequestContext, delegate: delegate, purpose: purpose)
144+
return await resolver.computeGraph()
145+
}
117146
}
147+
148+
TargetBuildGraphCache.store(
149+
signature: signature,
150+
topology: .init(
151+
allTargets: allTargets,
152+
targetDependencies: targetDependencies,
153+
targetsToLinkedReferencesToProducingTargets: targetsToLinkedReferencesToProducingTargets,
154+
dynamicallyBuildingTargets: dynamicallyBuildingTargets
155+
)
156+
)
118157
}
158+
119159
self.init(workspaceContext: workspaceContext, buildRequest: buildRequest, buildRequestContext: buildRequestContext, allTargets: allTargets, targetDependencies: targetDependencies, targetsToLinkedReferencesToProducingTargets: targetsToLinkedReferencesToProducingTargets, dynamicallyBuildingTargets: dynamicallyBuildingTargets)
120160
}
121161

0 commit comments

Comments
 (0)