WWDC Quick Look 💓 By SwiftGGTeam
Improve memory usage and performance with Swift

Improve memory usage and performance with Swift

元の動画を見る

ハイライト

Nate Cook 氏は、QOI 画像パーサーを題材に 5 段階の最適化を実演しました。Swift 6.2 の InlineArray と Span を活用し、unsafe コードを一切使わずに、初版から 700 倍高速なパースを実現しました。


主要内容

Nate Cook 氏は、WWDC25 のステージで自作の QOI 画像ビューアを開きました。数 KB の小さなアイコンは一瞬で表示されますが、少し大きな鳥の写真に切り替えると、数秒かかってしまいます。これがパフォーマンス問題の登場です。テストデータでは問題なく動作しても、実際のデータに出会うと動作が重くなるのです。

彼は大規模なアーキテクチャの変更を行うのではなく、Instruments を起動して、ツールで問題を特定し、コードを 1 行ずつ修正していきました。本セッションは、同じ QOI パーサーを対象に 5 回のイテレーションを行いました。アルゴリズムの修正、メモリ割り当ての削除、排他性チェックの排除、ヒープメモリからスタックへの移行、Span を使った参照カウントの削除です。各ステップで、Instruments で症状を確認してから(platform_memmove がフレームグラフを埋め尽くす、近百万回の一時的な割り当て、swift_beginAccess の頻出、swift_retain/swift_release がそれぞれ 7% を占める)、Swift 6.2 で導入された InlineArray、RawSpan、OutputSpan を使って症状を解消しました。最終的に、初版の二次オーダー複雑度は線形に変わり、さらにメモリ管理の最適化を重ねることで、全体として 700 倍高速になりました。


詳細

第 1 回:アルゴリズムの誤り(07:01)。 フレームグラフでは platform_memmove がほとんどのサンプリングを占めていました。原因は readByte()Data.dropFirst() を使った後、Data.init で再びラップしていたことです。1 バイト読むたびに、残りの data 全体をコピーしていました。これを popFirst() に変更します。

import Foundation

extension Data {
  /// Consume a single byte from the start of this data.
  mutating func readByte() -> UInt8? {
    guard !isEmpty else { return nil }
    return self.popFirst()
  }
}

ポイント:

  • popFirst()Collection のメソッドで、先頭から要素を 1 つ取り出し、開始位置を 1 バイト進めます。O(1) です。
  • Data は両端から縮小できるように設計されているため、ここでは全体のコピーが発生しません。
  • この 1 行の変更により、パース時間は二次オーダー複雑度から線形に戻りました。

第 2 回:中間割り当ての排除(12:53)。 Allocations ツールでは、1 枚の画像で近百万回の短命な割り当てが発生していました。原因はこのチェーン呼び出しです:readEncodedPixels(...).flatMap { decodePixels(...) }.prefix(...).flatMap { $0.data(channels:) }。各ピクセルごとに 3 または 4 要素の小さな配列を new し、flatten し、再び Data にコピーしていました。修正方針は、最終サイズを先に計算して Data を 1 回だけ割り当て、ピクセルごとに書き込んでいく方式です。

extension QOIParser {
  /// Parses an image from the given QOI data.
  func parseQOI(from input: inout Data) -> QOI? {
    guard let header = QOI.Header(parsing: &input) else { return nil }
    
    let totalBytes = header.pixelCount * Int(header.channels.rawValue)
    var pixelData = Data(repeating: 0, count: totalBytes)
    var offset = 0
    
    while offset < totalBytes {
      guard let nextPixel = parsePixel(from: &input) else { break }
      
      switch nextPixel {
      case .run(let count):
        for _ in 0..<count {
          state.previousPixel
            .write(to: &pixelData, at: &offset, channels: header.channels)
        }
      default:
        decodeSinglePixel(from: nextPixel)
          .write(to: &pixelData, at: &offset, channels: header.channels)
      }
    }
    
    return QOI(header: header, data: pixelData)
  }
}

ポイント:

  • totalBytes = pixelCount * channels.rawValue:header から直接最終サイズを計算し、実行時にサイズを増やすことはありません。
  • Data(repeating: 0, count: totalBytes):1 回だけ割り当てを行い、以降は realloc しません。
  • offset 変数で書き込み位置を手動で追跡し、毎回 write(to:at:channels:) でバッファに直接書き込みます。
  • 修正後、総割り当て数は近百万回から個位数に減り、実行時間はさらに半分以下になりました。

第 3 回:exclusivity チェックの排除(16:53 から)。 改善があまりに顕著だったため、Nate 氏はテストループを 50 回に増やしてサンプリングを増やしました。swift_beginAccess でフィルタリングすると、previousPixelpixelCache の 2 つのプロパティが実行時の排他性チェックを引き起こしていることが分かりました。原因は、これらのプロパティが class State の中に置かれていたことです。class のプロパティは実行時に排他性を強制する必要があります。これらをパーサーの struct 上に直接移動し、メソッドを mutating にマークすると、swift_beginAccess はフレームグラフから完全に消えました。

第 4 回:InlineArray による固定サイズ配列の置き換え(19:47)。 pixelCache は 64 要素で、長さが変わらない配列です。Swift 6.2 の InlineArray は、value generics を使ってサイズを型に組み込みます。

var array: InlineArray<3, Int> = [1, 2, 3]
array[0] = 4
// array == [4, 2, 3]

// Can't append or remove elements
array.append(4)
// error: Value of type 'InlineArray<3, Int>' has no member 'append'

// Can only assign to a same-sized inline array
let bigger: InlineArray<6, Int> = array
// error: Cannot assign value of type 'InlineArray<3, Int>' to type 'InlineArray<6, Int>'

ポイント:

  • 型シグネチャ InlineArray<3, Int>3 は value generic パラメータで、コンパイル時にサイズが判明します。
  • append/remove はサポートされず、異なるサイズの InlineArray への代入はコンパイルエラーになります。
  • 要素は直接インラインで格納されます(スタック上または struct 内部)。独立したヒープ割り当て、参照カウント、copy-on-write はありません。毎回の代入は実際のコピーです。
  • 「固定長、原地変更、共有しない」シーンに適しており、相互共有や copy-on-write が必要なシーンには適しません。

第 5 回:RawSpan + OutputSpan による参照カウントの排除(26:27)。 プロファイルでは swift_retain が 7%、swift_release が 7%、唯一性チェックが 3% を占めていました。これらはすべて、タイトなループ内での Data の参照カウントに由来していました。Swift 6.2 は Span ファミリー(SpanRawSpanMutableSpanOutputSpanUTF8Span)を導入しました。核心の特性は non-escapable です。コンパイラは span のライフタイムを元の collection にバインドし、スコープから脱出できないため、実行時に retain/release が不要になります。

@available(macOS 16.0, *)
extension RawSpan {
  mutating func readByte() -> UInt8? {
    guard !isEmpty else { return nil }
    
    let value = unsafeLoadUnaligned(as: UInt8.self)
    self = self._extracting(droppingFirst: 1)
    return value
  }
}

ポイント:

  • RawSpan は non-escapable で、型自体が外側のスコープへの返却を拒否します。
  • unsafeLoadUnaligned(as:) の名前に unsafe が含まれるのは、特定の型を読み込む場合に安全でない可能性があるためです。UInt8 のような組み込み整数を読み込む場合は常に安全です。
  • _extracting(droppingFirst: 1) は新しい RawSpan を返します。データをコピーせず、開始位置だけを移動します。
  • 呼び出し側は data.bytesRawSpan を取得し、パースメソッドに渡します。retain/release はゼロです。

最終版では Data(rawCapacity:)OutputSpan を組み合わせて書き込みを行います。

/// Parses an image from the given QOI data.
mutating func parseQOI(from input: inout RawSpan) -> QOI? {
  guard let header = QOI.Header(parsing: &input) else { return nil }
  
  let totalBytes = header.pixelCount * Int(header.channels.rawValue)
  
  let pixelData = Data(rawCapacity: totalBytes) { outputSpan in
    while outputSpan.count < totalBytes {
      guard let nextPixel = parsePixel(from: &input) else { break }
      
      switch nextPixel {
      case .run(let count):
        for _ in 0..<count {
          previousPixel
            .write(to: &outputSpan, channels: header.channels)
        }
        
      default:
        decodeSinglePixel(from: nextPixel)
          .write(to: &outputSpan, channels: header.channels)
        
      }
    }
  }
  
  return QOI(header: header, data: pixelData)
}

ポイント:

  • Data(rawCapacity:) は Swift 6.2 の新しいイニシャライザで、未初期化メモリを段階的に埋めるための OutputRawSpan クロージャを提供します。
  • outputSpan.count は、前の版で手動で管理していた offset 変数に代わり、OutputSpan 自身が書き込み進捗を記録します。
  • previousPixel.write(to: &outputSpan, channels:) は output span に直接 append し、Data の中間層を経由しません。
  • [InlineArray](https://github.com/apple/swift-binary-parsing) の pixel cache と組み合わせることで、パース全体で swift_retain/swift_release が完全に消えます。

28:51 の箇所で最終的な比較が示されます。メモリ管理の最適化だけでパースは 6 倍高速になりました。さらに前の段階のアルゴリズム修正を重ねると、初版から 700 倍高速になり、unsafe コードは一切ありません。Nate 氏は Swift Binary Parsing もオープンソース化しました。中の ParserSpan は、この仕組みをベースにしています。


重要ポイント

  • やること:パフォーマンスボトルネックの探索パスを「Time Profiler でホットスポットを探す → Allocations で割り当て元を確認 → Reveal in Xcode でコードにジャンプ」に標準化する。

    • なぜ価値があるか:セッションではこのフローが 5 回実行され、毎回 30 秒以内に具体的な 1 行のコードを特定できました。これを身体に覚え込ませておけば、実際のデータで動作が重くなった際に推測に頼る必要がなくなります。
    • どう始めるか:テストを右クリックして Profile を選択し、Instruments で Time Profiler と Allocations の 2 つのツールを追加します。Invert Call Tree をオンにし、自分のコードの中で最初に現れるシンボルを確認します。
  • やること:自分のコード内の固定長コンテナを監査し、InlineArray に置き換えられるものは置き換える。

    • なぜ価値があるか:固定サイズの lookup table、キャッシュ、ring buffer に普通の Array を使うと、参照カウント、唯一性チェック、ヒープ割り当ての 3 重のオーバーヘッドが生じます。InlineArray<N, T> に変更すると、コンパイル時に固定され、実行時のオーバーヘッドはゼロになります。
    • どう始めるか:自分のプロジェクトで Array(repeating:count:)[T](repeating:count:) といったイニシャライザを grep し、固定長かつ共有が不要かを 1 つずつ判断し、条件に合うものを InlineArray に置き換えます。
  • やること:バイナリフォーマットをパースする際、中間層として Data の代わりに RawSpan/OutputSpan を使う。

    • なぜ価値があるか:タイトなループ内での Data の retain/release が、本セッションで CPU の 14% を占めていました。Span ファミリーは non-escapable でライフタイムを元の collection にバインドするため、retain/release が不要になり、コンパイラが危険な返却を拒否してくれます。
    • どう始めるか:パーサーのエントリポイントでは引き続き Data を受け取りますが、関数内ですぐに data.bytesRawSpan を取得し、下位レイヤーに渡します。書き出しは Data(rawCapacity:) が提供する OutputSpan を使います。
  • やること:メソッド間で共有する「状態変数」を class からそれを含む struct に移動する。

    • なぜ価値があるか:class のプロパティアクセスには実行時の exclusivity チェック(swift_beginAccess/swift_endAccess)が必要で、ループ内ではそれなりのコストがかかります。struct のプロパティアクセスは、ほとんどがコンパイル時に強制できます。
    • どう始めるか:parser や encoder などのオブジェクトの中で、プロパティをグループ化するために存在する内包 class を見つけ、プロパティを外側の struct に移動し、それらを読み書きするメソッドを mutating にマークします。
  • やること:パフォーマンスプロファイル時に対象コードを 50 回以上ループする。

    • なぜ価値があるか:最適化が進むと 1 回の実行が短くなりすぎて、Instruments のサンプリングが不足し、フレームグラフが歪みます。Nate 氏も第 3 回でこの問題に遭遇し、50 回のループを追加して初めて swift_beginAccess の発生源が見えました。
    • どう始めるか:XCTest 内にパフォーマンス専用のケースを書き、外側を for _ in 0..<50 { ... } で包みます。プロファイル対象はこのケースで、本番のエントリポイントではありません。

関連セッション

コメント

GitHub Issues · utterances