Swift Digest
SE-0460 | Swift Evolution

Explicit Specialization

Proposal
SE-0460
Authors
Ben Cohen
Review Manager
Steve Canon
Status
Implemented (Swift 6.3)

01 何が問題だったのか

Swift コンパイラは、最適化ビルドにおいてジェネリック関数の「特殊化(specialization)」を行います。特殊化とは、ジェネリックなプレースホルダを具体的な型に置き換えた専用の実装を生成することで、たとえば次のような関数を Array<Int> に対して呼び出した場合、[Int] 専用の高速なコードが生成されます。

extension Sequence where Element: BinaryInteger {
  func sum() -> Double {
    reduce(0) { $0 + Double($1) }
  }
}

let arrayOfInt: [Int] = // ...
let result = arrayOfInt.sum()

この専用コードは、配列のバッファ上をポインタで直接舐めながら要素をレジスタに読み込み、整数から浮動小数点数への変換も専用命令 1 つで済ませられるため、ジェネリックな実装を実行する場合と比べて桁違いに高速になります。

一方で、コンパイラが特殊化を行うには、呼び出しサイトで具体型が見えていることと、特殊化対象の関数本体が見えていることの両方が必要です。次のように existential などで型消去されている場合、コンパイラには具体型が分からないため特殊化ができません。

protocol Summable: Sequence where Element: BinaryInteger {}
extension Array: Summable where Element: BinaryInteger {}

var summable: any Summable
// 実行時には [Int] が入ってきても、
// コンパイラからは any Summable としか見えません。
let result = summable.sum()

この場合、実際に格納されているのが [Int] であっても、makeIterator() / next() を介した反復や BinaryInteger プロトコル経由の変換といった、完全に特殊化されていない経路が走ってしまい、特殊化された版に比べて 2 桁ほど遅くなることがあります。

同様の状況は ABI 安定なバイナリフレームワークでも起きます。@inlinable を付けずに提供されたジェネリック関数は、呼び出し側に型情報があっても本体が見えないため特殊化できず、呼び出し側は常に未特殊化版を呼ばざるを得ません。かといって @inlinable で実装を公開してしまうと、ABI の表面積が大きくなり後から変更しにくくなる、バイナリだけの差し替えでバグ修正や脆弱性対策ができなくなるといった別の問題が生じます。

型消去を避けたり @inlinable を使ったりして回避できる場面もありますが、ヘテロジニアスな格納や ABI 安定性との両立が必要な場面では、どうしても「本体を公開せずに、特定の型に対する特殊化だけは効かせたい」というニーズが残っていました。

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

ジェネリック関数の作者側が、特定の型の組に対する特殊化を事前生成するよう指示できる @specialized 属性が導入されます。未特殊化版の関数本体の冒頭で型が照合され、指定した型と一致すれば、事前生成された特殊化版に再ディスパッチされます。

extension Sequence where Element: BinaryInteger {
  @specialized(where Self == [Int])
  func sum() -> Double {
    reduce(0) { $0 + Double($1) }
  }
}

このように書いておくと、呼び出しサイトで特殊化できなかった場合でも、実行時の型が [Int] であれば [Int] 専用の特殊化版が走るため、any Summable 経由で呼び出しても(型の照合と分岐のコストを除けば)特殊化版と同じ性能が得られます。

複数の特殊化と擬似コードでの挙動

同じ関数に対して複数の @specialized を並べると、それぞれに対応する特殊化版が生成され、未特殊化版の冒頭で順番に照合されます。

extension Sequence where Element: BinaryInteger {
  @specialized(where Self == [Int])
  @specialized(where Self == [UInt32])
  @specialized(where Self == [Int8])
  func sum() -> Double {
    reduce(0) { $0 + Double($1) }
  }
}

実際のディスパッチ機構は実装によりますが、概念的には次のようなコードが未特殊化版の先頭に挿入されるイメージです。

extension Sequence where Element: BinaryInteger {
  @specialized(where Self == [Int])
  @specialized(where Self == [Int8])
  func sum() -> Double {
    if Self.self == [Int].self {
      // [Int] 用の特殊化版を実行
    } else if Self.self == [Int8].self {
      // [Int8] 用の特殊化版を実行
    } else {
      reduce(0) { $0 + Double($1) }
    }
  }
}

照合は厳密な型一致で行われます。暗黙の変換は試みられず、たとえば Int? 用の特殊化が Int に対して実行されたり、スーパークラス用の特殊化がサブクラスで実行されたりはしません。

