Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
steps:
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.2.0' # Specify the desired Xcode version
xcode-version: '16.4.0' # Specify the desired Xcode version

- uses: actions/checkout@v4

Expand Down
22 changes: 20 additions & 2 deletions Sources/DependencyGraph/DependencyGraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,31 @@ public class DependencyGraph<Value: NodeValue> {
/// - Parameter value: the associated value for a node to start the search with
/// - Returns: the chain of nodes, starting with the 'bottom' of the dependency subgraph
public func chain(for value: Value) -> [Node] {
let noFilter: Set<String> = []
return chainWithFilter(for: value, filter: noFilter)
}

/// Returns the dependency 'chain' for the value associated with a node in the graph using a depth-first search
/// while filtering dynamic dependencies.
/// - Parameter value: the associated value for a node to start the search with
/// - Parameter value: a set whose keys indicate node values which will not be chased further.
/// - Returns: the chain of nodes, starting with the 'bottom' of the dependency subgraph
public func chainWithFilter(for value: Value, filter dynamicDependencyFilter: Set<String>) -> [Node] {
guard let node = findNode(for: value) else {
GenIRLogger.logger.debug("Couldn't find node for value: \(value.valueName)")
return []
}

return depthFirstSearch(startingAt: node)
return depthFirstSearchWithFilter(startingAt: node, filter: dynamicDependencyFilter)
}

/// Perform a depth-first search starting at the provided node
/// - Parameter node: the node whose children to search through
/// - Parameter filter: A set of String. If a dependency relationship in the graph is contained in
/// the Set, then add that edge to the chain and continue with the next edge without descending
/// further down the graph.
/// - Returns: an array of nodes ordered by a depth-first search approach
private func depthFirstSearch(startingAt node: Node) -> [Node] {
private func depthFirstSearchWithFilter(startingAt node: Node, filter dynamicDependencyFilter: Set<String>) -> [Node] {
GenIRLogger.logger.debug("----\nSearching for: \(node.value.valueName)")
var visited = Set<Node>()
var chain = [Node]()
Expand All @@ -60,6 +73,11 @@ public class DependencyGraph<Value: NodeValue> {
visited.insert(node)

for edge in node.edges where edge.relationship == .dependency {
if dynamicDependencyFilter.contains(edge.to.valueName) {
GenIRLogger.logger.debug("\tskipping dependency: \(edge.to.valueName)")
chain.append(edge.to)
continue
}
if visited.insert(edge.to).inserted {
GenIRLogger.logger.debug("edge to: \(edge.to)")
depthFirst(node: edge.to)
Expand Down
4 changes: 2 additions & 2 deletions Sources/GenIR/CompilerCommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ struct CompilerCommandRunner {
continue
}

GenIRLogger.logger.info("Operating on target: \(target.name). Total modules processed: \(totalModulesRun)")
GenIRLogger.logger.debug("Operating on target: \(target.name). Total modules processed: \(totalModulesRun)")

totalModulesRun += try run(commands: targetCommands, for: target.productName, at: output)
}
Expand All @@ -90,7 +90,7 @@ struct CompilerCommandRunner {
var targetModulesRun = 0

for (index, command) in commands.enumerated() {
GenIRLogger.logger.info(
GenIRLogger.logger.debug(
"""
\(dryRun ? "Dry run of" : "Running") command (\(command.compiler.rawValue)) \(index + 1) of \(commands.count). \
Target modules processed: \(targetModulesRun)
Expand Down
4 changes: 2 additions & 2 deletions Sources/GenIR/GenIR.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ struct DebuggingOptions: ParsableArguments {
}
}

mutating private func validateXcarchive() throws {
mutating private func validateXcarchive() throws {
// Version 0.2.x and below allowed the output folder to be any arbitrary folder.
// Docs said to use 'IR' inside an xcarchive. For backwards compatibility, if we have an xcarchive path with an IR
// folder, remove the IR portion
Expand Down Expand Up @@ -226,7 +226,7 @@ struct DebuggingOptions: ParsableArguments {
let targets = pifCache.projects.flatMap { project in
project.targets.compactMap { Target(from: $0, in: project) }
}.filter { !$0.isTest }
GenIRLogger.logger.debug("Project non-test targets: \(targets.count)")
GenIRLogger.logger.debug("Project non-test targets: \(targets.count)")

let targetCommands = log.commandLog.reduce(into: [TargetKey: [CompilerCommand]]()) { commands, entry in
commands[entry.target, default: []].append(entry.command)
Expand Down
39 changes: 33 additions & 6 deletions Sources/GenIR/OutputPostprocessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class OutputPostprocessor {
try manager.createDirectory(at: output, withIntermediateDirectories: false)

for node in nodes {
var targetDependencies: [String: [String]] = [:]
let dependers = node.edges.filter { $0.relationship == .depender }

guard dynamicDependencyToPath[node.value.productName] != nil || (dependers.count == 0 && !node.value.isSwiftPackage) else {
Expand All @@ -78,22 +79,33 @@ class OutputPostprocessor {

// Copy over this target's static dependencies
var processed: Set<Target> = []
try copyDependencies(for: node.value, to: irDirectory, processed: &processed)
try copyDependencies(for: node.value, to: irDirectory, processed: &processed, savedDependencies: &targetDependencies)

// Persist the dependency map for this target
try persistDynamicDependencies(map: targetDependencies, to: irDirectory.appendingPathComponent("savedDependencies.json"))
}
}

private func copyDependencies(for target: Target, to irDirectory: URL, processed: inout Set<Target>) throws {
private func copyDependencies(for target: Target, to irDirectory: URL, processed: inout Set<Target>, savedDependencies: inout [String: [String]]) throws {
guard processed.insert(target).inserted else {
return
}

for node in graph.chain(for: target) {
GenIRLogger.logger.debug("Processing Node: \(node.valueName)")
for node in graph.chainWithFilter(for: target, filter: Set(dynamicDependencyToPath.keys)) {
GenIRLogger.logger.debug("Processing Node with product: \(node.value.productName) and value: \(node.valueName)")

// Do not copy dynamic dependencies
guard dynamicDependencyToPath[node.value.productName] == nil else { continue }
guard dynamicDependencyToPath[node.value.productName] == nil else {
// Skip this directory for any dynamic dependency that is not the current one being processed. During preprocessing on the
// platform the modules for this dependency will be retrieved and added to this module.
if irDirectory.lastPathComponent != node.value.productName {
savedDependencies[irDirectory.lastPathComponent, default: []].append(node.value.productName)
}
GenIRLogger.logger.debug(" ---> Skipping dynamic dependency: \(node.value.productName)")
continue
}

try copyDependencies(for: node.value, to: irDirectory, processed: &processed)
try copyDependencies(for: node.value, to: irDirectory, processed: &processed, savedDependencies: &savedDependencies)

let buildDirectory = build.appendingPathComponent(node.value.productName)
if manager.directoryExists(at: buildDirectory) {
Expand Down Expand Up @@ -185,4 +197,19 @@ class OutputPostprocessor {
GenIRLogger.logger.debug("Couldn't determine the base search path for the xcarchive, using: \(productsPath)")
return productsPath
}

/// Persist the dynamic dependecies in the IR folder. The preprocessor will use this data to build modules
/// for BCA.
private func persistDynamicDependencies(map dynamicDependencyToPath: [String: [String]], to destination: URL) throws {
// Convert URL to string for serialization
let serializableDict = dynamicDependencyToPath.mapValues { $0 }

// JSON encode
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
let data = try encoder.encode(serializableDict)

// Write to file
try data.write(to: URL(fileURLWithPath: destination.filePath))
}
}
5 changes: 3 additions & 2 deletions Sources/GenIR/Versions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
// Created by Thomas Hedderwick on 12/09/2022.
//
// History:
// 2025-nn-nn - 0.5.4 -- Update release doc; warn multiple builds; capture debug data
// 2025-nn-nn - 1.0.0 -- Don't chase through Dynamic Dependencies
// 2025-09-19 - 0.5.4 -- Update release doc; warn multiple builds; capture debug data
// 2025-04-18 - 0.5.3 -- PIF Tracing; log unique compiler commands
// 2025-04-09 - 0.5.2 -- PIF sort workspace; log instead of throw
// 2024-09-17 - 0.5.1
// 2024-09-16 - 0.5.0 -- Process based on the PIF cache instead of project files.
import Foundation

enum Versions {
static let version = "0.5.4"
static let version = "1.0.0"
}
53 changes: 53 additions & 0 deletions TestAssets/DependencyChaseFilter/README.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
Test project in TestApp77.xcworkspace (built originally with Xcode 16.1).


TestApp77 (app target) depends on TestLibraryA (framework target) and TestLibraryC (framework target)

TestLibraryA (framework target) depends on TestLibraryB (framework target)

TestLibraryB (framework target) depends on the opentelemetry-swift package.

TestLibraryC (framework target) has no dependencies.



Generated archive is in build/test.xcarchive.


---
See that the IR files from the opentelemetry-swift package (e.g., ZipkinBaggagePropagator.bc) appear in all targets,
except for TestLibraryC (which doesn't have a transitive dependency on opentelemetry-swift).

However, the files should only appear in IR/TestLibraryB, which is the target that references the package. You can
check that only Products/Applications/TestApp77.app/Frameworks/TestLibraryB.framework/TestLibraryB contains symbols
from opentelemetry-swift (e.g., using the `nm` utility).

Why is this a problem? The size of the IR folder is bloating the app:

$ du -chd 1 build/test.xcarchive/IR
26M build/test.xcarchive/IR/TestApp77.app
26M build/test.xcarchive/IR/TestLibraryB.framework
4.0K build/test.xcarchive/IR/TestLibraryC.framework
26M build/test.xcarchive/IR/TestLibraryA.framework
78M build/test.xcarchive/IR
78M total


This app has no actual contents: only TestLibraryB should have anything of
significance from the opentelemetry-swift package (26M), and everything else
should be ~4K. However, we're getting the 26M from opentelemetry-swift multiplied
across all transitive dependencies, which for a big app with ~200 frameworks, can
be multiple gigabytes of extra space.

In particular, we're seeing our zipped app archive go above the 2GiB upload limit!


---
Build command:

rm -rf ./build && xcodebuild clean -workspace TestApp77.xcworkspace -scheme TestApp77 -derivedDataPath ./build && xcodebuild archive -derivedDataPath ./build -workspace TestApp77.xcworkspace -scheme TestApp77 -configuration Debug -destination generic/platform=iOS -archivePath build/test.xcarchive > build_log.txt


Gen-IR command:

gen-ir build_log.txt build/test.xcarchive --debug > gen_ir_log.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
Loading