Improve iOS build time

はじめに


AWAという音楽ストリーミングサービスでiOSエンジニアをやっている小梛です。

AWAでは、Build時間が長いことによる開発効率の低下が定期的に問題になっており、高速化のためにさまざまな試行錯誤を重ねてきました。
その概要については、昨年末CA.swiftというiOS勉強会において「Build時間改善」というタイトルでLTさせていただきました。

ただ、このLTから既に半年が経過し、Xcodeのアップデートもあったことで、一部挙動が変わっていたりします。
本記事では、最新データを再調査した上で、LTでは伝えきれなかった詳細部分についても含めてBuild高速化についてご紹介できればと思います。

目次


調査環境


macOS Sierra / Xcode 8.3.3 / Swift 3.1

Build時間の計測方法


高速化をする前に、まずはBuild時間を計測できるようにしておきます。

全体のBuild時間

ターミナルで以下のコマンドを叩いてからXcodeを起動することで、Build終了後に全体のBuild時間をXcode上に表示してくれるようになります

$ defaults write com.apple.dt.Xcode ShowBuildOperationDuration YES

Xcode上にBuild時間が表示される
Xcode上にBuild時間が表示される

メソッド / ファイルごとのCompile時間

ざっくり知りたい場合

-Xfrontend -warn-long-function-bodies=200
をBuild SettingsのOther Swift Flagsへ追加することで、Compileに200ms(任意の時間を設定可能)以上かかったメソッドに対してwarningを出してくれるようになります。

Compile time warning
Compile time warning

細かく知りたい場合

Build Time Analyzer for Xcodeという便利なツールがあるので、こちらを使います。 githubからrepositoryをClone or downloadし、自分のPC上でRunすることでMacアプリケーションとして使うことができます。

Build Time Analyzer for Xcode
Build Time Analyzer for Xcode

手順

  1. 計測したいプロジェクトのBuild Settings -> Other Swift Flagsに以下のフラグを追加
    -Xfrontend -debug-time-function-bodies
  2. Clean Build
  3. Build Time Analyzer for XcodeをRun
  4. 計測結果を見たいプロジェクトを選択

計測結果

遅い順にソートされており、行をクリックすることで該当ファイルを開くことができます。また、 LocationとFunctionを見ることで具体的にどこが遅いのかをおおよそ特定もできます。

メソッドごとのCompile時間
メソッドごとのCompile時間
ファイルごとのCompile時間
ファイルごとのCompile時間

この例ではSample1.swiftの lateMethod(type:text:) メソッドのcompileに約150秒もかかっていることが分かります

注意点

-Xfrontend -warn-long-function-bodies=200

-Xfrontend -debug-time-function-bodies
をOther Swift Flagsへ追加することで、計測による数秒程度のオーバーヘッドが発生します。
計測後、不要な場合は外しておくことをおすすめします。

Build設定の最適化


計測の準備が整ったので、本題に入ります。

まずはBuild設定の最適化についてです。Xcode周りの設定にはBuild時間が大幅に変わるものが複数あります。未設定の内容があれば、一度試してみることで高速化が見込めるかもしれません。

1. Build Active Architecture Only

必要なArchitectureのBuildのみに限定するためのBuild Active Architecture Onlyという設定があります。もしDebug時にもNoになっている場合はYesにします。

 

Build Active Architecture Only
Build Active Architecture Only

2. Generic iOS DeviceをBuild対象にしない

同様に、Buildする際の対象をBuild Only DeviceのGeneric iOS DeviceにしてしまうことでiOSのアーキテクチャ全てに対するBuildとなってしまいます。
これによってBuild時間が倍近くかかることもあるため、Archive時など特別な理由がなければ単一アーキテクチャに対するBuildである実機もしくはSimulatorを選択します。

Build対象の選択
Build対象の選択

3. 並列Compile数

以下のコマンドで並列Compileするファイル数を変更できるのですが、この数によって全体のBuild時間も大きく変わってきます。

$ defaults write com.apple.dt.Xcode IDEBuildOperationMaxNumberOfConcurrentCompileTasks 4

基本的にはPCのCore数に合わせることで最も効率よく並列処理できます。
プロジェクトの状態や環境にも依存するので、私の環境ではCore数4に対して並列Compile数8が最速となりました。

Core数の確認方法

このMacについて概要システムレポート…ハードウェアコアの総数

もしくはターミナルで

$ system_profiler SPHardwareDataType

と叩き、出てきた結果の Total Number of Cores がそのPCのCore数です。

4. dSYM

Crashファイルを解析するためのdSYMファイルですが、Debug時には不要なことが多いかと思います。
その場合はDebug Information Formatで DWARF with dSYM FileDWARF とすることで生成する時間を省くことができます。
(最近のXcodeバージョンではプロジェクト作成時にDebug時は標準で生成しないようになっています)

dSYMの設定
dSYMの設定

5. CocoaPodsからCarthageへ移行する

