LoginSignup
55
30

More than 5 years have passed since last update.

Writing High-Performance Swift Code を翻訳してみる

Last updated at Posted at 2017-12-07

はじめまして。じゃらんのiOSアプリを開発している@yasuda0321と申します。

突然ですがSwiftがOSSとして公開されて早くも二年以上が経過しましたが、Swiftのリポジトリを普段ご覧になることはありますか?
僕はSwiftらしいコード書くのに参考にするためにたまに眺めています。

この記事では、リポジトリ内にある「OptimizationTips.rst」というハイパフォーマンスにSwiftを書くためのTipsがまとめられた文書を翻訳してみようと思います。
「対象読者はコンパイラや標準ライブラリの作者」となっているように必ずしも全ての情報がiOSアプリ開発に応用できるものではありませんが、言語仕様をより深く知るという意味では目を通しておいて損はないと思います。
誤訳をしている箇所も結構あると思うので、見つけ次第コメントいただけると幸いです。

本家リンク

Writing High-Performance Swift Code

次のドキュメントは高性能なSwiftコードを記述するための様々なtipsをまとめたものです。このドキュメントの対象読者はコンパイラや標準ライブラリの開発者です。

このドキュメントのtipsの中にはSwiftコードの質を向上させ、エラーが無く読みやすいコードを書くことにつながるようなものもあります。明示的にfinalclassにしたり、classにのみ適用可能なprotocolにしたりするのは明らかな例です。しかし、このドキュメントで説明しているtipsの中には節操がなくひねくれていて、コンパイラや言語の特定の一時的な制限を解決するものもあります。このドキュメントの勧められている多くのものはプログラムの実行時間・バイナリサイズ・コードの可読性などがトレードオフになります。

Enabling Optimizations

まず最初に行うべきことは最適化を有効にすることです。Swiftは3つの最適化レベルを提供しています。

  • -Onone: これは通常の開発のためのものです。最低限の最適化を行い、全てのデバッグ情報を保持します。
  • -O: これはほとんどのプロダクションコードで使用されています。大幅に型やコード量の変更するような積極的な最適化を行います。デバッグ情報は出力されますが圧縮されたものになります。
  • -Ounchecked: これは安全性と引き換えにパフォーマンスが望まれるような特定のライブラリやアプリケーションのための特別な最適化レベルです。コンパイラは、暗黙的な型検査はもちろん全てのオーバーフロー検査を削除します。検出されないメモリの安全性の問題や整数オーバーフローを引き起こす可能性があるため一般的には使用されません。整数オーバーフローや型のキャストが安全に行われることが慎重に確認できた場合のみ使用してください。

Whole Module Optimizations

通常Swiftでは各ファイルを個別にコンパイルします。これによって、Xcodeは複数ファイルを並行かつ高速にコンパイルできます。しかし、ファイルを個別にコンパイルすることによって最適化が妨げられます。Swiftはプログラム全体を一つのファイルであるかのようにコンパイルし、単一のコンパイルであるかのように最適化することもできます。このモードはswiftcコマンドで-whole-module-optimizationフラグをつけることによって有効化されます。このモードでコンパイルした場合コンパイル時間が長くなる可能性が高くなりますが、実行時間はより高速になります。

Xcodeのbuild settingの'Whole Module Optimization'を利用して有効化することができます。

Dynamic Dispatchを減らす

Swiftでは通常、Objective-Cのように非常に動的な言語です。Objective-Cとは違い、Swiftではこの動的さを削除、または減らすことによって実行時のパフォーマンスを向上させるこができます。このセクションではそのような操作を実行させるのに使用できるいくつかの例を紹介します。

Dynamic Dispatch

クラスは通常、メソッドやプロパティにアクセスするのにdynamic dispatchを使用します。したがって下のコードではa.aPropertya.doSomething()a.doSomethingElse()はすべてdynamic dispatchを介して呼び出されます。

class A {
  var aProperty: [Int]
  func doSomething() { ... }
  dynamic doSomethingElse() { ... }
}

class B : A {
  override var aProperty {
    get { ... }
    set { ... }
  }

