LoginSignup
8
3

More than 1 year has passed since last update.

【iOS】Metal Best Practicesの解説(4) トリプルバッファリング

Posted at

Metal Best Practicesは、iOS/MacOS/tvOSのAPIであるMetalを用いた設計のベストプラクティスガイドです。

本稿では、何回かに分けてこのガイドを読み解き、コード上での実験を交えて解説していきます。
読んでそのまま理解できそうなところは飛ばしますので、原文を読みながら原文のガイドとしてご利用下さい。
また、iOSの記事なので他のOS(MacOS, tvOS)についての記載は割愛します。

他の記事の一覧は、初回記事よりご覧下さい。

Triple Buffering (トリプルバッファリング)

ベストプラクティス: トリプルバッファリングモデルを使用してダイナミックバッファデータを更新する。

トリプルバッファリングとは、3つのバッファを用意し、CPUが書き込んだデータをGPUが読み込んでいる間に、並行してCPUが次のフレームのデータを書き込むことで、CPUとGPUの並列性を高め、アイドルタイムを減らす手法です。

CPUとGPUの協調作業は次のような順番でおこなわれます。

  1. CPUは動的データバッファに書き込み、コマンドをコマンドバッファにエンコードする。
  2. CPUは、完了ハンドラー(addCompletedHandler:)をスケジュールし、コマンドバッファー(commit)をコミットして、コマンドバッファーをGPUに転送する
  3. GPUはコマンドバッファを実行し、動的データバッファから読み取る
  4. GPUは実行を完了し、コマンドバッファー完了ハンドラー(MTLCommandBufferHandler)を呼び出す

このCPUとGPUの相互作用を3つのバッファを使ってなるべく並行に動かすのがトリプルバッファです。

  1. CPUはbuffer 0にデータを書き込み、コミットする
  2. GPUはbuffer 0を読み込む、その間にCPUはbuufer 1に書き込む
  3. GPUはbuffer 0をframe 1に表示し処理を完了させる、その間にCPUはbuffer 1をコミットさせ、buffer 2への書き込みを開始する
  4. GPUはbuffer 1を読み込む、その間にCPUはBuffer 2をコミットさせ、buffer 0の書き込みを開始する (以降繰り返す)

【トリプルバッファリングのタイムライン】

コードで検証してみる

トリプルバッファリングを実装してみます。
こちらのリポジトリにサンプルコードがあります。

サンプルコードの中にトリプルバッファリングを実装したTripleBufferingというのがあるので、こちらを説明します。

(実行イメージ)

パーティクルを上から下にスクロールさせる処理です。
Swift側(CPU側)で、パーティクルを乱数発生させ、フレームごとに下に移動させていきます。

パーティクルの座標は構造体で管理します。

CommonShadersType.h
struct Particle {
    vector_float2 position;
};

Particle構造体のデータをパーティクル数分の配列として、MTLBufferに保存します。
トリプルバッファリングなので、このMTLBufferは3つ用意します。

TripleBufferingMetalView.swift
// 宣言しているところ
static let numberOfParticles = 10000 // パーティクルの数
static let maxBuffers = 3 // バッファを3つ確保する
var particleBuffers:[MTLBuffer] = []

// 領域を確保しているところ
func allocBuffer() -> [MTLBuffer] {
    var buffers:[MTLBuffer] = []
    for _ in 0..<Coordinator.maxBuffers {
        let length = MemoryLayout<Particle>.stride * Coordinator.numberOfParticles
        guard let buffer = metalDevice.makeBuffer(length: length, options: .storageModeShared) else {
            fatalError("Cannot make particle buffer.")
        }
        buffers.append(buffer)
    }
    return buffers
}

確保した3つのバッファを切り替えながら、パーティクルの新しい位置を書き込んでいきます。
前フレームのバッファから座標を取り出して、スクロール分だけ増分して現在のフレーム用のバッファに書き込みます。
MTLBufferの示すメモリへのアクセスは、content()を呼ぶことでUnsafeRawPointer型のデータが得ることでできるようになります。これに対して、load()で読み込んで、store()で書き込んでいきます。

