Help us understand the problem. What is going on with this article?

SILから始めるSwiftのパフォーマンス比較

はじめに

本記事は、CyberAgent 20新卒 Advent Calendar 2019の8日目の投稿です。

昨日7日目の投稿は、Daichi Igarashiくんの「FramerMotionでクリスマスっぽいものを作る!」でした!
// TODO: 追記
明日9日目の投稿は、SaltTsukaくんの「」です!

本日8日目の記事は、現在(執筆日:2019/12/8)AbemaTVにて内定者バイトをしているiOSエンジニアのkitakitsuneが担当します。

本記事の内容は、バイト先でレビューをして頂いている最中に出た話を元にしています。
対象の言語はSwiftになります。
Swiftで変数の値を初期化する際に(今回のケースでは)ふたつの書き方が考えられ、どちらの方がパフォーマンス的にいいのか気になったので、実際にパフォーマンス比較をおこないました。比較にはSwiftの中間言語であるSILを用いています。
本記事ではSwiftのコンパイルの説明やSILの細かい文法の解説は省かせて頂きます(徐々にアップデート出来ればと思います🙇‍♂️)。

これから仮説や検証内容を書き連ねていくのですが、先に結論を。
変数値の初期化においてクロージャを使わない場合とクロージャを使う場合ではパフォーマンスの違いはありません(※クロージャ内で複雑な処理をした場合は未検証)。
よって、どちらのパターンで書いても問題はないかと思います。

対象のプログラム

変数typeの値に応じて、変数xの初期値が決定するプログラムを対象とします。

A. クロージャを使わないパターン

nonClosure.swift
enum BloodType {
    case a
    case b
    case o
    case ab
}

let type: BloodType = .o

let x: Int

switch type {
case .a:
    x = 1
case .b:
    x = 2
case .o:
    x = 3
case .ab:
    x = 4
}

B. クロージャーを使うパターン

closure.swift
let type: BloodType = .o

let x: Int = {
    switch type {
    case .a:
        return 1
    case .b:
        return 2
    case .o:
        return 3
    case .ab:
        return 4
  }
}()

どちらのパターンでも変数xに入る値は3になります。

仮説

クロージャーを用いた変数の初期化は関数呼び出しとなり余分なオーバーヘッドが生じるのではないか?

検証

では検証方法ですが、今回はそれぞれのプログラムをSwiftの中間言語であるSILに変換します。
コンパイルにおける中間生成物であるSILの違いは最終生成物の違いに紐づくと考えたため、SILの違いから仮説の裏づけをしようと思います。

まずはSwiftで書かれたプログラムをSILへ変換するため、以下のコマンドを使います。

swiftc -emit-sil a.swift -o a.sil

生成されたSILは以下のようになりました(簡易化のため重要な箇所を抜粋)。

A. クロージャを使わないパターン

nonClosure.sil
@_hasStorage let x: Int { get }

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s7switch11xSivp                  // id: %7
  %8 = global_addr @$s7switch11xSivp : $*Int      // users: %25, %21, %17, %13
  %9 = load %3 : $*BloodType                      // user: %10
  switch_enum %9 : $BloodType, case #BloodType.a!enumelt: bb1, case #BloodType.b!enumelt: bb2, case #BloodType.o!enumelt: bb3, case #BloodType.ab!enumelt: bb4 // id: %10

bb1:                                              // Preds: bb0
  %11 = integer_literal $Builtin.Int64, 1         // user: %12
  %12 = struct $Int (%11 : $Builtin.Int64)        // user: %13
  store %12 to %8 : $*Int                         // id: %13

bb2:                                              // Preds: bb0
  %15 = integer_literal $Builtin.Int64, 2         // user: %16
  %16 = struct $Int (%15 : $Builtin.Int64)        // user: %17
  store %16 to %8 : $*Int                         // id: %17

bb3:                                              // Preds: bb0
  %19 = integer_literal $Builtin.Int64, 3         // user: %20
  %20 = struct $Int (%19 : $Builtin.Int64)        // user: %21
  store %20 to %8 : $*Int                         // id: %21

bb4:                                              // Preds: bb0
  %23 = integer_literal $Builtin.Int64, 4         // user: %24
  %24 = struct $Int (%23 : $Builtin.Int64)        // user: %25
  store %24 to %8 : $*Int                         // id: %25
} // end sil function 'main'

switchでの分岐処理はすべてmain関数の中に書かれています。
switch_enum %9の行で判定をおこない、caseに応じて次にジャンプするブロックを変えています。
今回はcase #BloodType.o!enumelt: bb3にヒットするためbb3にジャンプし、%19 = integer_literal $Builtin.Int64, 3で生成した数値リテラルから%20 = struct $Int (%19 : $Builtin.Int64)でInt64構造体を生成し、store %20 to %8 : $*Intで最初に宣言した変数xに対して値の代入をおこなっていることが分かります。

B. クロージャーを使うパターン

closure.sil
@_hasStorage @_hasInitialValue let x: Int { get }

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s6switch1xSivp                   // id: %7
  %8 = global_addr @$s6switch1xSivp : $*Int       // user: %11
  // function_ref closure #1 in
  %9 = function_ref @$s6switchSiyXEfU_ : $@convention(thin) () -> Int // user: %10
  %10 = apply %9() : $@convention(thin) () -> Int // user: %11
  store %10 to %8 : $*Int                         // id: %11
} // end sil function 'main'