  override func doSomething() { ... }
}

func usingAnA(_ a: A) {
  a.doSomething()
  a.aProperty = ...
}

Swiftでは通常、dynamic dispatchはvtable1を通して間接的に呼び出されます。dynamicキーワードが宣言に結び付けられると、Swiftは代わりにObjective-Cのメッセージングを介して呼び出しを行います。どちらの場合も、関節呼び出し実行のオーバーヘッドに加えてコンパイラの多くの最適化2を妨げるため、直接関数を呼び出すのに比べて遅くなります。パフォーマンスが重要なコードでは、このような動的な振る舞いを制限したい場合があります。

アドバイス: overrideが必要ないことがわかっている場合はfinal修飾子を利用する

finalはクラス・メソッド・プロパティのoverrideを制限させる修飾子です。これはコンパイラが間接関数の呼び出しの代わりに直接関数の呼び出しを発行できることを意味します。例えば以下のC.array1D.array1は直接アクセスされます。3 対象的にD.array1はvtable経由で呼び出されます。

final class C {
  // クラス'C'はoverrideできない。
  var array1: [Int]
  func doSomething() { ... }
}

class D {
  final var array1: [Int] // 'array1'はcomputed propertyでoverrideされない
  var array2: [Int]      // 'array2'はcomputed propertyでoverrideされる
}

func usingC(_ c: C) {
   c.array1[i] = ... // C.array1はdynamic dispatchを利用せずに直接アクセスできる
   c.doSomething() = ... // C.doSomethingはvirtual dispatchを利用せずに直接呼び出される
}

func usingD(_ d: D) {
   d.array1[i] = ... // D.array1はdynamic dispatchを利用せずに直接アクセスできる
   d.array2[i] = ... // D.array2はdynamic dispatchを利用してアクセスされる。
}

アドバイス: ファイル外からアクセスする必要がないときはprivate, fileprivateを利用する

privateもしくはfileprivateを適用すると、宣言されているファイルの可視性を制限することができます。
これによってコンパイラはoverrideされる可能性のある宣言をすべて確認することができます。そのような宣言がない場合、コンパイラはfinal修飾子を自動的に推論し、それに応じてメソッドやフィールドの間接的な呼び出しを削除します。例えば、以下のe.doSomething()f.myPrivateVarは、EとFが同一ファイル内でoverrideされないと仮定して直接アクセスされます。

private class E {
  func doSomething() { ... }
}

class F {
  fileprivate var myPrivateVar : Int
}

func usingE(_ e: E) {
  e.doSomething() // このクラスが宣言されているファイル内にサブクラスは存在しません。
                  // コンパイラはdoSomething()への仮想呼び出しを取り除くことができ、
                  // 直接呼び出しを行うことができます。
}

func usingF(_ f: F) -> Int {
  return f.myPrivateVar
}

コンテナ型を効果的に使用する

汎用コンテナであるArrayとDictionaryはSwiftの標準ライブラリが提供する重要な機能です。このセクションではこれらの型を効果的に使用する方法について説明します。

アドバイス: Array内で値型を利用する

Swiftでは型を値型(struct, enum, tuple)と参照型(class)の2つに分けることができます。重要な違いは値型をNSArrayの内部に含めることができないことです。したがって値型を使用する場合、optimizerは配列がNSArrayに戻される可能性を扱うのに必要なオーバーヘッドを取り除くことができます。

加えて参照型とは対象的に、値型は参照型を再帰的に含む場合にのみ参照カウントが必要です。参照型を持たない値型を利用することで、retain・release時の配列内部のトラフィックを回避することができます。

// ここではクラスを使わないでください
struct PhonebookEntry {
  var name : String
  var number : [Int]
}

var a : [PhonebookEntry]

アドバイス: NSArray bridgingが不要な場合は、参照型のContiguousArrayを利用する。

参照型の配列が必要で、配列にNSArrayへのbridgingが必要ない場合はArrayの代わりにContiguousArrayを利用します。

class C { ... }
var a: ContiguousArray<C> = [C(...), C(...), ..., C(...)]

