Swift Digest
SE-0193 | Swift Evolution

Cross-module inlining and specialization

Proposal
SE-0193
Authors
Slava Pestov
Review Manager
Ted Kremenek
Status
Implemented (Swift 4.2)

01 何が問題だったのか

Swift 5 ではライブラリ進化(library evolution)と ABI 安定化が大きな目標となり、フレームワークがバイナリ互換性を保ったまま API を進化させられるようになりました。これを実現するために、Swift コンパイラはモジュール境界をまたぐときには保守的なコード生成を行います。

モジュール境界で起きるオーバーヘッド

同一モジュール内では、Swift コンパイラはジェネリック関数の特殊化(specialization)やインライン化、モジュール内全体に渡る最適化を積極的に行います。一方で、モジュール境界をまたぐ呼び出しでは、ジェネリクスはランタイムでの型メタデータの受け渡しや、間接的な値アクセスを経由する必要があり、どうしても実行時オーバーヘッドが発生します。

多くのアプリケーションにとって、このオーバーヘッドは実際の処理に比べれば無視できる程度です。しかし、標準ライブラリのように「アルゴリズム自体は非常に軽く、ほとんどの時間をジェネリック値の操作とユーザー提供のクロージャ呼び出しに費やす」ような API では、オーバーヘッドが本質的な処理量を上回ってしまいます。SequenceCollection のプロトコル拡張に定義された map のような高階関数がその典型です。もしこれらの関数本体を呼び出し側にインライン化・特殊化できれば、具体型を直接操作する手書きループと同等のコードを生成でき、抽象化のコストを完全に取り除けます。

ABI 安定化と最適化の両立

単純な解決策として「すべての宣言をインライン化可能にする」ことが考えられますが、これは ABI 安定化の目標と衝突します。標準ライブラリのように、ユーザーコードとは独立して配布・更新されるべきモジュールでは、すべての本体を呼び出し側に埋め込んでしまうと、後からライブラリを差し替えても古い実装がバイナリに焼き付いたままになってしまいます。

必要なのは、ライブラリ作者が特定の API に限って「本体をモジュールインタフェースの一部として公開してよい」と宣言できる仕組みと、その本体から呼び出されるヘルパー宣言を「ソース上は internal のまま、ABI 上は公開する」ために区別する仕組みの2つです。

02 どのように解決されるのか

ライブラリ作者が関数本体をモジュールインタフェースの一部として公開するための属性として @inlinable を、そしてその本体から参照される internal 宣言を ABI 上だけ公開するための属性として @usableFromInline を導入します。

@inlinable

@inlinable を付けた宣言は、本体がモジュールインタフェースに書き出され、他モジュールからの呼び出し側が最適化を行う際に参照できるようになります。コンパイラはその本体をインライン展開するかもしれませんし、特殊化したり、部分的に使ったり、あるいは完全に無視して通常通りエントリポイントを呼び出したりします。

次の例では、Sequence の要素がすべて等しいかを調べる allEqual@inlinable として公開しています。

@inlinable public func allEqual<T>(_ seq: T) -> Bool
    where T : Sequence, T.Element : Equatable {
  var iter = seq.makeIterator()
  guard let first = iter.next() else { return true }

  func rec(_ iter: inout T.Iterator) -> Bool {
    guard let next = iter.next() else { return true }
    return next == first && rec(&iter)
  }

  return rec(&iter)
}

このフレームワークに対してビルドされたクライアントバイナリは、最適化を有効にすればジェネリクスによる抽象化コストを取り除いた形で allEqual を呼び出せます。

@inlinable は次の種類の宣言に付けられます。

  • 関数・メソッド
  • subscript
  • computed property
  • イニシャライザ(self を代入するか self.init を呼ぶ委譲イニシャライザのみ。stored property を直接初期化する root イニシャライザには付けられません)
  • デイニシャライザ

