Analyzing and Improving Build times in iOS
Build Time, Compilation Modes and Tools
In this article, I write about:
- Analyzing XCode Projects Build Time with XCLogParser.
- Analyzing Swift Code with Sitrep.
- How I decreased our iOS Project Debug Build-Time by 40%!
- How a Swift compiler bug made our Release-builds take roughly 50 minutes (and how I solved that problem).
Debug-Mode.
A few months ago I set out to decrease our project compile-time. It was taking us around 6–8 minutes to compile a clean build in debug mode. Each time I had to clear the Derived Data folder and compile a fresh build, I couldn’t help to think that I needed to do something, because it was taking too long.
The app in question depends on something like 35 external frameworks managed by Cocoapods. It actually needs less than a dozen frameworks, but those have in turn other dependencies and the total number rises up to 35.
Sitrep for some stats
To give you an idea of the size of the app I’m working on, I’ll share some stats that I’ve gather using Sitrep. Sitrep is a tool built by Paul Hudson that gives you a quick insight into your Swift Project code stats. I’ve used it to measure the number of lines of code for each module, the app and the Pods the app depends upon.
Using Cocoapods
The way Cocoapods handles the dependencies is by checking out the source code and introducing it as part of our app project. Whenever we compile an app, the dependencies code is also compiled. So, the project needs a lot of time to build every single one of those frameworks.
The next image shows part of the analysis of the build report, created with XCLogParser, of the app compiled with Cocoapods.
How can we improve this? How can we avoid compiling all those frameworks that haven’t changed, each time we need to compile our project?
One answer is to compile those external dependencies only once and use those pre-compiled frameworks (dynamic libraries).
Using Carthage and Pre-Compiled Frameworks.
With Carthage, we can specify our dependencies and compile them only once, outside the build process of the app target. Once the dependencies are compiled, the app target uses them to build, link and run.
The compilation occurs when we run carthage bootstrap
or carthage update.
The tool will use xcodebuild
to compile the targets exposed by each framework and as a result, we will get the .framework
objects.
So, the app target references pre-compiled frameworks instead of source code. When we create a clean build, XCode compiles only the app source files, avoiding the compilation of the external dependencies. This saves a lot of time.
Take a look at the build time of the same app, using pre-compiled frameworks managed by Carthage.
A massive 40% time decrease!
What’s the reason behind this improvement? Simply, there’s less code to compile! The Pods were taking 44.4% of the app code. By migrating most of the dependencies to Carthage (I couldn’t migrate them all yet) the Pods percentage dropped to 11.5.
37% less code to compile! The total number of lines went from 187990 to 118037.
Future Improvements.
Pre-Compiled Modules. The architecture of the app in question, a modular approach described in this article I wrote, depends on all modules being compiled as part of the main target. A further improvement for a larger project could be to separate such modules and treat them as Carthage dependencies.
That would require the pre-compilation of each module before-hand. That would save time when compiling the main target. This approach could be implemented using git submodules to handle changes in the source code of the different modules.
Static Frameworks. Another improvement could be to use static frameworks. Even though this would not improve the compile-time, in theory, it would improve the launch time of the app.
SPM maybe. I haven’t tried SPM yet. I imagine that the same can be achieved with the Swift Package Manager and perhaps there are fewer steps to configure the project. Have you tried it? Can we have pre-compiled frameworks with it? Any thoughts about it?
Migrating from Cocoapods to Carthage.
The migration process was fairly simple but slow, requiring a lot of trial and error and testing. It’s true that Carthage requires some more knowledge about Xcode, build phases, project configuration and so on. But it’s not so hard and I found that I’ve learned quite a bit while integrating it.
For example, It’s important to remember that the Framework search path of each module and the main target include the Carthage/IOS/Build
folder, also to include the frameworks in the Link Binary with Libraries build phase and to configure the Carthage scripts properly as described in the documentation.
Some dependencies were tricky to integrate using Carthage, like the Firebase stack. Other dependencies did not have Carthage support. So, we ended up with a mixed solution that uses both Carthage and Cocoapods.
Release-Mode.
For a few months, our project was unusually taking more than 1.2 hours to compile and upload to TestFlight from Bitrise. We didn’t understand why this was happening for quite a while and we thought it was a Bitrise issue.
The entire Bitrise release flow took 1.5 hours! Our project was taking roughly 6–8 minutes to compile in debug mode. But in Bitrise, in Release mode, we discovered that compilation was taking like 45 minutes.
One cool feature of Bitrise is that you can run a workflow and remotely see the virtual machine that is running it. This way, I was able to investigate a bit more and discover what was going on.
After several attempts to decrease the compilation time, playing with Bitrise workflows, Build Phase Scripts, migrating from Cocoapods to Carthage, etc. I discovered that the problem was simpler. The Release-Build mode in itself was the culprit. There was nothing wrong with Carthage or any of the post-build phase scripts (Sourcery, SwiftLint, etc) or even Bitrise.
So, I found the problem, Release-Mode builds were taking too long. But what was the cause? The XCode reports didn’t yield any relevant information. I could see that the build was taking roughly 45 minutes and that the compiler hanged on something like 10 files.
Even when compiling with diagnostic flags to report compilation times, the XCode report was not useful. The Xcode report only was showing a list of files being compiled, the compiler hanged until half an hour later the report yielded no relevant information.
My intuition was that the code optimization in release mode could be the cause, and as it turned out, I was right. I turned on the code optimization compiler flags in Debug-mode and compiled the project. The build went from 6-8 minutes to 45 minutes.
The problem was code optimization. Compile-Time with no optimization: 6–8 minutes; with optimization: 45 minutes!!
Analyzing the code using XCLogParser.
So, what class, method or function was breaking the compiler? I couldn’t find that information in the XCode build reports. Luckily I found XCLogParser.
The tool allowed me to see which file was taking so long to compile. That was a huge step. I analyzed the files and eventually, I found a switch statement on a struct that looked suspicious because it was very large. I commented out that statement and suddenly the build time was normal.
To fix the issue, I replaced some of the inner statements of the switch that were calling ==
on other entities. I simplified the code using identifiers only, and that seemed to lower the complexity of the code optimization.
I couldn’t mimic the problem and take it to a simpler form, to replicate what to me seems like a compiler bug. But I was happy enough I fixed the issue.
Code optimization
During this project, I found two issues with the Swift compiler, both related to code optimization and handling of switch statements.
In the case I mention here, the Swift compiler hanged for 40 minutes or so trying to optimize a switch statement on an enum that contained a comparison of another enum, which contained another switch statement.
I couldn’t recreate the conditions in a minimal example to understand what exactly was the problem, but after changing the switch statement that referenced the equals method of the inner structures, the problem went away.
Conclusions
- Sitrep is a small tool that allows us to quickly analyze the overall stats of a project. You can count the classes, structs, lines of code, etc. It’s nice to see where your project is at from time to time.
- XCLogParser is a great tool to analyze our builds. Understanding our builds is paramount to achieve better compile times.
- Bitrise’s Build with Remote Access is a nice tool to see what’s wrong with our CI build live.
- Pre-built frameworks yield lower compilation times and Carthage is a good alternative to Cocoapods when you need to optimize for it.
- Finally, there are differences in compiling code with or without optimization. In our case, the compiler halted upon a complex Swift statement while optimizing the code. That might have happened due to a bug in the compiler which I couldn’t reproduce with a simpler case.