Build performance can be analyzed in Xcode to speed up your builds. This can quickly speed up your workflow and save a lot of time during the day for all developers working on the project. Slow builds often distract us as they enable us to focus on distractions like social media and Slack.
By investigating your build performance and investing some time to improve where possible, you’ll see that you can make progress with a few small steps. I’ve made our WeTransfer’s incremental build time 4x faster using several optimizations. Let’s dive into the options we have today.
Measuring build performance using the Xcode Build Timeline
Before we start diving into optimizations, it’s essential to measure your project’s baseline. You can compare the baseline continuously along the way of implementing improvements.
Start by running a clean build using CMD ⌘ + SHIFT ⇧ + K
or selecting Product ➔ Clean Build Folder...
from the menu. The build folder contains cached build files that will speed up incremental builds but lead to an incorrect baseline for performance optimizations. It’s essential to compare builds using the same environment you’ll create by cleaning your build folder.
You can start a fresh build after Xcode completes the cleanup. Select your build inside the Report Navigator once the build completes, and make sure to open the assistant to show the Build Timeline:
The assistant shows you where all time is spent during the clean build. In the above example, you’re looking at the optimized version of the WeTransfer project with optimized parallelization. The latter means that my MacBook’s cores are used optimally since every row is filled constantly with build processes. I encourage you to watch the WWDC 2022 session Demystify parallelization in Xcode builds to learn all about this concept.
We will use this Build Timeline as our starting point for investigating build performance. You can find potential improvements by zooming into specific blocks that execute serially. For example, the above timeline still shows the following isolated run shell script:
After clicking the specific build block, you can find more information about the executed process. In my case, I found out which run script we were looking at and concluded we could not optimize further for clean builds. I’ll dive deeper into these specific optimizations later.
Build performance insights with Build Timing Summary
The Build Timing Summary is another baseline insight you can use during build performance optimizations. It builds your project once and summarizes time spent per category.
The action can be triggered in the product menu using Product ➔ Perform Action ➔ Build with Timing Summary
or using xcodebuild -showBuildTimingSummary
.
After executing this action, you’ll see that Xcode starts building your project on the selected target device or simulator.
Navigate to the Report Navigator after the build is finished and select the last build. Select “Recent” and scroll all the way down until you see the Build Timing Summary.
This can be another excellent starting point for investigating where you should improve your project. In most cases, most time will be spent on compiling Swift files, for which you should focus on parallelization optimizations. Also, I recommend you look at the run script execution times to see if there’s room for improvement.
Note that I did a clean build here which is different from doing an incremental build. It’s worth executing the action directly again, resulting in a different Build Timing Summary. You’ll run incremental builds most of the time, so it’s worth finding slow parts that influence both clean and incremental builds.
The above example represents an incremental build after completing optimizations for the WeTransfer app. We’re nearly running any scripts and completed the build in an acceptable 5 seconds.
How can the duration of Compiling Swift Sources be longer than the total build time?
Before we dive into optimizations, I want to point out that compiling the Swift sources takes longer than the total build time. The above build took 71 seconds to succeed, while compiling the Swift sources took 352 seconds.
I’ve asked Rick Ballard from the Xcode Build System team for clarification, and he gave some great insights into how the system works:
Yes – many commands, especially compilation, are able to run in parallel with each other, so multicore machines will finish the build much faster than the time it took to run each of the commands.
Optimizing build phases
Optimizing build phases is a great way to speed up Xcode builds. Some of our run scripts might not be required for debug builds and should only be configured to run for release builds.
While writing this blog post, I’ve been trying to improve the build times of the Collect by WeTransfer app, which I built during my day-to-day job. I found out that most of our time was spent on executing SwiftLint. For our main target, it took 10 seconds to execute for every incremental build.
One small improvement we found was adding the --quiet
parameter, but we only gained less than a second per build. All bits helped, so we decided to keep this in. Our real significant improvement was filtering out files that weren’t changed. As we run SwiftLint in many of our submodules, we easily gained ~15 seconds of improvement per build, including all targets.
The code related to SwiftLint is quite specific to projects that use this linter tool. If you want to see our final solution, I encourage you to check out this pull request.
Only run a build phase if needed
If you have a build script you want to run only for debugging or release builds, you can include the configuration check:
In this case, we’re only running the SwiftLint script for debug builds. You can obviously do the same by checking for “Release” builds if you want to run a script only for release builds.
Type checking of functions and expressions
To narrow down the causes of slow build times you can enable swift-flags to gather more insights. These flags were already available before Xcode 10, but are still very useful.
The compiler can warn about individual expressions that take a long time to type check using two frontend flags:
-Xfrontend -warn-long-function-bodies=<limit>
-Xfrontend -warn-long-expression-type-checking=<limit>
The <limit>
value can be replaced for the number of milliseconds that an expression must take to type check in order for the warning to be emitted.
To enable these warnings, go the Build Settings ➔ Swift Compiler - Custom Flags ➔ Other Swift Flags
:
With this setting, Xcode will trigger a warning for any function that took longer than 100ms to type-check. This can point you to methods that are slowing down build times. Splitting up those methods, as well as adding explicit types, might result in better build performance.
The above method results in a slow type-check which is bad for build performance. In this case, the slow type check is caused by the shorthand enum case. By adding NSFetchedResultsChangeType
in front of .delete
and .insert
we’ve fixed the warning:
Build settings to speed up build performance
Speeding up Xcode builds by altering a few Xcode build settings was a common technique to quickly gain seconds on an incremental build. Nowadays, Xcode has most of these settings set by default, so there’s little to cover. However, it could be that you’re maintaining an old project in which these settings still need to be set or are overwritten by the wrong values. Therefore, here is a short overview of the recommended settings of today.
Compilation mode
- Debug: Incremental
- Release: Whole Module
Optimization Level
- Debug: No Optimization [-O0]
- Release: Fastest, Smallest [-Os]
Build Active Architecture Only
- Debug: Yes
- Release: No
Debug Information Format (DWARF)
- Debug – Any iOS Simulator SDK: DWARF
- Release – Any iOS SDK : DWARF with DSYM File
Enabling Eager Linking
You can enable eager linking for your project to see if it results in better build times. If enabled, the build system will emit a so-called TBD file for Swift-only framework and dynamic library targets to unblock the linking of dependent targets before their dependency has finished linking. You can learn more about linking by watching WWDC 2022 session Link fast: Improve build and launch times.
Run Build Script Phases in Parallel
Running build script phases in parallel can potentially lead to improved build times. I recommend combining it with User Script Sandboxing to disallow undeclared input/output dependencies. Only scripts with specified inputs and outputs and those configured to run based on dependency analysis will be attempted to run in parallel.
Swift Package Build Plugins
Swift Package Build Plugins influence build times quite a bit. For the WeTransfer app, I discovered that a pre-build plugin causes build-cache invalidation and increased build times for incremental builds. We had the opportunity to switch to a regular build plugin, resulting in fewer cache invalidations and faster incremental builds.
It’s also important to know that a package build plugin always takes about a second to run:
In the above example, there were no files to lint, and the build plugin did not return any build commands. I would’ve expected close to zero seconds impact on overall build times, but there’s a particular minimum work to be done.
Conclusion
Now and then, it’s good to revisit your Xcode build times. You can benefit from every second you gain, and remember that it builds up over time: a second for every build is a minute for every 60 builds you do. Improvements can be made throughout project settings, build phases, and code-type checking improvements.
If you like to prepare and optimize yourself, even more, check out the optimization category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.
Thanks!