Advanced techniques to speed up the compile time in Xcode

Kumar Reddy
Swiggy Bytes — Tech Blog
9 min readDec 7, 2019

--

Image source: undraw.co

As an iOS developer, how many times you stare at Xcode to see Build Succeed alert, even for a single line of a code change?

If you are one of those developers, then you are at the right place to discuss the reasons and solutions to speed up the build time.

If something is happening, it should have a reason behind it. Nothing is magical in the real world.

The same rule applies here as well. We can not simply blame Xcode for the Build Time. Xcode is trying to resolve all the dependencies for the respective change and compiling all those files.

In this article, I will be providing a detailed explanation of the Xcode build process. Once we understand the build process, we can look for possible ways to make the build system faster.

Before we dive into the details, let's understand the high-level overview of the Xcode build system and compiler architecture.

Xcode Build System

PreProcessor: The purpose of the preprocessor is to transform your program in a way that it can be fed to a compiler. It replaces macros with their definitions, discovers dependencies and resolves preprocessor directives.

Compiler: Converts your Source Code to Machine code. (that’s what machines understand right?). Below is the Swift Compiler architecture diagram.

Swift Compiler Architecture

Assembler: Assembler translates human-readable Assembly Code into relocatable machine code. It produces Mach-O files which are basically a collection of code and data. Mach-O is a stream of bytes grouped in some meaningful chunks that will run on the ARM processor of an iOS device or the Intel processor on a Mac.

Linker: Linker is a computer program that merges various object files and libraries together in order to make a single Mach-O executable file that can be run on iOS or macOS system.

Object Files + dylib, .a , .tbd => Single Executable file

Loader: Lastly, loader which is a part of the OS, brings a program into memory and executes it. Loader allocates memory space required to run the program and initializes registers to the initial state. Loads dylibs and other dynamic libraries required to run the program. Loader times directly proportional to App Launch Times.

Now, I hope everyone got an understanding of what exactly is happening whenever we press CMD + B. Let’s switch back to our main topic on optimizing the build times. Before we go for that, let's understand different build types.

What is Clean Build?

Clean Build refers to cleaning the entire cache (derived data in case of iOS development) and building the application freshly.

What is Incremental Build?

Once you have clean build, whatever the changes that you do on this will be processed as Incremental Builds.

I have divided the techniques to speed up the build time into 2 phases.

  1. Analyze and setting up the best build settings for the project, which is a one-time effort irrespective of the project size.
  2. Understand the swift dependency graph, which will impact the Incremental Builds. This is a kind of incremental step that as a developer we should need to keep an eye every day.

Phase-1: Setting the best Build Settings

Compilation Mode

This setting instructs the compiler to define the behaviour of driver and frontend jobs. Confused about what is driver and frontend. Let's see what it means.

Driver: The top-level swiftc process in a tree of subprocesses. Responsible for deciding which files need compiling or recompiling and running child processes — so-called jobs — to perform compilation and linking steps.

Frontend jobs: The subprocesses launched by the driver, running swift-frontend and performing compilation, generating PCH files, merging modules, etc. These are the jobs that incur the bulk of the costs of compiling.

There are two different compilation modes available, namely the Primary file and the Whole file.

Primary File Mode: The driver divides the work it has to do between multiple frontend processes, emitting partial results and merging those results when all the frontends finish. Each frontend job by itself reads all the files in the module and focuses on one or more primary file(s) among the set it read, which it compiles.

Primary-file mode advantages are that the driver can do incremental compilation by only running frontends for files that it thinks are out of date, as-well-as running multiple frontend jobs in parallel, making use of multiple cores.

Whole file Mode: The driver runs one frontend job for the entire module, no matter what. That frontend reads all the files in the module once and compiles them all at once.

Whole Module mode advantages are that it can do certain optimizations that only work when they are sure they’re looking at the entire module, and it avoids the quadratic work in the early phases of primary-file mode. Its disadvantages are that it always rebuilds everything.

Debug ConfigurationIncremental Mode, Release ConfigurationWhole Module

Optimization Mode

When running the Swift compiler in optimizing mode, many SIL and LLVM (Please refer to the above swift compiler architecture diagram) optimizations are turned on, making those phases of compilation (in each frontend job) take significantly more time and memory.