アドバイス: object-reassignmentの代わりにinplace mutationを利用する。

Swiftの標準ライブラリの全てのコンテナは明示的なコピーの代わりにCOW(copy-on-write)4を利用してコピーを行う値型です。
deep copyを行う代わりにコンテナを保持することによって多くの不必要なコピーをコンパイラに省略させることができます。コンテナの参照カウントが1より大きく、変更されたときにのみ行われます。例えば以下の例だと、dcに割り当てられたときにはコピーされませんがdに2が加えられて変更されると、dはコピーされて2が加えられます

var c: [Int] = [ ... ]
var d = c        // ここではコピーはされない。
d.append(2)      // ここでコピーされる。

ユーザーが注意しないと、COWは追加で予期しないコピーを行う場合があります。この例では関数内でobject-reassignmentを通して変更を加えようとしています。パラメータは呼び出しの前にretainされ、呼び出し先の最後でreleaseされます。つまり、次のような関数を書くと


func append_one(_ a: [Int]) -> [Int] {
  a.append(1)
  return a
}

var a = [1, 2, 3]
a = append_one(a)

1が加えられていないバージョンのaは、append_oneの後には使用されないにも関わらず、割り当てるためにコピーされることがあります。5これを回避するためにはinoutパラメータを利用します

func append_one_in_place(a: inout [Int]) {
  a.append(1)
}

var a = [1, 2, 3]
append_one_in_place(&a)

Unchecked operations

Swiftでは、通常の演算を行う際にオーバーフローを確認することによって、整数オーバーフローによるバグを排除します。これらの確認はメモリの安全性の問題が発生しないことが分かっている高性能なコードでは適切ではありません。

アドバイス: オーバーフローが発生しないことが証明できるときは、チェックを行わない整数演算を行う

性能が重要なコードでは、安全だとわかっている場合オーバーフローの確認を省略することができる。

a : [Int]
b : [Int]
c : [Int]

// 前提条件: for all a[i], b[i]: a[i] + b[i] はオーバーフローを引き起こさない
for i in 0 ... n {
  c[i] = a[i] &+ b[i]
}

Generics

Swiftはgenerics型を通して非常に強力な抽象化の仕組みを提供しています。Swiftのコンパイラは任意のTに対して実行できるMySwiftFunc<T>の具体的なコードのブロックを出力します。生成されたコードは関数ポインタのテーブルと追加パラメータとして含むboxをもちます。MySwiftFunc<Int>MySwiftFunc<String>の振る舞いの違いは、異なる関数ポインタテーブルを渡すことと、boxが提供する抽象化の規模で説明されます。
genericsの例:

class MySwiftFunc<T> { ... }

MySwiftFunc<Int> X    // Int型で動くコードが出力される。
MySwiftFunc<String> Y // String型で動くコードが出力される。

最適化が有効なとき、Swiftのコンパイラはそのようなコードの呼び出しを探し、呼び出し時に利用された具体的な型(non-generic型)を突き止めようとします。genericsを利用した関数定義がoptimizerに見えていて具体的な型がわかっている場合には、Swiftのコンパイラーは特定の型専用のgenericsを利用した関数を出力します。このプロセスはspecializationと呼ばれ、genericsを使用したときのオーバーヘッドを取り除くことができます。
追加のgenericsの例:

class MyStack<T> {
  func push(_ element: T) { ... }
  func pop() -> T { ... }
}

func myAlgorithm(_ a: [T], length: Int) { ... }

// コンパイラはMyStack<Int>をspecializeできる。
var stackOfInts: MyStack<Int>
// intのstackを利用する。
for i in ... {
  stack.push(...)
  stack.pop(...)
}

var arrayOfInts: [Int]
// コンパイラは[Int]型にspecializeされたmyAlgorithmを出力する。
myAlgorithm(arrayOfInts, arrayOfInts.length)

genericの宣言は使用されている箇所と同一モジュール内におくこと

