この記事の要点
- whole-module optimization は Swift コンパイラの最適化モードで、プロジェクトによっては実行性能が 2〜5 倍 にもなります。
-whole-module-optimization(-wmo)フラグで有効化でき、Xcode 8 では新規プロジェクトで既定で有効、Swift Package Manager の release ビルドでも有効です。 - 既定(
-wmoなし)の single-file compilation はファイルごとに別々にコンパイルするため、関数のインライン化やジェネリックの特殊化(generic specialization)といった関数をまたぐ最適化が、同じファイル内で定義・呼び出しされる関数に限られます。 -wmoではモジュール内の全ファイルをまとめて最適化するため、ファイルをまたいだインライン化・特殊化や、使われていない関数の削除(dead code elimination)が可能になります。- 「whole-module だとビルドが遅くなるのでは」という懸念に対し、コンパイラは最適化後に処理を分割してマルチスレッド・インクリメンタルにコンパイルを進めるため、モノリシックな whole-program 最適化より高速にビルドできます。
背景: single-file compilation の限界
モジュールは複数の Swift ファイルの集まりで、フレームワークまたは実行ファイルという 1 つの配布単位にコンパイルされます。-wmo を付けない single-file compilation では、コンパイラがモジュール内の ファイルごとに別々に 起動されます(実際の起動はコンパイラドライバや Xcode のビルドシステムが自動で行うため、手動でやる必要はありません)。
各ファイルは、パースや型チェックののち最適化・機械語生成され、オブジェクトファイルが書き出されます。最後にリンカがすべてのオブジェクトファイルを結合し、共有ライブラリや実行ファイルを生成します。
このとき最適化の範囲は 1 ファイル内 に閉じます。そのため関数のインライン化やジェネリックの特殊化のような関数をまたぐ最適化は、同じファイル内で定義され呼び出される関数にしか効きません。
たとえば、utils.swift にジェネリックなコンテナ型 Container<T> があり、その getElement メソッドを main.swift から呼ぶとします。
main.swift:
func add (c1: Container<Int>, c2: Container<Int>) -> Int {
return c1.getElement() + c2.getElement()
}
utils.swift:
struct Container<T> {
var element: T
func getElement() -> T {
return element
}
}
main.swift を最適化するとき、コンパイラは getElement の実装を知らず、存在することだけを知っています。そのため getElement の呼び出しを生成するだけです。一方 utils.swift を最適化するときは、どの具体型で呼ばれるかを知らないため、具体型に特殊化したものより遅い ジェネリック版 を生成するしかありません。
getElement の単純な return ですら、要素をどうコピーするかを判断するために型のメタデータを参照する必要があります。要素は単純な Int かもしれませんが、参照カウント操作を伴うような大きな型かもしれず、コンパイラには分からないからです。
whole-module optimization で何ができるのか
-wmo を付けると、コンパイラはモジュールの全ファイルを一体として最適化します。これには 2 つの大きな利点があります。
1. ファイルをまたいだインライン化・特殊化
コンパイラがモジュール内のすべての関数の実装を見られるため、インライン化や関数の特殊化(function specialization)が可能になります。特殊化とは、特定の呼び出しコンテキスト向けに最適化した関数の新しい版を作ることで、たとえばジェネリック関数を具体型向けに特殊化できます。
先ほどの例では、コンパイラは Container を具体型 Int 向けに特殊化した版を生成できます。
struct Container {
var element: Int
func getElement() -> Int {
return element
}
}
さらに、特殊化した getElement を add にインライン展開できます。
func add (c1: Container<Int>, c2: Container<Int>) -> Int {
return c1.element + c2.element
}
これはわずか数命令にコンパイルされ、ジェネリック版 getElement を 2 回呼んでいた single-file の場合と比べて大きな差になります。
仮にインライン化しないと判断した場合でも、実装が見えること自体に価値があります。たとえば参照カウント操作に関する関数の振る舞いを推論でき、関数呼び出し前後の冗長な参照カウント操作を取り除けます。
2. 非 public 関数の全用途を把握できる
2 つ目の利点は、コンパイラが非 public な関数のすべての使用箇所を把握できることです。非 public な関数はモジュール内でしか使えないため、コンパイラはそれらへの参照をすべて見ていると確信できます。
基本的な応用が dead code elimination です。どこからも呼ばれない「dead」な関数・メソッドを削除できます。「使われない関数をなぜ書くのか」と思うかもしれませんが、これが主な用途ではありません。ほかの最適化の副作用 として関数が dead になる場合が多いのです。
たとえば Container.getElement を呼ぶのが add だけだとします。getElement をインライン化すると元の関数はもう使われないため削除できます。インライン化しない場合でも、add は特殊化版だけを呼ぶため、元のジェネリック版 getElement を削除できます。
ビルド時間への影響
「whole-module にするとビルドが遅くなるのでは」と心配になるかもしれません。single-file compilation は、ファイルごとに別プロセスで並列にコンパイルでき、依存先も含めて前回から変更のないファイルは再コンパイル不要(インクリメンタルコンパイル)です。小さな変更ならこれが大きく効きます。
whole-module モードではコンパイラは内部的に複数のフェーズ(パーサ、型チェック、SIL 最適化、LLVM バックエンド)で動きます。パースと型チェックはたいてい非常に高速です。SIL(Swift Intermediate Language)最適化器はジェネリックの特殊化やインライン化など Swift 固有の重要な最適化を担い、コンパイル時間のおよそ 3 分の 1 を占めます。残る大半は低レベルな最適化とコード生成を行う LLVM バックエンドが消費します。
ポイントは、SIL 最適化器で whole-module 最適化を行ったあと、モジュールが再び複数の部分に分割される ことです。LLVM バックエンドはその分割部分を複数スレッドで処理し、前回ビルドから変更のない部分の再処理も避けます。つまり whole-module 最適化でも、コンパイル作業の大部分を並列・インクリメンタルに行えます。
まとめ
whole-module optimization は、モジュール内でコードをどうファイルに分けるかを気にせずに最大限の性能を引き出す手段です。クリティカルな箇所で前述のような最適化が効けば、性能は single-file compilation の最大 5 倍になり得ます。しかも、モノリシックな whole-program 最適化にありがちなビルド時間の悪化を避けつつ、この高い性能を得られます。