Cutting a 4-minute Xcode build to 50 seconds
A clean build was four minutes. An incremental build after changing one line was ninety seconds, which is long enough to lose your train of thought and open a browser tab.
Most of it turned out to be the Swift type checker, and finding out which lines were responsible took one build setting.
Measure first
Add these to Other Swift Flags in build settings:
-Xfrontend -warn-long-function-bodies=100
-Xfrontend -warn-long-expression-type-checking=100
That emits a warning for any function body or expression taking longer than 100ms to type-check. Build, then sort the issue navigator by that warning.
The first time I did this, one expression took 8.4 seconds. On its own. In a file I edited daily.
The three patterns that caused most of it
Mixed-type arithmetic in one expression. This is the big one:
// 8.4 seconds
let spacing = (width - padding * 2 - CGFloat(items.count - 1) * gap) / CGFloat(items.count)
Every numeric literal in Swift is generic over ExpressibleByIntegerLiteral, and every operator is
overloaded. The type checker has to consider every combination of Int, Double, CGFloat and the
rest for every sub-expression, and the search space is exponential in the number of terms.
Breaking it up with explicit types collapses the search:
// 40ms
let count = CGFloat(items.count)
let totalGaps: CGFloat = (count - 1) * gap
let available: CGFloat = width - padding * 2 - totalGaps
let spacing: CGFloat = available / count
Same arithmetic, four statements, two hundred times faster to compile. It also reads better, which made this an easy sell to myself.
Collection literals without a type annotation.
// slow: the checker must infer the element type from all elements
let config = ["timeout": 30, "retries": 3, "backoff": 1.5]
// fast
let config: [String: Double] = ["timeout": 30, "retries": 3, "backoff": 1.5]
Long + chains on strings or arrays. Same problem as arithmetic — + is heavily overloaded.
String interpolation is both faster to compile and easier to read.
Tip
The rule that covers all three: annotate the type when an expression has more than about three
terms, or mixes numeric types. Type inference is a convenience, and it is one that gets
superlinearly expensive. Writing : CGFloat costs you nothing.
Modularisation, and what it actually bought
Splitting the app into local Swift packages is the advice everyone gives. It helped, and less than I expected.
The win is that Xcode does not rebuild a module whose sources did not change, so editing a view only rebuilds the feature module and the app. On this codebase, incremental builds went from 90 seconds to about 25.
The cost is real: local packages need their dependencies declared explicitly, previews sometimes break in ways that are hard to diagnose, and a circular dependency between two feature modules is a refactor rather than a warning.
My advice would be to do the type-checker work first. It is a few hours, it needs no architectural change, and on my codebase it was the larger of the two wins.
The build settings worth checking
SWIFT_COMPILATION_MODE should be incremental for Debug and wholemodule for Release. If Debug
is set to whole-module, every change rebuilds every file in the module — I found this on a project
where someone had set it globally to “make Release faster”.
DEBUG_INFORMATION_FORMAT should be dwarf for Debug, not dwarf-with-dsym. Generating a dSYM
on every debug build is pure waste; you need it only for release symbolication.
ONLY_ACTIVE_ARCH should be YES for Debug. Otherwise you are building for architectures you are
not running.
ENABLE_PREVIEWS — worth knowing that previews build additional variants, and turning them off
in a scheme you use for CI or profiling shaves time.
What else helped
Reducing @_exported import. These make everything downstream depend on everything upstream,
which defeats incremental compilation across module boundaries.
Explicit private and fileprivate. Beyond the readability argument, it lets the compiler skip
work when checking what could have changed. Marking classes final helps for the same reason.
Removing dead code. Obvious and easy to postpone. About 4,000 lines in my case, and the compiler was type-checking all of them.
The result
| Before | After | |
|---|---|---|
| Clean build | 4m 10s | 1m 20s |
| Incremental (one view) | 90s | 12s |
| Incremental (one model) | 90s | 25s |
The single largest contributor was the type checker work — about half the total improvement, from a morning of adding type annotations to roughly forty expressions.
If you do one thing from this: turn on the two warning flags and look at what comes out. It is five minutes, and the list will surprise you.