optimizerはgenericsの定義が現在のモジュールから見える場合にのみspecializetionを行います。-whole-module-optimizationが使用されてない場合は、宣言がgenericsの呼び出しと同一ファイル内にあるときのみ実行されます。

Note: 標準ライブラリは特殊です。標準ライブラリの定義はすべてのモジュールから見え、specializationが可能です。

大きな値をSwiftで扱う際のコスト

Swiftでは、値はデータのユニークなコピーを保持します。値が独立した状態を持つことが保証されるように、値型を利用することにはいくつかの利点があります。値をコピーするとプログラムはコピーされた新しい値を作ります。(代入、初期化、引数に渡す際の作用) 大きな値の場合時間がかかり性能を劣化させます。

値のノードを利用するツリーを定義した以下の例を考えてみましょう。各ノードはprotocolを利用する他のノードを含みます。コンピュータ・グラフィックスはは値として表すことができる異なるエンティティと変形で構成されるため、この例はある程度現実的です。

protocol P {}
struct Node : P {
  var left, right : P?
}

struct Tree {
  var node : P?
  init() { ... }
}

値がコピーされるとき(引数に渡す場合や代入や初期化するとき)Tree全体のコピーが必要になります。このTreeの場合、多くのmalloc/freeの呼び出しと相当な参照カウントのオーバーヘッドを伴うため、高くつく操作になります。

しかし、value semanticsが残っている場合は値がメモリーにコピーされるかどうかを気にする必要はありません。

アドバイス: 大きな値に対しては copy-on-write semantics を利用する

大きな値をコピーする際のコストを排除するためにcopy-on-writeの振る舞いを採用します。copy-on-writeを実装する最も簡単な方法はArrayなどの既存のcopy-on-writeの構造を作成することです。SwiftのArrayは値ですが、copy-on-writeの特性をもっているため配列が引数として渡されるたびにその要素がコピーされることはありません。

Treeの例ではその要素を配列で包むことによってコピーする際のコストを削減しています。この単純な変更がTreeのパフォーマンスに大きな影響を与え、配列を引数として渡すコストはツリーのサイズに応じてO(n)からO(1)になります。

struct Tree : P {
  var node : [P?]
  init() {
    node = [thing]
  }
}

ArrayをCOW semanticsのために利用するのには2つの明らかな欠点があります。1つ目の問題は値のラッパーとしての文脈では意味をなさないappendcountなどのメソッドを公開している点です。これらのメソッドは参照ラッパーとして使用するには都合が悪いものにすることがあります。この問題を回避するためには未使用のAPIを隠すラッパーを作りoptimizerにこれらのオーバーヘッドを取り除かせることですが、このラッパーでは2つめの問題を解決することができません。2つ目の問題は、プログラムの安全性とObjective-Cでの利用を保証するためのコードを持っている点です。Swiftでは添字アクセスが配列の要素数におさまっているかと、値を格納するたびに配列を拡張する必要があるかを確認しています。これらの処理によって性能が低下する可能性があります。

Arrayを使用する代わりに、Arrayをcopy-on-writeのデータ構造専用の値のラッパーとして置き換える事もできます。以下にこのようなデータ構造を作る例を示します。

final class Ref<T> {
  var val : T
  init(_ v : T) {val = v}
}

struct Box<T> {
    var ref : Ref<T>
    init(_ x : T) { ref = Ref(x) }

    var value: T {
        get { return ref.val }
        set {
          if (!isUniquelyReferencedNonObjC(&ref)) {
            ref = Ref(newValue)
            return
          }
          ref.val = newValue
        }
    }
}

Unsafe code

Swiftのクラスでは常に参照カウントが行われます。Swiftのコンパイラは、オブジェクトがアクセウされるたびに参照カウントを増加するコードを挿入します。例えば、クラスを利用して実装されたLinked Listを走査するという問題を考えてみましょう。Listの走査は、あるノードから次のノードへと参照を移動することによって行われます:elem = elem.next。参照を移動するたびに、Swiftは次のオブジェクトの参照カウントを増加して前のオブジェクの参照カウントを減少させます。これらの参照カウントの操作のコストは高くつき、Swiftのクラスを使用するときは避けることができません。

