【iOS】Metal Best Practicesの解説（6） ドローアブル

[Metal Best Practices](https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/index.html#//apple_ref/doc/uid/TP40016642-CH27-SW1)は、iOS／MacOS／tvOSのAPIであるMetalを用いた設計のベストプラクティスガイドです。

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

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

https://qiita.com/TokyoYoshida/items/521619d6d8dd8d8ef9e4

### [Drawables (ドローアブル)](https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/Drawables.html#//apple_ref/doc/uid/TP40016642-CH2-SW1)

ベストプラクティス： ドローアブルをできるだけ短く保持します。

ドローアブルとは、レンダリングや書き込みして、実際に表示をすることのできるリソースです。アプリはこのドローアブルをできるだけ短く保持しましょうということです。

その理由は次のようになります。

ドローアブルは、コマンドバッファに登録されたコマンドの処理が完了するのを待ってから表示します。

一方で、ドローアブルは再利用可能なリソースプール内に存在していて、CPU側のスレッドから利用できるドローアブルは限られます。ドロアーブルが利用できないとき、CPU側のスレッドはブロックされます。

つまり、ドローアブルの利用はCPUスレッドにとっては待ち状態（ストール）が発生しやすくなるということです。このため、ドロアーブルの保持はなるべく短くして、他のCPUスレッドの処理を邪魔しないようにします。

ドローアブルをできるだけ短く保持するにはつぎの方法を使います。

1. できるだけ遅くドローアブルを取得する。できれば画面のレンダーパスをエンコードする直前。オフスクリーンレンダリングはドローアブルを取得する前に実行することができるので、ドローアブルの取得はその後で良い。
2. できるだけ早くドローアブルをリリースする。できれば、フレームのCPU作業を完了した直後。複数のドロアーブルで発生する可能性のあるデッドロックを回避するために、autorelease pool内にレンダリングプールを含める。

MTKViewはドローアブルのライフサイクルを自動的に処理してくれるので、なるべくこれを使うのが良さそうです。　MTKViewはcurrentDrawableプロパティを提供し、現在のフレームの終わりにcurrentDrawableを自動的に更新してくれます。

### コードで検証してみる

ドローアブルを長く保持すると、実際にストールが発生しやすくなるのかを検証してみます。
MTKViewはいい感じに処理してしまうので、あえて問題を出しやすいようにCAMetalLayerを使います。

こちらにCAMetalLayerに描画するために今回ベースにしたサンプルコードを保存してあります。

https://github.com/TokyoYoshida/MetalExamples/tree/main/MetalExamples/Samples/DrawToCAMetalLayer

このコードを改変して、autoreleasepoolがある場合、ない場合に実行時間に差が出るかを検証してみます。
差を浮き彫りにするために、autoreleasepoolの後に無駄な処理を入れています。

時間の計測はos_signpostを使いたかったのですがなぜかうまく動作しなかったため、print文を使用します。

レンダリングループのコードは次のとおりです。テクスチャーを表示しているだけの処理ですが、ところどころに無駄な処理を入れてドローアブルを長く保持するようにしています。

```swift:CAMetalLayerView.swift
@objc func draw() {
    // ミリ秒を表示する関数
    func nowTime() -> String {
        let format = DateFormatter()
        format.dateFormat = "yyyy/MM/dd HH:mm:ss.SSSS"
        return format.string(from: Date())
    }
    // ログ出力を見やすくするためにスレッドに番号を付ける
    threadNo += 1
    let localNo = threadNo
    guard let texture = texture else {return}
    // ここからレンダリング処理
    autoreleasepool {
        print("before = \(localNo), \(nowTime())")
        guard let drawable = metalLayer.nextDrawable() else {
            print("fail   = \(localNo), \(nowTime())")
            return
        }
        print("after  = \(localNo), \(nowTime())")
        let commandBuffer = metalCommandQueue.makeCommandBuffer()

        renderPassDescriptor.colorAttachments[0].texture = drawable.texture

        let w = min(texture.width, drawable.texture.width)
        let h = min(texture.height, drawable.texture.height)
        
        let blitEncoder = commandBuffer!.makeBlitCommandEncoder()!
        
        // レンダリングは無駄に重くする。テクスチャーを1万回コピーしている
        for _ in 0..<10000 {
            blitEncoder.copy(from: texture,
                              sourceSlice: 0,
                              sourceLevel: 0,
                              sourceOrigin: MTLOrigin(x:0, y:0 ,z:0),
                              sourceSize: MTLSizeMake(w, h, texture.depth),
                              to: drawable.texture,
                              destinationSlice: 0,
                              destinationLevel: 0,
                              destinationOrigin: MTLOrigin(x:0, y:0 ,z:0))
        }
        
        blitEncoder.endEncoding()
        commandBuffer!.present(drawable)
        commandBuffer!.commit()
        commandBuffer?.waitUntilCompleted()
    }
    // レンダリング後の処理は無駄に重くする。
    var x = 0
    for _ in 0...100000 {
        x += 1
    }
}
```

上のレンダリング処理を500回スレッドで呼び出す処理を作ります。

```swift:CAMetalLayerView.swift
for _ in 0..<500 {
    DispatchQueue.global(qos:.userInteractive).async {
        self.draw()
    }
}
```

**実行してみる**

上のコードを使ってautoreleasepoolがある場合、ない場合を作り、それぞれ実行して処理時間を計測します。
実行するとXcodeに処理時間が表示されるので、これを集計します。

なお、CAMetalLayer.nextDrawable()はドローアブルが取得できないとブロックしますが、1秒経過するとタイム・アウトして次のようなエラーが出ていました。

```swift
2021-08-26 18:02:37.958534+0900 MetalExamples[44711:5922300] [CAMetalLayer nextDrawable] returning nil due to 1 second timeout. Set allowsNextDrawableTimeout to keep retrying.
```

**実行結果**

次のようになりました。

|コード|平均値待ち時間(msec)|ドロアーブル取得に失敗した回数|トータル実行時間(sec)|
|:--|--:|--:|--:|
|autoreleaseあり|	777|	30|	65|
|autoreleaseなし|	933|	31|	70|

autorelaseをつけたほうが待ち時間は短くなり、ドローアブル取得の失敗も少ないことがわかります。

### 結論

ドローアブルを保持する時間が短いほうが、CPU処理の待ち時間が減り、パフォーマンスが上がることがわかりました。レンダリングループの処理の中でautoreleasepoolで処理を囲むコードはよく見かけますが、これが理由のようです。

### 最後に

今回はうまいサンプルを作るのに苦労しましたが、なんとか完成してよかったです😂

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