TripleBufferingMetalView.swift
func calcParticlePostion() {
    let p = particleBuffers[currentBufferIndex].contents() // 現在のフレーム用のバッファ
    let b = particleBuffers[beforeBufferIndex].contents() // 前フレーム用のバッファ
    let stride = MemoryLayout<Particle>.stride
    for i in 0..<Coordinator.numberOfParticles {
        var particle = b.load(fromByteOffset: i*stride, as: Particle.self)
        if particle.position.y > -1 {
            particle.position.y -= 0.01
        } else {
            particle.position.y += 2 - 0.01
        }
        p.storeBytes(of: particle,toByteOffset: i*stride,  as: Particle.self)
    }
}

バッファの切替はセマフォで制御します。
セマフォはバッファ数である3で初期化します。このように2以上の値を設定するセマフォをカウンティングセマフォといいます。3はアクセス可能な資源の数、つまり3つのバッファにアクセスできることを示しています。

セマフォは、wait(-1)するとアクセス可能数が減り、signal(+1)するとアクセス可能数が増えます。waitの時にセマフォの値が0になるとアクセスできる資源はなくなり、処理はブロックされます。

TripleBufferingMetalView.swift
// セマフォの宣言
let semaphore = DispatchSemaphore(value: Coordinator.maxBuffers)

// フレームごとの処理
func draw(in view: MTKView) {
    // 略

    // セマフォで資源獲得待ち
    semaphore.wait()

    // パーティクルバッファを更新する
    currentBufferIndex = (currentBufferIndex + 1) % Coordinator.maxBuffers
    calcParticlePostion()

    // 略

    // GPUへのレンダリング指示
renderEncoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: Coordinator.numberOfParticles)

    renderEncoder.endEncoding()

    commandBuffer.present(drawable)

    // レンダリングが終わったらセマフォで資源を開放
    commandBuffer.addCompletedHandler {[weak self] _ in
        self?.semaphore.signal()
    }
    commandBuffer.commit()
}

処理の状況を確認する

Metal System Traceを使って処理の状況を確認してみましょう。
このツールの使い方はこちらをご覧下さい。

トリプルバッファリングの場合

トリプルバッファリングの場合、GPUの処理が終わるのを待たずにCPUが次のフレームの処理を開始しているのがわかります。

image.png

シングルバッファリングの場合も確認してみます。
この場合は、GPUの処理が終わるのをまってCPUが処理を開始していることがわかります。
その間の待ち時間は1.5ミリ秒程度でした。

image.png

結論

トリプルバッファリングを使用した場合、GPUの処理待ちをせずにCPUの処理が行なわれるため、CPUとGPUがより並列に動作することになり、1.5ミリ秒程度の処理時間を稼ぐことができることがわかりました。

60FPSを実現するためには、1フレームを16.65ミリ秒以内に処理を終える必要がありますが、このうちの1.5ミリ秒が稼げるのであれば、実装の選択肢としては魅力的なのではないでしょうか。

なお、今回の実装はパーティクルの座標をCPUの処理で更新していましたが、Compute Shaderを使ってMetalで処理させればさらに高速化が可能です。時間があったら実装するかもしれません。

最後に

iOSを使った3D処理やAR、ML、音声処理などの作品やサンプル、技術情報を発信しています。
作品ができたらTwitterで発信していきますのでフォローをお願いします🙏

Twitterは作品や記事のリンクを貼っています。
https://twitter.com/jugemjugemjugem

Qiitaは、iOS開発、とくにARや機械学習、グラフィックス処理、音声処理について発信しています。
https://qiita.com/TokyoYoshida

Noteでは、連載記事を書いています。
https://note.com/tokyoyoshida

Zennは機械学習が多めです。
https://zenn.dev/tokyoyoshida

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3