final class Node {
 var next: Node?
 var data: Int
 ...
}

アドバイス: 参照カウントのオーバーヘッドをなくすためにunmanaged referenceを使いましょう

注意: Unmanaged<T>._withUnsafeGuaranteedRefは公開APIではなく今後廃止される予定です。将来変更できないコードでは使用しないでください。

パフォーマンスが重要なコードではunmanaged referenceを利用することができます。Unmanaged <T>を使えば特定の参照の自動参照カウントを無効にすることができます。

これを行う際には、Unmanagedを使用している間、Unmanagedのインスタンスが保持するインスタンスに対して、インスタンスを生存させるために他の参照が存在することを確認する必要があります。(詳細は Unmanaged.swiftを参照)

//``withExtendedLifetime(Head)``を呼び出すと、unmanaged referenceを利用するコードの領域までHeadの存在期間が広がることが保証されます。
// スコープが存在する間Headへの参照は存在し、`Head.next`、`Head.next.next`のチェーンをとうして参照が存在する``Node``のリストを変更しないためです。

withExtendedLifetime(Head) {

  // Unmanaged referenceを作成する
  var Ref : Unmanaged<Node> = Unmanaged.passUnretained(Head)

  // 呼び出し・変数へのアクセスの際にはunmanaged referenceを利用してください。
  // `_withUnsafeGuaranteedRef`を使用すると、コンパイラは呼び出し・アクセス時のretain/releaseを完全に排除します。

  while let Next = Ref._withUnsafeGuaranteedRef { $0.next } {
    ...
    Ref = Unmanaged.passUnretained(Next)
  }
}

プロトコル

アドバイス: クラスでしか利用されないprotocolはclass-protocolを使用する。

Swiftではプロトコルをクラスにのみ適用可能なように制限をすることができます。プロトコルをクラス専用にするメリットの1つに、クラスのみがプロトコルに適合されるということに基づいてコンパイラがプログラムを最適化させられることがあります。例えば、ARCのメモリ管理システムではそれがクラスを扱っていることがわかれば容易にretain(参照カウントの増加)することができます。クラスにのみ適用可能であるとわかっていない場合、コンパイラは「このプロトコルは構造体かもしれない」と仮定する必要があり、自明でない構造のものをretain/releaseする準備をする必要があります。この処理は高コストになることがあります。

プロトコルの適用をクラスに制限することに意味がある場合はそうすることによって、実行時により優れたパフォーマンスを得ることができます。

protocol Pingable : class { func ping() -> Int }

サポートされていないOptimization Attributes

いくつかの_が付与された関数は最適化のためのものです。開発者はこれらを試して、バグレポートやメタバグを含む次の不完全なドキュメントのレポートにフィードバックすることができます。これらの属性は、サポートされている言語機能ではありません。Swift Evolutionによってレビューされておらず、コンパイラがリリースされる間に変更される可能性があります。

あとがき

いかがでしたでしょうか。普段のアプリ開発で使えるようなことがらは多くありませんが、SwiftやSwiftのコンパイラの挙動の一端をイメージできたのではないでしょうか。 https://github.com/apple/swift/blob/master/docs 配下にはほかにも面白い資料が揃っているのでたまに眺めてみると面白いと思います!

↓あとがきの下に脚注が出てますが、こちらも翻訳になります。


  1. virtual method tableまたはvtableは、ある型のメソッドのアドレスを含み、インスタンスから参照される型固有のテーブルです。dynamic dispatchは最初にオブジェクトのテーブルを検索し、次にテーブル内のメソッドを検索することによって行われます。 

  2. これはコンパイラが呼び出されている関数を正確に把握していないためです。 

  3. すなわち、クラスのフィールドか、関数の直接呼び出しのこと。 

  4. もとのデータかオリジナルのデータに変更が加えられたときにコピーする最適化手法。それ以外の場合は参照が与えられる。 

  5. 場合によってはoptimizerはインライン化を介して実行し、ARCの最適化によって参照が削除され、コピーも行われません。 

55
30
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
55
30