Metal Best Practicesは、iOS/MacOS/tvOSのAPIであるMetalを用いた設計のベストプラクティスガイドです。
本稿では、何回かに分けてこのガイドを読み解き、コード上での実験を交えて解説していきます。
読んでそのまま理解できそうなところは飛ばしますので、原文を読みながら原文のガイドとしてご利用下さい。
また、iOSの記事なので他のOS(MacOS, tvOS)についての記載は割愛します。
他の記事の一覧は、初回記事よりご覧下さい。
Buffer Bindings (バッファバインディング)
ベストプラクティス:適切な方法を使用してデータをシェーダー関数に渡します
適切な方法とは何かというと、シェーダー関数にデータを渡す方法は次のような基準で決めましょうということです。
データが少量(4KB未満) ・・・ データをsetVertexBytes()を使って直接参照渡しします
データが大きい(4KB以上) ・・・ データをMTLBufferに入れてsetVertexBuffer()を使って渡します。
また、MTLBufferを使って複数の描画を呼び出すときは、setVertexBufferOffset()を使って参照位置を少しずつずらしながらシェーダー関数に渡すことができます。つまり、毎回MTLBufferに詰め直さずとも、1回MTLBufferに詰め込んだら少しずつオフセットの位置をずらして参照先を変更することができます。
コードで検証してみる
この章はあんまり検証することがなさそうでしたが、一応コードを書いてみました。
こちらのリポジトリにサンプルコードがあります。
サンプルコードの中にトリプルバッファリングを実装したTripleBufferingというものがあります。これは1万パーティクルをスクロールするサンプルです。今回はこれを改変して検証します。
サンプルでは、パーティクルの座標をCPU側で計算してこれをシェーダに渡していますが、座標データの渡し方を次の3パターンで試してみて、違いを比較してみましょう。
- setVertexBytes()で1パーティクルごとに渡す
- setVertexBuffer() + setVertexBufferOffset()を使って1パーティクルごとに渡す
- setVertexBuffer()で一気に渡す
ソースコードは次のとおりです。
実行するときは他のパターンをコメントアウトしてから実行します。
// 処理時間の計測開始
os_signpost(.begin, log: metalLog, name: "metal render")
//sample 1:setVertexBytes()で渡す
for i in 0 ..< 10000 {
let p = particleBuffers[currentBufferIndex].contents()
renderEncoder.setVertexBytes(p + MemoryLayout<Particle>.stride*i, length: MemoryLayout<Particle>.stride, index: 0)
renderEncoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: 1)
}
// sample 2:setVertexBufferOffset()を使う
renderEncoder.setVertexBuffer(particleBuffers[currentBufferIndex], offset: 0, index: 0)
for i in 0 ..< 10000 {
renderEncoder.setVertexBufferOffset(i * MemoryLayout<Particle>.stride, index: 0)
renderEncoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: 1)
}
// sample 3:setVertexBuffer()でまとめて渡す
renderEncoder.setVertexBuffer(particleBuffers[currentBufferIndex], offset: 0, index: 0)
renderEncoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: 10000)
// 処理時間の計測終了
os_signpost(.end, log: metalLog, name: "metal render")
計測結果
このようになりました。
パターン | CPU側の処理時間 | Driver Processing - Command Buffer | Driver Processing - Render Command | Vertex | Fragment |
---|---|---|---|---|---|
1. setVertexBytes() | 56ms | 0.057ms | 0.068ms | 0.413ms | 1.440ms |
2. setVertexBuffer() + setVertexBufferOffset() | 13ms | 0.072ms | 0.076ms | 0.391ms | 1.430ms |
3. setVertexBuffer() | 0.081ms | 0.102ms | 0.109ms | 0.416ms | 0.659ms |
※1 Driver Processing - Command Bufferの値を平均して算出
パターン3のsetVertexBuffer()で一気に転送する方法が圧倒的に有利でした。CPU側の処理時間に大きく差ができています。というのも、他のパターンは、forループで1万回回しているためです。
ただ、GPUドライバの処理時間(Driver Processingの列)だけをみると、setVertexBytes()が一番早かったです。
結論
今回のケースでは1万パーティクルを転送する方法で検証しましたが、この場合は大量のデータにあたるので、ベストプラクティスにある通りパターン3のsetVertexBuffer()で一気に転送するのが良さそうです。
また、パターン1のsetVertexBytes()は今回のサンプルでは1万回ループを回してしまったので非効率になってしまいましたが、経過時間やタップ位置の座標を格納するような小さな構造体のデータを転送するのに向いているようです。
パターン2のsetVertexBuffer() + setVertexBufferOffset()の組み合わせは今回のケースには向かなかったようです。大量のデータをスイッチングコストを少なくしながら切り替えるのに向いていそうです。
最後に
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