はじめに
本記事は、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. クロージャを使わないパターン
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. クロージャーを使うパターン
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. クロージャを使わないパターン
@_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. クロージャーを使うパターン
@_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
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'
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