Swift におけるパフォーマンス比較を、nil 判定を例に解説します。なお、パフォーマンスを比較する上で、コンパイル時の最適化1の様子を観察することはとても重要です。この記事では、最適化の様子を中間生成物を通して観察する方法についても解説しています。
さて、結論から言うと、コンパイル時の最適化によって、nil 判定を != nil
でする方法と if let
でする方法は等価になります。したがって、最適化されていればパフォーマンスに差は出ません。
nil 判定とは
nil 判定の方法はいくつかあります。このうち、次の 2 つが代表的なものです:
let x: Int? = 0
// != nil 方式
if x != nil {
print("not nil")
}
let x: Int? = 0
// if let 方式
if let _ = x {
print("not nil")
}
これについて、!= nil
方式が if let
方式と比べて10倍ほど遅いという話を巷で見かけました。これがこの記事を書くきっかけになったのですが、付属していたコードがかなり危ういように感じたのです。
危ういパフォーマンス測定のコード
私が見かけた nil 判定のパフォーマンス測定のコードは、おおよそ次のようなものです:
// QUESTION: このコードで正しく計測できるのだろうか?
import Foundation
let optional: Int? = 0
let count = 10000000
// != nil 方式のパフォーマンス測定
let start1 = Date()
for _ in 0...count {
if optional != nil {}
}
print(Date().timeIntervalSince(start1))
// if let 方式のパフォーマンス測定
let start2 = Date()
for _ in 0...count {
if let x = optional {}
}
print(Date().timeIntervalSince(start2))
さて、どこが危ういのでしょうか。私が思いつく限りでは、次の点で危ういと感じます:
- コンパイル時最適化による無意味な処理の除去
ふつう、Swift のコンパイル時にはかなりの最適化がおこなわれます。例えば、先ほどのコードの if 文は条件部を含め何もしないコードなわけですから、コンパイラから不要なコードと見なされるかもしれません2。ここで不要と判定されたコードは、最適化時にコンパイラによって除去されます(コンパイル時の中間生成物を観察すると除去されることがわかります)。すると、最適化後のコードは次のようになっているかもしれないのです:
// NOTE: 最適化後にこうなる可能性がある
import Foundation
let optional: Int? = 0
let count = 10000000
// NOTE: 最適化によって何もしていない if 文が除去される。すると外側の
// for 文の中身もなくなるので、同様に何もしていないと判定される。
// 最終的に、最適化によって for 文は丸ごと除去される。
let start1 = Date()
print(Date().timeIntervalSince(start1))
// NOTE: 同様に最適化によってfor文ごと除去される。
let start2 = Date()
print(Date().timeIntervalSince(start2))
これでは一体何を計測しているのでしょうか。このように、最適化はパフォーマンス計測に大きく影響を及ぼします。今回は計測対象が消失することによって計測が無意味になりました。しかし、これ以外にも計測対象が不公平に最適化されることによって正しく比較できなくなることも考えられます。
つまり、パフォーマンスの比較においては、計測対象が消えず、かつ公平に最適化されることがとても重要なのです。したがって、パフォーマンスを比較/計測する際にはどのように最適化されるのかをきちんと見極めなければなりません。
また、Swift のコンパイル時以外にも様々な最適化がかかります。その代表例が CPU や OS レベルでの最適化3です。ただ、私はこれらのレベルでの最適化に詳しくないので説明はしません。もし、このレベルの最適化の雰囲気を知りたい場合は、性能測定道 実践編を読むといいでしょう。
さて、ここからは最適化の影響を考慮しながら、パフォーマンスを比較する方法を紹介していきます。
パフォーマンス比較方法の検討
今回、私がパフォーマンス比較をする際に検討したのは、Swift コンパイル時の中間生成物を比較することでした。ただし、最適化は CPU や OS などのレイヤーでも実施されるため、中間生成物の内容だけをみてパフォーマンスを正確に比較するのは困難です。しかしながら、最適化後の中間生成物が等しいことさえわかれば、最終的な生成物も等しくなり、この場合に限ってパフォーマンスに差がないことを確かめられる、と考えたのです。
では、Swift のコンパイルにおける中間生成物を見ていきましょう。次の図は、コンパイル時の処理の流れを示したものです(引用: Swift コンパイラのアーキテクチャ):
この図を見ると、次のような中間生成物が生成されていることがわかります:
- AST (parsed)
- コードの構造を表すデータ。抽象構文木(Abstract Syntax Tree)と呼ばれる。
- AST (type-checked)
- 型が補完/検査された抽象構文木。
- SIL (raw)
- 抽象構文木を、解析しやすい構造へと変換した直後のもの。Swift Intermidiate Language の略。
- SIL (canonical)
- 前段の SIL 生成物を最適化したもの。
- LLVM IR
- Swift が利用しているコンパイラ基盤 LLVM の受け取れるデータ。LLVM Intermidiate Representation の略。
さて、これらの中間生成物のうち、最適化がおこなわれたものは SIL (canonical) と LLVM IR です。このどちらで比較してもいいのですが、今回は、SIL (canonical) の内容を比較することにします。
最適化後の SIL による比較
では、まず次のように != nil
方式の比較コードを用意します:
public func unwrapByEq(_ optional: Int?) {
if optional != nil {
// IMPORTANT: 最適化で除去されないコードを入れる
print("not nil")
}
}
このコードから最適化された SIL (canonical) を得るには、次のコードを実行します:
$ xcrun swiftc -emit-sil -O Eq.swift -o Eq.sil
これによって、Eq.sil
に最適化された SIL (canonical) が出力されます。次のコードは、SIL (canonical) から unwrapByEq
に関連する部分を抜粋したものです:
// unwrapByEq(_:)
sil @_T02Eq08unwrapByA0ySiSgF : $@convention(thin) (Optional<Int>) -> () {
// %0 // users: %2, %1
bb0(%0 : $Optional<Int>):
debug_value %0 : $Optional<Int>, let, name "optional", argno 1 // id: %1
switch_enum %0 : $Optional<Int>, case #Optional.none!enumelt: bb1, case #Optional.some!enumelt.1: bb2 // id: %2
bb1: // Preds: bb0
br bb3 // id: %3
bb2: // Preds: bb0
// function_ref print(_:separator:terminator:)
// (print 関数の中身がインライン展開されているので省略)
br bb3 // id: %36
bb3: // Preds: bb1 bb2
%37 = tuple () // user: %38
return %37 : $() // id: %38
} // end sil function '_T02Eq08unwrapByA0ySiSgF'
まず、先頭行で unwrapByEq
に対応する SIL レベルでの関数の宣言がされています。続く行では、この SIL レベルの関数のエントリポイントとして bb0
という「基本ブロック」が宣言されています。この基本ブロックというのは複数の命令を束ねてラベルをつけたものです4。例えば、unwrapByEq
に対応する SIL レベルの関数は、bb0
bb1
bb2
bb3
の4つの基本ブロックを持っています。
なお、上の基本ブロックに含まれる命令で最も重要なのは switch_enum
命令と br
命令です:
-
switch_enum
命令 - enum の値をみて、対応する基本ブロックへとジャンプする
-
br
命令 - 指定された基本ブロックへとジャンプする
では、これを踏まえた上で unwrapByEq
に対応する SIL 関数の処理の流れを追ってみましょう:
-
bb0
から実行が始まる -
debug_value
命令が実行される -
switch_enum
命令が実行され、bb0
の引数である%0
が.none
であればbb1
へジャンプし、%0
が.some
であればbb2
へジャンプする-
%0
が.none
の場合(Swift でいうnil
の場合)-
bb1
の実行が始まる -
br
命令によってbb3
へとジャンプする
-
-
%0
が.some
の場合(Swift でいうnil
ではない場合)-
bb2
の実行が始まる - Swift 標準ライブラリの
print
関数が実行される -
br
命令によってbb3
へとジャンプする
-
-
-
bb3
の実行が始まる -
unwrapByEq
に対応する SIL 関数の戻り値であるVoid
が%37
に準備される -
%37
がこの関数の戻り値として返される
まとめると、!= nil
方式の関数の SIL (canonical) は、引数に与えられた enum が .none
であれば何もせず、.some
であれば print
関数が実行されるというように読めます。
次に、if let
方式の SIL (canonical) をみてみましょう。こちらの方式のコードは次の通りです:
public func unwrapByIfLet(_ optional: Int?) {
if let _ = optional {
print("not nil")
}
}
このコードの最適化後の SIL (canonical) を得るために、次のコマンドを実行します:
$ xcrun swiftc -emit-sil -O If.swift -o If.sil
これによって生成された SIL (canonical) のうち、unwrapByIfLet
に関連する部分を抜粋しました:
// unwrapByIfLet(_:)
sil @_T02If08unwrapByA3LetySiSgF : $@convention(thin) (Optional<Int>) -> () {
// %0 // users: %2, %1
bb0(%0 : $Optional<Int>):
debug_value %0 : $Optional<Int>, let, name "optional", argno 1 // id: %1
switch_enum %0 : $Optional<Int>, case #Optional.some!enumelt.1: bb2, case #Optional.none!enumelt: bb1 // id: %2
bb1: // Preds: bb0
br bb3 // id: %3
bb2(%4 : $Int): // Preds: bb0
// function_ref print(_:separator:terminator:)
// (print 関数の中身がインライン展開されているので省略)
br bb3 // id: %37
bb3: // Preds: bb2 bb1
%38 = tuple () // user: %39
return %38 : $() // id: %39
} // end sil function '_T02If08unwrapByA3LetySiSgF'
すると、先ほどの != nil
方式の SIL (canonical) と同じ処理なことに気がつきます。つまり、SIL の最適化によって != nil
方式と if let
方式の間には差がなくなることがわかりました。
また、今回は割愛していますが、nil 判定の方法の1つに switch
による nil 判定も存在します。これについても、最適化によって同じ SIL (canonical) へと変換されます。したがって、これらのどの方法を使っても、最適化後のパフォーマンスに差はありません。
結論
SIL の最適化によって != nil
方式と if let
方式は、どちらも同じ SIL (canonical) へと最適化されます。したがって、最適化がされるのであれば、これらの方法のパフォーマンスに差はありません。
また、このように最適化後の SIL (canonical) の内容を比較することで、パフォーマンスに差がないことを確かめられました。
補足: 最適化をしない場合の比較
これまでの説明では、最適化を有効にすることを前提としていました。しかし、仮に最適化をしなかったとしたらパフォーマンスに差は出るのかでしょうか。実際に確かめてみましょう。
最適化されていない SIL (canonical) を出力するには、これまでの SIL (canonical) 出力コマンドから -O
を抜きます。なお、Eq.swift
は先ほどのものと同じものです:
$ xcrun swiftc -emit-sil Eq.swift -o Eq.sil
このコマンドによって生成された SIL (canonical) のうち、unwrapByEq
に関連する部分を抜粋しました:
// unwrapByEq(_:)
sil @_T02Eq08unwrapByA0ySiSgF : $@convention(thin) (Optional<Int>) -> () {
// %0 // users: %4, %1
bb0(%0 : $Optional<Int>):
debug_value %0 : $Optional<Int>, let, name "optional", argno 1 // id: %1
// function_ref != infix<A>(_:_:)
%2 = function_ref @_T0s2neoiSbxSg_ABts9EquatableRzlF : $@convention(thin) <τ_0_0 where τ_0_0 : Equatable> (@in Optional<τ_0_0>, @in Optional<τ_0_0>) -> Bool // user: %11
%3 = alloc_stack $Optional<Int> // users: %4, %14, %11
store %0 to %3 : $*Optional<Int> // id: %4
%5 = alloc_stack $Optional<Int> // users: %6, %8, %13
inject_enum_addr %5 : $*Optional<Int>, #Optional.none!enumelt // id: %6
%7 = tuple ()
%8 = load %5 : $*Optional<Int> // user: %10
%9 = alloc_stack $Optional<Int> // users: %10, %12, %11
store %8 to %9 : $*Optional<Int> // id: %10
%11 = apply %2<Int>(%3, %9) : $@convention(thin) <τ_0_0 where τ_0_0 : Equatable> (@in Optional<τ_0_0>, @in Optional<τ_0_0>) -> Bool // user: %15
dealloc_stack %9 : $*Optional<Int> // id: %12
dealloc_stack %5 : $*Optional<Int> // id: %13
dealloc_stack %3 : $*Optional<Int> // id: %14
%15 = struct_extract %11 : $Bool, #Bool._value // user: %16
cond_br %15, bb1, bb2 // id: %16
bb1: // Preds: bb0
// function_ref print(_:separator:terminator:)
// (print 関数の中身がインライン展開されているので省略)
br bb2 // id: %39
bb2: // Preds: bb1 bb0
%40 = tuple () // user: %41
return %40 : $() // id: %41
} // end sil function '_T02Eq08unwrapByA0ySiSgF'
このうち、unwrapByEq
の基本ブロック bb0
の内容がかなり増えていることに気がつきます。ここで重要なのは、%11 = apply ...
の部分です。apply
命令は SIL レベル関数を呼び出すもので、ここで呼び出された関数は Equatable
の !=
です。
では、同じように最適化されていない if let
方式をみてみます:
$ xcrun swiftc -emit-sil If.swift -o If.sil
すると、if let
方式の方は最適化前後であまり内容が変わっていないことに気がつきます:
// unwrapByIfLet(_:)
sil @_T02If08unwrapByA3LetySiSgF : $@convention(thin) (Optional<Int>) -> () {
// %0 // users: %2, %1
bb0(%0 : $Optional<Int>):
debug_value %0 : $Optional<Int>, let, name "optional", argno 1 // id: %1
switch_enum %0 : $Optional<Int>, case #Optional.some!enumelt.1: bb2, case #Optional.none!enumelt: bb1 // id: %2
bb1: // Preds: bb0
br bb4 // id: %3
bb2(%4 : $Int): // Preds: bb0
br bb3 // id: %5
bb3: // Preds: bb2
// function_ref print(_:separator:terminator:)
// (print 関数の中身がインライン展開されているので省略)
br bb4 // id: %28
bb4: // Preds: bb3 bb1
%29 = tuple () // user: %30
return %29 : $() // id: %30
} // end sil function '_T02If08unwrapByA3LetySiSgF'
つまり、最適化前であれば != nil
方式の方が !=
関数の呼び出しのオーバーヘッドがある分、遅い可能性が高いでしょう。
参考文献
-
きつねさんでもわかるLLVM ~コンパイラを自作するためのガイドブック~
- 今回解説した SIL と LLVM IR はよく似ているので、読み解く上でとても参考になる書籍です
-
実行時間や使用メモリを小さくすることを目的としたコードの変換処理のこと(参考: コンパイラ最適化 - Wikipedia)。 ↩
-
実際に、最適化オプションをつけてコンパイルすると、この if 文は不要なコードとみなされて除去されます。 ↩
-
OS レベルではファイルの読み書きの最適化が有名です。 ↩
-
より正確には、束ねられた命令は途中に分岐を含まないという制約があります(参照: 基本ブロック - Wikipedia) ↩