Exploring Swift Build Times
Dominic Ancrum
Dominic Ancrum

Exploring Swift Build Times

Dominic Ancrum, iOS Engineer

I was recently working on a product at Prolific and one of my early realizations was that a full build in Xcode would always take several minutes. At first I didn’t know exactly how long it to build, but it was easily in the neighborhood of five minutes.

Over the course of the next few weeks, it seemed like the build times were slowly creeping upwards. While new features, code refactoring and bug fixes meant the codebase didn’t remain static, the build times I was seeing piqued my curiosity as to what might be increasing them. Xcode’s tendency to perform a full build after even the smallest changes, or after checking out a different Git branch, means typical tasks such as code reviews and debugging changes made on different branches are fairly time consuming.

This reminded me of an article I had previously read, demonstrating a way in which the Swift compiler could be tripped up by seemingly benign expressions. Though the article is somewhat dated and deals with Swift 2.2 code, it was relevant because the project I was working on was written in Swift 2.2 and built with Xcode 7 at the time.

Further searching uncovered similar blog posts demonstrating how to profile Swift compilation times, as well as more possible culprits for increasing compile times. Finally, in two Medium articles, most notably in part two, Robert Gummenson points out several possible causes of long build times and provides a plugin for Xcode 7, Build Time Analyzer, that allows one to easily see how long the build process is taking. With these tools in hand, I had everything needed to find out where the possible trouble spots in the project were.

Profiling to Gauge Optimization Effectiveness

To understand what the effect that any changes had on long build times, I decided to take the most contemporary build of the project with the optimizations and a stable version of the project from before any optimizations were made and compare their clean build times using Gummenson’s Build Time Analyzer plugin for Xcode 7. Since build times can vary from run to run, I decided to take an average of ten clean builds for each version of the project and compare them. I also decided to have as few apps running along Xcode as possible in order to make sure there weren’t processes that might be inadvertently slowing down build times.

The notebook I did the profiling on is an early 2014 MacBook Air with a 1.4GHz Intel Core i5 CPU and 4GB of RAM. I would expect significantly different results in terms of the quotes times on a machine with different specs. However, the general outcome of the optimized project building faster than an unoptimized build should hold true.

The Results

Over the course of ten builds, the average build time for the unoptimized version of the project was about six minutes. The average build time for the optimized version was a little less than five and a half minutes. The optimizations decreased average build times by about 9%.

The top offenders in terms of build times fell into one of two categories:

1. Lazily initialized views

2. Model objects being decoded via the Argo framework

The various get{} and (closure) functions referenced above are lazily initialized properties. Those lazy properties tended to be in the vein of the following code snippet from ReservationDatesView:

lazy var collectionView: UICollectionView =  {
    // Collection view set up code
    ...
    return collectionView
}()

The above snippet had the second lengthiest build time, taking 5174ms to build. Refactoring this in the manner similar to the suggestions in Gummenson’s article:

lazy var collectionView: UICollectionView = self.createCollectionView()
...
func createCollectionView() {
    // Collection view setup code
    ...
    return collectionView
}

This reduced the build time for this property to 197.4ms, a 96.18% decrease in build times for this one property.

The various decode(json:) functions in the above screenshot are implementations of Argo’s decode(json:) method from the framework’s Decodable protocol, which is used to create instances of model objects from downloaded JSON data. A typical implementation, such as this example from UserSession.swift, looked like the below snippet:

static func decode(json: JSON) -> Decoded<UserSession> {
    return curry(UserSession.init)
        <^> json <| "access_token"
        <*> json <| ["profile", "contact_info"]
        <*> json <| ["profile", "profile_id"]
        ... 
        <*> (json <| ["profile", "email_unsubscribe"] >>- 
            { return Decoded.Success(!$0) })
        <*> json <| ["profile", "linked_social_account"]
}

The above implementation took 1390ms to build before refactoring. In this case, refactoring involved creating an intermediate value with just some of its properties before joining it with its remaining properties and returning the resulting value.

static func decode(json: JSON) -> Decoded<UserSession> {
    let session = curry(UserSession.init)
        <^> json <| “access_token”
        <*> json <| ["profile", "contact_info"]
        <*> json <| ["profile", "profile_id"]
        ...
        
    return session
        <*> (json <| ["profile", "email_unsubscribe"] >>- 
            { return Decoded.Success(!$0) })
        <*> json <| ["profile", "linked_social_account"]
}

The build time for this function after the above refactor was reduced to 115.2ms, a 91.71% reduction.

This same approach of building up a value to be returned  from a function by using intermediate values  was used to refactor the top offender in the above screenshot, serializeForKeychain. This method took 15358.8ms, or over 15 seconds, to build during one clean build. It is partly represented in the below snippet:

func serializeForKeychain() -> [String: AnyObject] {
    var keychainData: [String: AnyObject] = [
        “firstName”: firstName,
        “email”: email,
        ... 
        “addressLine1”: address.lineOne
        “addressLine2”: address.lineTwo ?? “”
        “postalCode”: address.postalCode 
        ... 
        “emailSubscribed”: emailSubscribed
        ... 
    ]
 
    ... 
 
    return keychainData
}

Since most of the key/value pairs had Strings as their values, a colleague of mine refactored the above method by creating three intermediate dictionaries, two of which had more concrete types for their values, and concatenated them together as a dictionary of type [String: AnyObject]:

func serializeForKeychain() -> [String: AnyObject] {
    var userData: [String: String] = [
        “firstName”: firstName,
        “email”: email,
        ... 
    ]
    var addressData: [String: AnyObject] = [
        “addressLine1”: address.lineOne
        “addressLine2”: address.lineTwo ?? “”
        “postalCode”: address.postalCode 
        ... 
    ]
 
    var otherData: [String: Bool] = [
        “emailSubscribed”: emailSubscribed
        ... 
    ]
 
    var keychainData: [String: AnyObject] = userData + addressData + otherData
 
    ... 
 
    return keychainData
}

After the above refactor, this build time for this function was reduced to 1433.3ms, a 90.77% decrease in the build time for this one function. This refactor was even more impactful when you take into account that this one function took fifteen seconds before being refactored. Approximately half of the average decrease in clean build times for the project was achieved just by refactoring this one function.

The end result of these refactors was a reduction of about half a minute off of clean build times. While performing build time profiling showed there weren’t quite as many trouble spots in the project as I was expecting, it also showed where there were opportunities for improvement. For the relatively simple refactoring involved, taking thirty seconds off of clean build times seems like an easy win.

Lingering Questions

There are a few questions that still remain.

Since the codebase was written in Swift 2.2 with Xcode 7 at the time I profiled it, one question that immediately comes to mind is whether Xcode 8 and Swift 3 would make a difference in clean build times. The improvements in build times for mixed Objective-C and Swift projects in Swift 3.1 shows that reducing build times is an area of focus for the Swift team. It might be worth revisiting this topic and profiling the project’s clean build times once the migration of the codebase and its dependencies to Swift 3 is complete.

My second question concerns whether it’s worth the effort to profile one’s build times and then refactor specifically with the goal of reducing build times. This kind of refactoring has the potential to negatively impact the code base in terms of code clarity. In our case, it didn’t negatively impact readability or maintainability, so the trade-off was worth it. For other projects, the refactoring necessary may be considered more trouble than it’s worth.

Still, I believe it is worthwhile to at least profile your project’s clean build times every once in awhile, particularly if your project includes a lot of lazy properties or makes heavy use of the sorts of expressions that bog down the Swift compiler. It could be a relatively simple win in terms of build times to profile build times and refactor any trouble spots that make themselves apparent.