Swift Digest
SE-0496 | Swift Evolution

@inline(always) attribute

Proposal
SE-0496
Authors
Arnold Schwaighofer
Review Manager
Tony Allevato
Status
Implemented (Swift 6.3)

01 何が問題だったのか

Swift コンパイラは、関数呼び出しを呼び出し先の関数本体に置き換える インライン化(inlining) を最適化として行います。インライン化されると、呼び出し側と呼び出し先のコードがひとつながりに見えるようになり、両者をまたいだ最適化が効くため、結果として実行コードが大きく速くなることがあります。

たとえば次のように、ループの中から inout 引数を取る関数を呼ぶケースを考えます。

func callee(_ result: inout SomeValue, _ cond: Bool) {
  result = SomeValue()
  if cond {
    // たくさんのコード ...
  }
}

func caller() {
  var cond: Bool = false
  var x: SomeValue = SomeValue()
  for i in 0 ..< 1 {
    callee(&x, cond)
  }
}

calleecaller にインライン化されると、inout 引数の ABI に縛られず SomeValue() をレジスタに置けるようになり、x をループの外に巻き上げたり、cond が常に false だと分かって条件分岐ごと消したりといった、呼び出し境界をまたぐ最適化が一気に走ります。

ヒューリスティックによるインライン化の不確かさ

インライン化はコードサイズを増やしうる最適化なので、コンパイラはむやみに展開しないよう、関数の命令数や呼び出し頻度といった ヒューリスティック で展開可否を決めています。しかしこの判断は完璧ではなく、

  • 実際にはホットなのに、ループ構造が呼び出しチェーンの数段先にあるため頻繁に呼ばれていると検知できない、
  • 関数本体が大きすぎてヒューリスティック上「割に合わない」と判定されてしまう、

といった理由で、本当はインライン化したい場面で展開されないことが起こります。さらにヒューリスティックはコンパイラのバージョンによって直接・間接に変わりうるため、「同じコードが次のバージョンでは展開されなくなる」という不安定さもあります。

@inline(__always) の限界

これまでもアンダースコア付きの非公式属性 @inline(__always) で「常にインライン化してほしい」と伝える手段はありましたが、これは正式な言語機能ではなく、あくまで最適化ヒントの域を出ません。インライン化が満たせない条件を作者がうっかり満たしてしまっても(たとえば、public 関数なのに @inlinable を付け忘れて呼び出し側のモジュールに本体が見えていない、など)、エラーになることなく静かに展開が諦められます。

性能に効くインライン化を「公式に」「保証付きで」要求できる、明示的な最適化コントロールが必要でした。

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

関数に付けると その関数を必ずインライン化させる 新しい属性 @inline(always) を導入します。コンパイラのヒューリスティックに頼らず、作者が明示的にインライン化を指示できる正式な最適化コントロールです。

@inline(always)
func callee(_ result: inout SomeValue, _ cond: Bool) {
  result = SomeValue()
  if cond {
    // たくさんのコード ...
  }
}

@inline(__always) のような単なる「ヒント」ではなく、インライン化が成立しないと判断できる場合はコンパイル時に診断する のが基本方針です。ただし、呼び出し先が動的に決まるためコンパイラには静的に追えないケースについては、エラーにせず最善を尽くす形にしています。

直接参照される関数

呼び出し先が呼び出し位置から静的に分かる関数を「直接参照される関数」と呼びます。具体的には次のものです。

  • フリースタンディング関数
  • struct / enum / actor 型のメソッド
  • class 型の final メソッド、final class 型メソッド、static 型メソッド

これらに @inline(always) が付いていれば、原則として常にインライン化されます(後述の再帰の例外を除く)。

struct S {
  @inline(always)
  final func method() {}
}

class C {
  @inline(always)
  final func finalMethod() {}

  @inline(always)
  static func method() {}
}

@inline(always)
func freestanding() {}

func f(s: S, c: C) {
  s.method()        // 必ずインライン化される
  c.finalMethod()   // 必ずインライン化される
  C.method()        // 必ずインライン化される
  freestanding()    // 必ずインライン化される
}

final なクラスメソッドは宣言時エラー

final のメソッドや非 finalclass メソッドは動的ディスパッチされ、呼び出し位置からは実体が静的に分かりません。インライン化を確実に保証できないため、宣言時点でエラーになります。

class C {
  @inline(always) // error: 非 final メソッドに @inline(always) は付けられない
  func method() {}

  @inline(always) // error: 非 final な class メソッドにも付けられない
  class func classMethod() {}
}

class C2: C {
  @inline(always) // error: override も同様
  override func method() {}
}

@inline(always) を付けたいときは final を付けるか、struct / enum / actor 側にロジックを移すことになります。

再帰

@inline(always) の関数同士が再帰的に呼び合うと、インライン化を進めるほどコードが無限に膨らんでしまいます。直接参照だけで構成された再帰サイクルが見つかった場合は、エラーとして診断されます。

@inline(always)
func a() {
  if cond { b() } // error: a は @inline(always) で b と再帰的に呼び合っており、インライン化サイクルになる
}

@inline(always)
func b() {
  if cond2 { a() }
}

動的に決まる呼び出しは「できる範囲で」

呼び出し先が静的に決まらないケース、すなわち first class function value、protocol existential を介した呼び出し、protocol 制約付きジェネリクスを介した呼び出しなどでは、インライン化を保証することはそもそも不可能です。これらについては エラーは出さず、最適化の余地があれば展開する という穏やかな扱いになります。

@inline(always)
func callee() {}

func use(_ f: () -> ()) {
  f()
}

func site() {
  let f = callee
  f()         // 関数値経由。展開できれば展開、できなくても診断はされない
  use(callee) // 同上
}

-O などの最適化レベルでは、コンパイラが値の流れを追って実体を特定できれば、これらの呼び出しもインライン化されます。あくまで「できないときに黙る」だけで、できるときには @inline(always) の意図は尊重されます。

@inlinable との関係

@inline(always) で要求するインライン化は、定義モジュールの内側だけでなく 外部モジュールから呼ばれた場合にも成立してほしい ものです。そのためには呼び出し側に関数本体が見えている必要があり、つまり @inlinable 属性(SE-0193)の効果が要ります。

両方を毎回書くのは冗長なので、public / open / package な宣言に @inline(always) を付けると @inlinable暗黙に含意 されます(internal 以下の可視性では含意されません)。結果として、@inlinable のすべての制約(参照できるのは public または @usableFromInline の宣言のみ、など)がそのまま適用されます。

@inline(always)
public func caller() {
  g() // error: g は internal なので @inlinable な文脈から参照できない
}

internal func g() {}

@usableFromInline との関係

@usableFromInline は ABI エントリポイントを公開するだけで、本体を外部モジュールに見せる属性ではありません。したがって @inline(always) と組み合わせても、外部モジュールに本体が届かずインライン化を保証できないため、組み合わせはエラーになります。@inlinable を併用してください。

@inline(always) // error: @usableFromInline と組み合わせては保証できない
@usableFromInline
internal func callee() {}

Future Directions(参考)

提案では、今回のスコープから外した方向性として、モジュール内に閉じた利用に限ってインライン化を要求する @inline(module) 属性が speculative に挙げられています。@inlinable を介さず、モジュール内の @inline(always) 相当の効果だけを得たいケースを想定したものですが、あくまで今後の方向性を示すもので、実現を約束するものではありません。