Install
TL;DR
Xcode’s build system (llbuild) models your project as a dependency graph, but most teams never inspect or optimize how that graph gets traversed. Enabling explicit modules (SWIFT_ENABLE_EXPLICIT_MODULES), configuring eager linking, and auditing your module map topology can get you much better parallelism. In a production Swift codebase with 400+ source files, these changes cut our clean build times by roughly 50%. What follows is exactly what to change and how to measure it.
How llbuild actually works
Under the hood, Xcode delegates to llbuild, a low-level build engine that constructs a directed acyclic graph (DAG) of build tasks. Each node represents a unit of work: compiling a single .swift file, linking a framework, copying a resource bundle.
The thing most teams miss: parallelism is constrained by the longest critical path through this graph, not by your core count. You can have a 16-core M4 Max and still bottleneck on a single serial chain of module dependencies.
Teams throw hardware at this problem when they should be shortening the critical path.
Implicit vs. explicit modules
In implicit module builds (the default prior to Xcode 16), the compiler discovers and builds Clang modules on-demand during compilation. This creates hidden serialization. If two Swift files both import UIKit, the compiler may redundantly build the UIKit module map or block waiting on a shared module cache lock.
Explicit modules fix this. With SWIFT_ENABLE_EXPLICIT_MODULES = YES, Xcode:
- Scans all source files for imports up front
- Builds each module exactly once as a discrete graph node
- Exposes the full dependency structure to the scheduler
The difference is measurable. With explicit modules, llbuild can see the entire module graph before compilation starts, which means better task scheduling and no redundant work.
Build setting comparison
| Setting | Default | Recommended | Impact |
|---|---|---|---|
SWIFT_ENABLE_EXPLICIT_MODULES | NO (pre-Xcode 16) | YES | Eliminates implicit module rebuilds, improves parallelism |
EAGER_LINKING | NO | YES | Starts linking before all compile tasks finish |
SWIFT_ENABLE_BATCH_MODE | YES | YES | Groups files into batches per core (keep enabled) |
SWIFT_WHOLE_MODULE_OPTIMIZATION | Varies | YES (Release only) | Better codegen but serializes compilation |
ENABLE_MODULE_VERIFIER | NO | YES | Catches module map issues that cause silent rebuilds |
Eager linking: don’t wait for stragglers
EAGER_LINKING is a setting most teams have never touched. By default, the linker waits for every object file before starting. With eager linking enabled, llbuild begins the link phase as soon as enough object files are available, overlapping link prep with the tail end of compilation.
In a 400-file target, this shaves real time off the critical path because your last few files to compile are rarely the ones the linker needs first.
Profiling with xclogparser
You can’t optimize what you can’t measure. xclogparser parses Xcode’s .xcactivitylog files and produces build timelines you can actually reason about.
# Install
brew install xclogparser
# Parse the last build
xclogparser parse --project MyApp.xcodeproj --reporter html
# Get the critical path
xclogparser parse --project MyApp.xcodeproj --reporter json \
| jq '.targets[].steps | sort_by(-.duration) | .[0:10]'
The HTML report gives you a Gantt-chart-style build timeline. Look for:
- Long serial chains, where modules build one after another instead of in parallel
- Wide gaps where cores sit idle waiting on a dependency
- Repeated module builds, the hallmark of implicit module thrashing
Before we enabled explicit modules, our build timeline showed 6 cores idle while waiting on a chain of implicitly-built Objective-C modules. After the switch, those modules built as parallel leaf nodes in the graph. The idle gaps disappeared.
The module map trap
Even with explicit modules enabled, a poorly structured module map can reintroduce serialization. If Module A’s umbrella header transitionally imports Module B, which imports Module C, you’ve created a three-deep serial chain that llbuild can’t parallelize.
# Before: Serial chain
ModuleC → ModuleB → ModuleA → YourTarget
# After: Flattened imports
ModuleC ─┐
ModuleB ─┼→ YourTarget
ModuleA ─┘
The fix: audit your module maps with ENABLE_MODULE_VERIFIER = YES. This surfaces circular dependencies and unnecessary transitive imports that silently kill parallelism.
What to do with all this
Enable explicit modules. Set SWIFT_ENABLE_EXPLICIT_MODULES = YES and EAGER_LINKING = YES in your project. These are low-risk changes that give the build system the visibility it needs to schedule work well.
Profile before and after with xclogparser. Generate HTML build timelines for your clean builds. Look for serial module chains and idle core gaps. Those are your optimization targets, not individual file compile times.
Then flatten your module dependency graph. Enable the module verifier, audit transitive imports in your module maps, and break unnecessary chains. The build system can only parallelize what your dependency structure allows. A flat, wide graph will always outperform a deep, narrow one regardless of how many cores you have.
I keep coming back to this: the teams that treat build time as an architecture problem, not a hardware problem, are the ones who actually fix it. The build graph is code. Treat it like code.