Swift のパフォーマンス比較を正しくできますか？ パフォーマンスに差がないことをSIL から読み解く事例

Swift におけるパフォーマンス比較を、nil 判定を例に解説します。なお、パフォーマンスを比較する上で、コンパイル時の最適化[^1]の様子を観察することはとても重要です。この記事では、最適化の様子を中間生成物を通して観察する方法についても解説しています。

さて、結論から言うと、コンパイル時の最適化によって、nil 判定を `!= nil` でする方法と `if let` でする方法は等価になります。したがって、最適化されていればパフォーマンスに差は出ません。

[^1]: 実行時間や使用メモリを小さくすることを目的としたコードの変換処理のこと（参考: [コンパイラ最適化 - Wikipedia](https://ja.wikipedia.org/wiki/%E3%82%B3%E3%83%B3%E3%83%91%E3%82%A4%E3%83%A9%E6%9C%80%E9%81%A9%E5%8C%96)）。


# nil 判定とは

nil 判定の方法はいくつかあります。このうち、次の 2 つが代表的なものです:

```swift:a.swift
let x: Int? = 0

// != nil 方式
if x != nil {
    print("not nil")
}
```

```swift:b.swift
let x: Int? = 0

// if let 方式
if let _ = x {
    print("not nil")
}
```

これについて、`!= nil` 方式が `if let` 方式と比べて10倍ほど遅いという話を巷で見かけました。これがこの記事を書くきっかけになったのですが、付属していたコードがかなり危ういように感じたのです。



# 危ういパフォーマンス測定のコード

私が見かけた nil 判定のパフォーマンス測定のコードは、おおよそ次のようなものです:

```swift
// 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]。ここで不要と判定されたコードは、最適化時にコンパイラによって除去されます（コンパイル時の中間生成物を観察すると除去されることがわかります）。すると、最適化後のコードは次のようになっているかもしれないのです:

[^2]: 実際に、最適化オプションをつけてコンパイルすると、この if 文は不要なコードとみなされて除去されます。

```swift
// 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 レベルでの最適化[^4]です。ただ、私はこれらのレベルでの最適化に詳しくないので説明はしません。もし、このレベルの最適化の雰囲気を知りたい場合は、[性能測定道 実践編](https://www.slideshare.net/hayamiz/ss-31934105)を読むといいでしょう。

[^4]: OS レベルではファイルの読み書きの最適化が有名です。

さて、ここからは最適化の影響を考慮しながら、パフォーマンスを比較する方法を紹介していきます。



# パフォーマンス比較方法の検討

今回、私がパフォーマンス比較をする際に検討したのは、Swift コンパイル時の中間生成物を比較することでした。ただし、最適化は CPU や OS などのレイヤーでも実施されるため、中間生成物の内容だけをみてパフォーマンスを正確に比較するのは困難です。しかしながら、**最適化後の中間生成物が等しいことさえわかれば、最終的な生成物も等しくなり、この場合に限ってパフォーマンスに差がないことを確かめられる**、と考えたのです。

では、Swift のコンパイルにおける中間生成物を見ていきましょう。次の図は、コンパイル時の処理の流れを示したものです（引用: [Swift コンパイラのアーキテクチャ](https://qiita.com/rintaro/items/3ad640e3938207218c20)）:

<img width="688" alt="af33025e-7c5a-4435-a5cc-838ee8d93da2.png" src="https://qiita-image-store.s3.amazonaws.com/0/6281/dfd5bcdb-ffe6-c97c-8d1e-48d555ead27b.png">

この図を見ると、次のような中間生成物が生成されていることがわかります:

<dl>
<dt>AST (parsed)
<dd>コードの構造を表すデータ。抽象構文木（Abstract Syntax Tree）と呼ばれる。
<dt>AST (type-checked)
<dd>型が補完/検査された抽象構文木。
<dt>SIL (raw)
<dd>抽象構文木を、解析しやすい構造へと変換した直後のもの。Swift Intermidiate Language の略。
<dt>SIL (canonical)
<dd>前段の SIL 生成物を最適化したもの。
<dt>LLVM IR
<dd>Swift が利用しているコンパイラ基盤 LLVM の受け取れるデータ。LLVM Intermidiate Representation の略。
</dl>

さて、これらの中間生成物のうち、最適化がおこなわれたものは SIL (canonical) と LLVM IR です。このどちらで比較してもいいのですが、今回は、SIL (canonical) の内容を比較することにします。



# 最適化後の SIL による比較

では、まず次のように `!= nil` 方式の比較コードを用意します:

```swift:Eq.swift
public func unwrapByEq(_ optional: Int?) {
    if optional != nil {
        // IMPORTANT: 最適化で除去されないコードを入れる
        print("not nil")
    }
}
```

このコードから最適化された SIL (canonical) を得るには、次のコードを実行します:

```console
$ xcrun swiftc -emit-sil -O Eq.swift -o Eq.sil
```

これによって、`Eq.sil` に最適化された SIL (canonical) が出力されます。次のコードは、SIL (canonical) から `unwrapByEq` に関連する部分を抜粋したものです:

```plaintext:Eq.sil
// 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` という「基本ブロック」が宣言されています。この基本ブロックというのは複数の命令を束ねてラベルをつけたものです[^3]。例えば、`unwrapByEq` に対応する SIL レベルの関数は、`bb0` `bb1` `bb2` `bb3` の4つの基本ブロックを持っています。

[^3]: より正確には、束ねられた命令は途中に分岐を含まないという制約があります（参照: [基本ブロック - Wikipedia](https://ja.wikipedia.org/wiki/%E5%9F%BA%E6%9C%AC%E3%83%96%E3%83%AD%E3%83%83%E3%82%AF)）

なお、上の基本ブロックに含まれる命令で最も重要なのは [`switch_enum` 命令](https://github.com/apple/swift/blob/0ae61f65c90ccaf5419645edc773cc98e100af02/docs/SIL.rst#switch-enum)と [`br` 命令](https://github.com/apple/swift/blob/0ae61f65c90ccaf5419645edc773cc98e100af02/docs/SIL.rst#br)です:

<dl>
<dt><code>switch_enum</code> 命令</dt>
<dd>enum の値をみて、対応する基本ブロックへとジャンプする
<dt><code>br</code> 命令</dt>
<dd>指定された基本ブロックへとジャンプする
</dl>

では、これを踏まえた上で `unwrapByEq` に対応する SIL 関数の処理の流れを追ってみましょう:

1. `bb0` から実行が始まる
2. `debug_value` 命令が実行される
3. `switch_enum` 命令が実行され、`bb0` の引数である `%0` が `.none` であれば `bb1` へジャンプし、`%0` が `.some` であれば `bb2` へジャンプする
    - `%0` が `.none` の場合（Swift でいう `nil` の場合）
        1. `bb1` の実行が始まる
        2. `br` 命令によって `bb3` へとジャンプする
    - `%0` が `.some` の場合（Swift でいう `nil` ではない場合）
        1. `bb2` の実行が始まる
        2. Swift 標準ライブラリの `print` 関数が実行される
        3. `br` 命令によって `bb3` へとジャンプする
4. `bb3` の実行が始まる
5. `unwrapByEq` に対応する SIL 関数の戻り値である `Void` が `%37` に準備される
6. `%37` がこの関数の戻り値として返される

まとめると、`!= nil` 方式の関数の SIL (canonical) は、引数に与えられた enum が `.none` であれば何もせず、`.some` であれば `print` 関数が実行されるというように読めます。

次に、`if let` 方式の SIL (canonical) をみてみましょう。こちらの方式のコードは次の通りです:

```swift:If.swift
public func unwrapByIfLet(_ optional: Int?) {
    if let _ = optional {
        print("not nil")
    }
}
```

このコードの最適化後の SIL (canonical) を得るために、次のコマンドを実行します:

```console
$ xcrun swiftc -emit-sil -O If.swift -o If.sil
```

これによって生成された SIL (canonical) のうち、`unwrapByIfLet` に関連する部分を抜粋しました:

```plaintext:If.sil
// 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` は先ほどのものと同じものです:

```console
$ xcrun swiftc -emit-sil Eq.swift -o Eq.sil
```

このコマンドによって生成された SIL (canonical) のうち、`unwrapByEq` に関連する部分を抜粋しました:

```plaintext:Eq.sil
// 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` 命令](https://github.com/apple/swift/blob/0ae61f65c90ccaf5419645edc773cc98e100af02/docs/SIL.rst#apply)は SIL レベル関数を呼び出すもので、ここで呼び出された関数は `Equatable` の `!=` です。

では、同じように最適化されていない `if let` 方式をみてみます:

```console
$ xcrun swiftc -emit-sil If.swift -o If.sil
```

すると、`if let` 方式の方は最適化前後であまり内容が変わっていないことに気がつきます:

```plaintext:If.sil
// 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` 方式の方が `!=` 関数の呼び出しのオーバーヘッドがある分、遅い可能性が高いでしょう。


# 参考文献

- <a target="_blank" href="https://www.amazon.co.jp/gp/product/4844334158/ref=as_li_tl?ie=UTF8&camp=247&creative=1211&creativeASIN=4844334158&linkCode=as2&tag=kuniwak-22&linkId=d88848a0d4b292e352cdcc91b3572b2b">きつねさんでもわかるLLVM ~コンパイラを自作するためのガイドブック~</a><img src="//ir-jp.amazon-adsystem.com/e/ir?t=kuniwak-22&l=am2&o=9&a=4844334158" width="1" height="1" border="0" alt="" style="border:none !important; margin:0px !important;" />
    - 今回解説した SIL と LLVM IR はよく似ているので、読み解く上でとても参考になる書籍です