CocoaPodsはClean Build時やCI環境で毎回全ライブラリをBuildし直しますが、Carthageは最初に1度BuildしてFramework化しておけばそれ以降更新があるまではBuildする必要はなくなります。
ライブラリの数やサイズによってはBuild時間の大半を占めてしまうこともあるため、Carthage対応されているものはそちらへ移行することで大幅に時間短縮できます。

6. 共通コードをEmbedded Framework化する

他のTargetやApp Extensionなどからも使えるような共通コードはEmbedded Frameworkとして切り出しておきます。
そうすることで依存関係が簡潔になり、差分Buildが効きやすくなります。

注意点

5. CocoaPodsからCarthageへ移行する についても言えることですが、Dynamic Frameworkが増えるにつれてアプリの起動時間が長くなることがあります。
起動時間が問題になる可能性がある場合は、そちらとのトレードオフであることを前提に判断したほうが良いかと思います。

参考: Optimizing App Startup Time – WWDC 2016

7. Build時の最適化設定

Build SettingsではApple LLVMとSwift CompilerのBuild最適化レベルを変更できます。
基本的に、この最適化レベルを上げれば実行時のパフォーマンスが向上し、その代わりBuild時間が長くなるというトレードオフの関係があります。
そのため、Build時間を優先したいDebug Buildに対するデフォルト設定は両方とも最適化を行わないよう以下のようになっています。

Optimization Level default
Optimization Level – Default

しかし、最適化を行わないこの設定でも、大規模プロジェクトになってくるとClean Buildの時間が顕著に遅くなってくることがあります。
そこで、以下のようにSwift CompilerのOptimization LevelをFast, Whole Module Optimization(※)へ引き上げつつも、Swift Other Flagsで-Ononeを指定することで「モジュール内の全体最適化を行いつつ、コード自体のCompileへの最適化は無効化する」という設定を行います。

Swift Compiler Settings - After
Swift Compiler Settings – After

AWAのプロジェクトにおいてこの設定をした結果、以下のような変化が見られました。

While Module Optimization Build Time
While Module Optimization Build Time

差分Buildは遅くなってしまいましたが、Clean Buildが大幅に速くなりました

Clean Buildが速くなるということは、gitのbranch切り替え時に発生しやすいFull Buildの時間も速くなります。
そのため、差分Buildの時間を犠牲にしてでも、Clean Buildを速くしたほうがメリットが大きいという判断の元、AWAではこのような設定にしています。

※ ここで設定しているWhole-Module OptimizationでCompileの処理がどう変化するかについて詳しく知りたい方は、Apple公式のBlogに記事があるのでそちらをご参照ください。
Whole-Module Optimization

コードベースのCompile時間削減


次にメソッド / ファイルごとのCompile時間の計測結果に時間がかかっているメソッドがある場合はこれを改善していきます。

遅い原因

主にコンパイラの型推論に時間がかかっているのが原因です。

何気ないコードでも非常に遅くなっていることもあり、ひとつひとつ書き換えていくことによって全体で数分単位で短くできる場合もあります。
また、構文の解釈に時間がかかるということは補完が効くまでの時間やシンタックスハイライトが効くまでの時間も長くなるため、これを改善することでコーディング中の効率向上にもつながります。

改善方法

基本的には以下の手順で進めます

  1. 何msを超えるメソッドを改善するかの閾値を定める
  2. メソッド / ファイルごとのCompile時間の方法で閾値を超えるメソッドを割り出す
  3. 実装を見て型情報が足りなさそうな箇所を推測
  4. 書き換えてみる
  5. 3.〜4. を繰り返す

主な型推論が発生するパターンとしては、変数定義時に型を与えてやらなかったり、ジェネリクスやオーバーロードで定義されたイニシャライザやメソッドを使う時などがあります。特に +, -, /, *, ??, ||, &&, ==, ?, ... などの演算子は、ジェネリクスやオーバーロードで定義されている割に頻繁に使うものです。無意識の内に演算子によって式を複雑にしたり、大量に連結したりすることで、Compile時間が指数関数的に増加してしまいます。

以下にいくつかの例を挙げます。

配列の連結

【Late】 連結数に対し指数関数的に遅くなる

let ary = [1, 2, 3]

// 連結要素7個
// 2000ms
let result = ary + ary + ary + ary + ary + ary + ary

// 連結要素8個
// 21000ms
let result = ary + ary + ary + ary + ary + ary + ary + ary

// 連結要素9個
// 285000ms
let result = ary + ary + ary + ary + ary + ary + ary + ary + ary

【Fast】 [[Int]] 型にしてから flatMap でならすことで演算子による連結を避ける

let ary = [1, 2, 3]

// 要素9個
// 4ms
let result = Array([ary, ary, ary, ary, ary, ary, ary, ary, ary])
    .flatMap { $0 }

Nil-Coalescing Operator

【Late】 複雑な式において ?? (Nil-Coalescing Operator)を使うと遅くなりやすい

// 28700ms
let result = (str1 ?? "") + (str2 ?? "") + (str3 ?? "") + (str4 ?? "")

【Fast Pattern 1】 先にunwrapする