セマンティクスは変わらない

@specialized による特殊化は、呼び出しサイトでコンパイラが自動生成する特殊化と同じもので、プロトコルのウィットネステーブル経由の動的ディスパッチを静的ディスパッチに置き換え、結果として追加のインライン化や最適化を可能にするだけのものです。特殊化版の中でオーバーロード解決がやり直されることはなく、呼び出される関数は常にプロトコルのウィットネス経由で得られるものと同じです。つまり @specialized性能にのみ影響する最適化であり、振る舞いが変わる心配はありません。

適用できる対象

プロトコルエクステンションのほか、ジェネリック型のエクステンション、computed property、自由関数にも付けられます。computed property の場合、省略形ではなく明示的な get に付ける必要があります。

extension Array where Element: BinaryInteger {
  @specialized(where Element == Int)
  func sum() -> Double {
    reduce(0) { $0 + Double($1) }
  }

  var product: Double {
    @specialized(where Element == Int8)
    get { reduce(1) { $0 * Double($1) } }
  }
}

@specialized(where T == Int)
func sum<T: BinaryInteger>(_ numbers: T...) -> Double {
  numbers.reduce(0) { $0 + Double($1) }
}

全プレースホルダの指定が必須

where 句では、関数シグネチャに現れるジェネリックプレースホルダをすべて具体型に固定する必要があります。プロトコルエクステンションの場合は Self も含みます。本体で使われていないように見えるプレースホルダも例外ではありません。

extension Dictionary where Value: BinaryInteger {
  // error: Too few generic parameters are specified in 'specialize' attribute (got 1, but expected 2)
  // note: Missing equality constraint for 'Key' in 'specialize' attribute
  @specialized(where Value == Int)
  func sum() -> Double {
    values.reduce(0) { $0 + Double($1) }
  }
}

これは、たとえば Dictionary の stored property のレイアウト計算のように、本体のコードからは見えない形でプレースホルダが使われる可能性があるためです。複数のプレースホルダを固定したい場合はカンマで区切ります。

extension Dictionary where Value: BinaryInteger {
  @specialized(where Value == Int, Key == Int)
  func sum() -> Double {
    values.reduce(0) { $0 + Double($1) }
  }
}

ABI と back-deploy

@specialized が生成するのは、未特殊化版の内部から呼ばれる内部的な特殊化版だけです。特殊化版へのシンボルが公開されるわけではないため、ABI 安定なライブラリでも @specialized の付け外しは ABI に影響しません。ジェネリック関数は従来どおり @inlinable によって呼び出し側からも特殊化可能にできますし、ディスパッチ用のコードは .swiftinterface には漏れません。また、ランタイムへの新しい要求もないため、古い Swift ランタイムへ back-deploy できます。

今後の方向性

今回の提案は最小限のコアに絞られており、次のような拡張が今後検討される可能性があります(いずれも speculative で、導入が約束されているわけではありません)。

  • 部分特殊化: Dictionary.sumKey のように、本体で実質使われないプレースホルダの指定を省けるようにしたり、「キーのサイズだけが同じ型に対して 1 つの特殊化を共有する」といったレイアウトベースの特殊化を可能にする方向です。
  • 特殊化シンボルの公開: ABI 安定なフレームワークで特殊化版のエントリポイントを .swiftinterface に出し、呼び出し側から直接リンクできるようにする方向です。@inlinable ほど ABI 表面積を広げずに特殊化を提供でき、後からバイナリだけを差し替えて修正することもできます。
  • 特殊化の強制: 呼び出しサイトやプロトコル宣言で、特殊化を必須にする指定を入れられるようにする方向です。BinaryInteger のように、特殊化されていることを前提にしたプロトコルで有用とされています。
  • 型やエクステンション単位の @specialized: 特定の型に対して、型やエクステンションに含まれるジェネリック関数群をまとめて特殊化するための糖衣構文です。
  • ツーリング: プロファイリング結果から有益な特殊化を特定したり、関数定義とは別のファイル・バイナリから特殊化を追加できるような構文を整備する方向です。特殊化はバイナリサイズを増やす一方でホットでない箇所では効果が薄いため、適用箇所を絞り込む支援が重要になります。

なお、「型に応じて別の実装を呼び分ける」(たとえば [Int] のときだけ手書きの SIMD 実装を使う)といった仕組みは、@specialized の範囲外です。@specialized はあくまでコンパイラが生成する特殊化を事前に用意するだけのもので、セマンティクスは常に未特殊化版と同じになります。