Swift Digest
SE-0322 | Swift Evolution

Temporary uninitialized buffers

Proposal
SE-0322
Authors
Jonathan Grynspan
Review Manager
Joe Groff
Status
Implemented (Swift 5.6)

01 何が問題だったのか

C や C++ のライブラリでは、値型の配列を扱う際に、呼び出し側で一時的なバッファを確保し、それを C 関数に渡して初期化してもらう、というパターンがよく使われます。さらに、確保量が小さければスタックに置き、大きくなればヒープに切り替える、という工夫も一般的です。次のような C コードがその典型です。

size_t tacoCount = ...;
taco_fillings_t fillings = ...;

taco_t *tacos = NULL;
taco_t stackBuffer[SOME_LIMIT];
if (tacoCount < SOME_LIMIT) {
  tacos = stackBuffer;
} else {
  tacos = calloc(tacoCount, sizeof(taco_t));
}

taco_init(tacos, tacoCount, &fillings);
// ... 利用 ...
taco_destroy(tacos, tacoCount);
if (tacos != stackBuffer) {
  free(tacos);
}

ところが Swift では、このような「一時的かつ未初期化のバッファ」を素直に書く方法がありませんでした。Swift は値の利用前に必ず初期化されていることを要求する一方、C 関数側が初期化を担うケースでは、Swift で一度ゼロ初期化してから C に渡して再度初期化されることになり、無駄な初期化コストが発生します。

UnsafeMutableBufferPointer<T>.allocate(capacity:) を使えば未初期化の領域は得られますが、このメソッドは既定でヒープに確保します。オプティマイザがエスケープ解析によってスタックに昇格(stack promotion)することはあるものの、エスケープ解析は非自明なプログラムでは一般に決定不能で、安定した最適化には頼れません。

結果として、C ライブラリの Swift オーバーレイを書くと、同等の C コードより性能が落ちやすく、C の低レベル API を Swift から効率よく利用することが難しいという状況が続いていました。

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

標準ライブラリに withUnsafeTemporaryAllocation という自由関数を追加します。この関数は、指定された型と要素数に応じた未初期化バッファをクロージャに渡し、クロージャが戻った時点で自動的に解放します。バッファは可能であればスタックに確保され、大きすぎる場合などはヒープにフォールバックします。

クロージャに渡されたバッファ(およびその要素へのポインタ)は、クロージャの外へエスケープしてはいけません。クロージャの終了時に必ず解放されるため、以降は利用できないからです。コンパイラは「このポインタがエスケープしない」ことを前提に積極的に最適化できるため、性能面の恩恵が得られます。

API の全体像

3 つのオーバーロードが提供されます。いずれも、バッファは未初期化の状態で渡され、初期化と解体の責任は呼び出し側にあります(解放だけは自動)。

型付きのバッファを得るオーバーロードが中心です。

public func withUnsafeTemporaryAllocation<T, R>(
  of type: T.Type,
  capacity: Int,
  _ body: (UnsafeMutableBufferPointer<T>) throws -> R
) rethrows -> R

バイト数とアラインメントを指定して生のバッファを得るオーバーロードもあります。

public func withUnsafeTemporaryAllocation<R>(
  byteCount: Int,
  alignment: Int,
  _ body: (UnsafeMutableRawBufferPointer) throws -> R
) rethrows -> R

単一値へのポインタを得るオーバーロードも用意されます。

public func withUnsafeTemporaryAllocation<T, R>(
  of type: T.Type,
  _ body: (UnsafeMutablePointer<T>) throws -> R
) rethrows -> R

使い方の例

C の taco_init / taco_destroy / tacos_consume を Swift から呼ぶ場合、次のように書けます。defer で解体を済ませた上でバッファを利用し、クロージャの外へ buffer が漏れないようにするのがポイントです。

extension taco_t {
  public static func consume(count: Int, filledWith fillings: taco_fillings_t) throws {
    try withUnsafeTemporaryAllocation(of: taco_t.self, capacity: count) { buffer in
      withUnsafePointer(to: fillings) { fillings in
        taco_init(buffer.baseAddress!, buffer.count, fillings)
      }
      defer {
        taco_destroy(buffer.baseAddress!, buffer.count)
      }

      let eatenCount = tacos_consume(buffer.baseAddress!, buffer.count)
      guard eatenCount >= 0 else {
        let errorCode = POSIXErrorCode(rawValue: errno) ?? .ENOTHUNGRY
        throw POSIXError(errorCode)
      }
    }
  }
}

単一値を一時的に確保したい場合は、of: だけを取るオーバーロードが使えます。戻り値を取り出して利用することもできます(オプティマイザが move() を除去できれば、コピーは発生しません)。

let value = withUnsafeTemporaryAllocation(of: T.self) { ptr in
  // ptr を初期化
  // ...
  return ptr.move()
}

スタック確保は「保証」ではない

withUnsafeTemporaryAllocation はスタック確保を保証しません。これは意図的な設計で、常にスタック確保する API は C99 の可変長配列(VLA)に近くなり、巨大サイズを指定されたときにスタックを破壊しかねないためです。次のように、ユーザ入力に応じたサイズでも安全に書けるようにするには、必要に応じてヒープに逃がせる仕組みが必要です。

print("How many tacos do you want?")
guard let n = readLine().flatMap(Int.init(_:)) else {
  printUsageAndExit()
}

withUnsafeTemporaryAllocation(of: taco_t.self, capacity: n) { buffer in
  // n が極端に大きくてもスタックを破壊しない
}

小さい確保はスタックに置かれ、一定サイズを超えると、標準ライブラリ内部の関数がスタック残量を問い合わせるなどしてスタック可否を判断し、難しければヒープに切り替えます。この判断ロジックは標準ライブラリの実装詳細で、将来的な改善が可能になっています。

コンパイル時最適化との関係

これらの関数は常に呼び出し側フレームへインライン展開される属性が付与されます。バッファの確保・解放は新しいビルトイン(概念的には C の alloca / スタック解放に相当)として実装され、サイズとアラインメントがコンパイル時に決まるケースでは、LLVM の alloca ひとつ、ひいてはスタックポインタ調整 1 命令に落ちることもあります。インライン展開されるため、古い Swift ランタイムへのバックデプロイも可能です。

既定より大きなスタック確保上限を使いたい場合は、コンパイラフロントエンドに -stack-alloc-limit n を渡すことで調整できます。呼び出し側の API として個別に指定する手段はあえて用意されていません(利用側がコンパイラ・標準ライブラリ以上に適切な判断を下せる状況は稀であるため)。