Swift のパフォーマンス比較を正しくできますか? SIL で知るパフォーマンス比較

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

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

nil 判定とは

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

a.swift
let x: Int? = 0

// != nil 方式
if x != nil {
    print("not nil")
}
b.swift
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 レベルでの最適化です。ただ、私はこれらのレベルでの最適化に詳しくないので説明はしません。もし、このレベルの最適化の雰囲気を知りたい場合は、性能測定道 実践編を読むといいでしょう。

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

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

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

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

af33025e-7c5a-4435-a5cc-838ee8d93da2.png

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

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 方式の比較コードを用意します:

Eq.swift
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 に関連する部分を抜粋したものです:

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つの基本ブロックを持っています。

なお、上の基本ブロックに含まれる命令で最も重要なのは switch_enum 命令br 命令です:

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

では、これを踏まえた上で 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) をみてみましょう。こちらの方式のコードは次の通りです:

If.swift
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 に関連する部分を抜粋しました:

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 は先ほどのものと同じものです:

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

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

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 命令は SIL レベル関数を呼び出すもので、ここで呼び出された関数は Equatable!= です。

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

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

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

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

参考文献


  1. 実行時間や使用メモリを小さくすることを目的としたコードの変換処理のこと(参考: コンパイラ最適化 - Wikipedia)。 

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

  3. より正確には、束ねられた命令は途中に分岐を含まないという制約があります(参照: 基本ブロック - Wikipedia) 

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.