When running in non-optimizing mode, SIL and LLVM IR are still produced and consumed along the way, but only as part of lowering, with comparatively few ‘simple’ optimizations applied.

There are 3 types of optimization are available. -Onone stands for No optimization, -Osize stands for Optimized for Size, -O stands for Optimized for Speed.

Debug configuration -Onone, Release configuration -O

Complex Expressions & Type Inference

Type inference is a great feature in swift but sometimes that will end up taking a lot of compiler time to check the inferred type.

How do we know that expression is taking more time?

In a developing environment, we consider 100ms or less is the standard time to resolve an expression. If an expression is taking more than 100ms, we should either try to decouple the expression or help the compiler to understand what could be the final type.

To make this process more automated, we have a couple of flags we can inject, which will give us warnings in the Xcode editor.

-Xfrontend -warn-long-expression-type-checking=100

Add the above flag in Build Settings — > Swift Compiler — > Custom Flags — > Other Swift Flags.

We identified all the expressions and divided them into different categories in the order of ms ( 1000ms, 500ms, 250ms) and resolved them in an incremental way.

Remove dSYM file from Debug

dSYM (debug symbols file) is a file that takes debugging information and stores it in a dSYM Bundle. This file is generated each time you compile your project. You can locate Debug Information Format setting under Build Settings.

Debug Configuration DWARF, Release Configuration DWARF with dSYM file

Run Script Phase

Run script phase will get executed every time when you compile the project. We can optimize this step to get speed build times.

Use Input/Output files for script phases.

If the compiler knows that it is already generated output, it will not re-run the same script again till you clear the derive data or clean build.

If you want your script to execute every time, then do not go ahead with this approach.

Phase-2: Swift Dependency Graph

We discussed a few settings and flags which are required and a one-time setup for any project. But as code is growing on a daily basis, the build time will also get increased until unless we should know how compiler treats your code and how the newly added code will impact the build time.

The compiler does have the dependency graph which will be used when compiling the project. The build system should know which file or module should be built first or which module system can do a parallel building.

There are 3 rules which compiler will follow whenever you do an incremental build.

Rule 1:

When you change the function body in swift function, the Swift dependency graph intelligently will not build any of the dependent files.

Why?

As Swift is a type-safe language, it does not care about what you change in the function body. No compilation required for dependent files.

How to verify:

Just change any function body in your codebase and check the Report Navigator or CMD+9 and make sure to choose Recent Tab instead of All

Rule 2:

When you add a new function, new struct or new extension in a file, swift will conservatively build all files dependent on this file.

Why?

Compared to Objective-C, Swift is a pure file-based language. It means you can freely write structs or class anywhere irrespective of your file name. So whenever a new function, struct or extension added to a file, swift conservatively builds all the dependent files.

How to verify:

Illustration purpose

In the above diagram, I have added all the extensions in one file AllExtensions.swift. As this file contains all the extensions, whenever you add a new extension to this file or a new protocol, it compiles all the dependent files.

For every line change in the AllExtensions.swift, the compiler will end up compiling close to 8 files. Once we restructure our codebase to the right part of the diagram, if you change any UIFont extension, now the compiler will compile only 2 files.

Before Optimisation
After Optimisation

As seen above, the number of files compiled for a new line change in SwUtitlity.swift file is close to 3 files when compared to 30 files before optimization.

So, this could be the reason for Xcode to take more time to build your project, even for a small code change in a file.

Solution:

Do not create a file with Utility or Extension and not to dump all the utility functions in one class. Instead, create separate files for different extensions or split the utilities into separate files.

Rule 3:

If you are using framework based architecture, any change in the dependent framework will let the container app to compile all the files.

Why?

As this container app depends on the framework, whenever you change in the framework swift will builds the entire container app.

How to Verify:

Let’s change in any of the dependent frameworks and check the Report Navigator.

Once you know the swift dependency graphs, You can able to understand the why Xcode is taking more time for incremental build and able to speed up the build process.

What about results

We optimized the build script execution which resulted us the script execution time from 20 seconds to 4 seconds.

We resolved complex expressions which reduced the compile time by close to ~40 seconds.

After all the code cleanup and followed the three rules mentioned in the above, the clean build time now reduced from 300 seconds to ~250 seconds and incremental build reduced from 30–120 seconds to 10–70 seconds.

--

--