// closure #1 in
sil private @$s6switchSiyXEfU_ : $@convention(thin) () -> Int {
bb0:
  %0 = global_addr @$s6switch4typeAA9BloodTypeOvp : $*BloodType // user: %1
  %1 = load %0 : $*BloodType                      // user: %2
  switch_enum %1 : $BloodType, case #BloodType.a!enumelt: bb1, case #BloodType.b!enumelt: bb2, case #BloodType.o!enumelt: bb3, case #BloodType.ab!enumelt: bb4 // id: %2

bb1:                                              // Preds: bb0
  %3 = integer_literal $Builtin.Int64, 1          // user: %4
  %4 = struct $Int (%3 : $Builtin.Int64)          // user: %5
  br bb5(%4 : $Int)                               // id: %5

bb2:                                              // Preds: bb0
  %6 = integer_literal $Builtin.Int64, 2          // user: %7
  %7 = struct $Int (%6 : $Builtin.Int64)          // user: %8
  br bb5(%7 : $Int)                               // id: %8

bb3:                                              // Preds: bb0
  %9 = integer_literal $Builtin.Int64, 3          // user: %10
  %10 = struct $Int (%9 : $Builtin.Int64)         // user: %11
  br bb5(%10 : $Int)                              // id: %11

bb4:                                              // Preds: bb0
  %12 = integer_literal $Builtin.Int64, 4         // user: %13
  %13 = struct $Int (%12 : $Builtin.Int64)        // user: %14
  br bb5(%13 : $Int)                              // id: %14

// %15                                            // user: %16
bb5(%15 : $Int):                                  // Preds: bb4 bb3 bb2 bb1
  return %15 : $Int                               // id: %16
} // end sil function '$s6switchSiyXEfU_'

クロージャを使用した場合、使用しない場合とは明確は違いが見れました。
ひとつ目は、変数xに@_hasInitialValue属性が追加されています(軽く調べた感じ大きな処理の違いは無いように思います)。
ふたつ目は、クロージャの中に書いた処理がprivateな関数としてmain関数の外に切り出されていたこと。
最後に、切り出されたクロージャ内の処理はmain関数から%9 = function_ref @$s6switchSiyXEfU_ : $@convention(thin) () -> Int%10 = apply %9() : $@convention(thin) () -> Intのふたつの処理で呼び出されていたことです。

つまり、クロージャを使わないパターンに対しクロージャを使用したパターンでは関数呼び出しによるオーバーヘッドが発生します。
よって、仮説が正しいことが証明されました!

Swiftでは変数値の初期にクロージャを使用せず、余計なオーバーヘッドが生じないようにしましょう!

パフォーマンスの違いはないんじゃなかったの?

はい、ないです(厳密にはなくすことができます)。

実は、コンパイル時にはオプションを付与することで最適化をおこなうことができます。
上は最適化なしの場合の結果になります。実際にリリースするコードは最適化されたものになるので、改めて最適化した状態で検証を行いたいと思います。
最適化オプション-Oを付与して再度SILの生成をおこないます。

swiftc -emit-sil -O a.swift -o a.sil
nonClosure.sil
sil_stage canonical

@_hasStorage @_hasInitialValue let type: BloodType { get }

@_hasStorage let x: Int { get }

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s6switch1xSivp                   // id: %6
  %7 = global_addr @$s6switch1xSivp : $*Int       // user: %10
  %8 = integer_literal $Builtin.Int64, 3          // user: %9
  %9 = struct $Int (%8 : $Builtin.Int64)          // user: %10
  store %9 to %7 : $*Int                          // id: %10                      // id: %13
} // end sil function 'main'
closure.sil
sil_stage canonical

@_hasStorage @_hasInitialValue let type: BloodType { get }

@_hasStorage @_hasInitialValue let x: Int { get }

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s6switch1xSivp                   // id: %6
  %7 = global_addr @$s6switch1xSivp : $*Int       // user: %10
  %8 = integer_literal $Builtin.Int64, 3          // user: %9
  %9 = struct $Int (%8 : $Builtin.Int64)          // user: %10
  store %9 to %7 : $*Int                          // id: %10                      // id: %13
} // end sil function 'main'

ほとんど同じ結果になり、差分は以下のようになりました。

-@_hasStorage let x: Int { get }
+@_hasStorage @_hasInitialValue let x: Int { get }

まとめ

仮説

クロージャーを用いた変数の初期化は関数呼び出しとなり余分なオーバーヘッドが生じるのではないか?

結果

コンパイル時の最適化なし
クロージャ内の処理はSILのprivate関数と切り出され、初期化時は関数呼び出しによるオーバーヘッドが生じる

コンパイル時の最適化あり(-O)
クロージャの使用によるパフォーマンスの違いは生じない

補足

今回はクロージャ内の処理がシンプルだったため、最適化が効果的に働いていたのかもしれません🤔
より複雑な処理が必要になった際の検証は、また別の機会に試してみようと思います!

最後に

アドベントカレンダーへの参加は初めてでしたが、なんとか書き終えることができて良かったです。
内容に関するご意見やご指摘等あれば是非お願い致します(まだまだ分からないことだらけなので、教えて頂けると幸いです)。

最後になりますが、サイバーエージェントの本選考がはじまりました!

CAには優秀なエンジニアさんたちが多く在籍し、多くの刺激を得られるのが嬉しいところだと思ってます。
私は自分のロールモデルとなるエンジニアさんを見つけられたことがキッカケでした。
残念ながら私はiOSエンジニア、その方はサーバーサイドの方なので現状一緒に仕事は出来ていませんが、現在内定者バイトをしているAbemaTVにもスーパーアルティメット優秀なエンジニアさんたちがいらっしゃり毎日最高に楽しくやってます。
興味がある方は是非!

本選考の情報やエントリーはこちらから!
https://www.cyberagent.co.jp/careers/special/engineer2021/?utm_source=twitter&utm_medium=sns&utm_campaign=social

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした