MVP Factory
ai startup development

Install

KW
Krystian Wiewiór · · 5 min read

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:

  1. Scans all source files for imports up front
  2. Builds each module exactly once as a discrete graph node
  3. 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

SettingDefaultRecommendedImpact
SWIFT_ENABLE_EXPLICIT_MODULESNO (pre-Xcode 16)YESEliminates implicit module rebuilds, improves parallelism
EAGER_LINKINGNOYESStarts linking before all compile tasks finish
SWIFT_ENABLE_BATCH_MODEYESYESGroups files into batches per core (keep enabled)
SWIFT_WHOLE_MODULE_OPTIMIZATIONVariesYES (Release only)Better codegen but serializes compilation
ENABLE_MODULE_VERIFIERNOYESCatches 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.


Share: Twitter LinkedIn