// 1ms
let unwrappedStr1 = str1 ?? ""
let unwrappedStr2 = str2 ?? ""
let unwrappedStr3 = str3 ?? ""
let unwrappedStr4 = str4 ?? ""
let result = unwrappedStr1 + unwrappedStr2 + unwrappedStr3 + unwrappedStr4

【Fast Pattern 2】 as によって明示的に型情報を与える

// 2ms
let result = (str1 ?? "" as String)
    + (str2 ?? "" as String)
    + (str3 ?? "" as String)
    + (str4 ?? "" as String)

【Fast Pattern 3】 文字列内で変数展開する

// 5ms
let result = "\(str1 ?? "")\(str2 ?? "")\(str3 ?? "")\(str4 ?? "")"

Initializerやメソッドなどでジェネリクスな引数に複雑な式を渡すと遅くなりやすい

【Late】 ジェネリクスメソッドである max(_:_:) の引数に複雑な式を書く

// 844ms
func lateMethod(p1: CGFloat?, p2: CGFloat?) -> CGFloat {
    return max((p1 ?? 0) + 1, (p2 ?? 0) + 1)
}

【Fast】 先に型を確定させてから引数に渡す

// 2ms
func fastMethod(p1: CGFloat?, p2: CGFloat?) -> CGFloat {
    let p1 = (p1 ?? 0) + 1 // CGFloat
    let p2 = (p2 ?? 0) + 1 // CGFloat
    return max(p1, p2) // max(_ x: CGFloat, _ y: CGFloat)
}

コンパイラのバージョンによって得意不得意が変わる

以前Xcode7やXcode8.1のタイミングで調査した際に秒単位でかかっていたコードも、現状最新のXcode8.3.3において調査した結果、大幅に時間短縮されていた、という例もいくつかありました。

【Late】 Dictionaryのvalueに複雑な式 / メソッドの引数に複雑な式

Xcode8.1では3秒近くかかっていましたが、Xcode8.3.3では100ms以下に収まるようになっていました。

// Xcode8.1: 2817ms
// Xcode8.3.3: 88ms
self.log(data: [
    "userId": data.user?.id ?? "userId",
    "userName:": data.user?.name ?? "userName",
    "errorCodeString": "\(data.errorCode)",
    "description": data.description ?? "Description"
    ])

【Fast】 一度変数にする

速くなったとは言っても100ms近くはかかっているので以下のようにすることで高速化できます。
冗長にはなりますが、可読性の面でもこちらのほうが良い場合もあるかと思います。

// 3ms
let userId = data.user?.id ?? "userId"
let userName = data.user?.name ?? "userName"
let errorCodeString = "\(data.errorCode)"
let description = data.description ?? "Description"
let data = [
    "userId": userId,
    "userName:": userName,
    "errorCodeString": errorCodeString,
    "description": description
]
self.log(data)

また、大半が短くなる一方、逆に増えている書き方も存在しました。さらに、現在beta版であるXcode9においても計測したところ、こちらも異なる結果が得られました。
つまり、Xcodeのバージョンによってコンパイラ(LLVM)のバージョンも異なり、それぞれ得意不得意があるようです。
プロジェクトの状態にも依存し、いつの間にかBuild時間が変わっているという事もあるため、「最近遅いな」と感じたら計測し直してみると良いかもしれません。

AWAでの改善結果

AWAプロジェクトにおけるCompile時間が遅いMethodワースト50個を、改善前後でグラフにまとめた結果がこちらです。

File Compile Time Worst 50
File Compile Time Worst 50

もともと1秒以上かかるMethodがいくつもあった状態から、ほぼ全てのMethodが200ms以内に収まるようになりました。

Compile時間短縮とSwiftらしさはトレードオフ

型推論の時間を減らそうとすると、どうしてもSwiftらしさが失われたコードになりがちです。そのため、基本的には型推論のことはあまり意識せずに実装し、Compile時間が閾値を超えたものに関してだけ書き換えを行うという最低限の修正に留めておくという程度のバランスが良いかと思います。

Buildマシンの性能を上げる


最後に、プロジェクトを弄ること無くお金でシンプルに解決する手段としてBuildするPCのスペックを上げるという方法があります。

Xcode Hardware Performance というRepositoryにまとまっているBuild時間を参考にすると、スペックによってどれだけ差が出るのかが大まかに分かるかと思います。
ただし、Build時間が短くなるのはほぼ確実ですが、その幅はプロジェクトの状態や構成などにも依存します
ですので、もしチーム内などに最新スペックPCを持っていてBuildできる方が居れば、一度計測をお願いして確認しておくべきかと思います。

参考に、私のPCとチーム内の最新スペックPCとでClean Buildの時間を計測・比較した結果を載せておきます。

Clean Build Time for Specs
Clean Build Time for Specs

まとめ


「Build設定の最適化」「コードベースのCompile時間削減」「Buildマシンの性能を上げる」という3つのアプローチでBuild高速化方法について紹介させていただきました。何か参考になるものはありましたでしょうか。
特に前2つの内容についてはチーム全体の開発効率向上につながる内容かと思いますので、もし未対応の内容があればぜひ試していただければと思います。