可視性は publicinternal のいずれかである必要があります。関数の中に入れ子で書かれたローカル宣言には付けられませんが、public @inlinable な関数の中で定義されたローカル関数やクロージャ式は暗黙に @inlinable として扱われます。subscript や computed property に付けた場合、getter と setter の両方に適用されます。

インライン可能コンテキストの制約

@inlinable 宣言の本体はインライン可能コンテキスト(inlinable context)となり、次の制約を受けます。

  • ローカル型を定義できない。Swift のランタイムではすべての型が一意な identity を持ち、それは == 演算子でメタタイプを比較することで観測できます。もし複数のライブラリが同じローカル型をそれぞれインライン展開し、最終的に同一バイナリにリンクされると、型の identity がどうなるのかがはっきりしません。さらに、同じ関数の異なるバージョンが混在してしまうケースではより深刻な問題になります。
  • ABI 公開された宣言しか参照できない。本体がクライアントバイナリに埋め込まれうる以上、クライアントから参照可能なシンボルしか呼び出せないためです。

@usableFromInline と「ABI 公開」

この制約を満たしながら @inlinable な関数を書くには、ヘルパー宣言を ABI 上公開しつつ、ソース上は internal に留めたいケースがあります。そのために @usableFromInline を導入します。

ある宣言が ABI 公開(ABI-public) とは、次の両方を満たすことを指します。

  • トップレベル宣言であるか、ABI 公開された型の中にネストされている。
  • public であるか、もしくは internal かつ @usableFromInline または @inlinable が付いている。

例として、次のコードで C.D.fC.D.g はいずれも ABI 公開されています。public class C の中に @usableFromInline internal class D が入れ子になっており、その中でさらに @usableFromInline internal func f()@inlinable internal func g() が定義されているためです。

public class C {
  @usableFromInline internal class D {
    @usableFromInline internal func f() {}

    @inlinable internal func g() {}
  }
}

一方、次のように internal な型に public なメソッドが入っていても、外側の型が ABI 公開されていないため C.f は ABI 公開ではありません。

internal class C {
  public func f() {}
}

@usableFromInlineinternal 宣言にのみ付けられます。public 宣言はすでに ABI 公開なので重複し、privatefileprivate 宣言には意味を持ちません。また、この属性はソースレベルの可視性には影響せず、あくまで ABI にエントリポイントを公開することで @inlinable な関数から参照可能にするだけです。

プロトコル要件、enum ケース、クラスのデイニシャライザは、常に含まれる宣言と同じ effective visibility を持つため @usableFromInline を付ける対象にはなりません。subscript や computed property に付けた場合は getter / setter 両方に適用されます。なお、internal 宣言においては @inlinable@usableFromInline を含意するため、両方を付けるとコンパイラが警告を出します。

ライブラリ進化との付き合い方

@inlinable な関数の本体を後から変更することは、慎重に検討すべきです。クライアントバイナリが古い実装をインライン化したまま配布されていれば、フレームワーク側の新しい実装と古い実装が同一バイナリに混在する状況が起こりえます。たとえばハッシュ関数を @inlinable にしている場合、アルゴリズムを変更するとハッシュ値の不整合が発生するため、そのような変更は避けるべきです。

一般論として、@inlinable は「抽象化されたデータ型をプロトコル経由で操作するだけの、明らかに正しいアルゴリズム」に対して使うのが適しており、将来の変更は観測可能な挙動を変えない最適化に限定するのが望ましい、というガイドラインが推奨されます。

今後の見通し

将来的には、@inlinable(2.0)@available(inlinable, 2.0) のような構文で「どのバージョン以降にインライン化可能になったか」を指定できるようにすることが検討されています(この Proposal のスコープ外で、具体的な構文や実現時期を約束するものではありません)。これは、フレームワークの新しいリリースで初めて追加された ABI 公開関数を利用する本体を、旧バージョン向けにデプロイするクライアントが誤ってインライン展開してしまうのを防ぐために必要になります。同様の versioning は、後から exhaustive になる enum や fixed-contents になる struct を扱うためにも要求されます。