拙訳
PS3時代のエンジン
構成
- 30Hzのシングルスレッド
- ゲームロジックを処理してからコマンドバッファをセットアップしていた
- SPUはワーカースレッドとして使っていた
- エンジンシステムのほとんどはSPU上で処理していた
- SPU上で動作するgameplayコードはごくわずか
問題点
- ジョブは返すものがなくても完了を待たなければならない
- アトミックな単位が決まっている
- メモリ管理はユーザーがやらなければならない
- ジョブを定義したり、メモリの寿命を気にしたりする手間が大きい
- ジョブリストの状態が煩わしい
- ジョブリストが実行中か停止中かを気にしなければならない
- ジョブ配列を指すマーカーインデックスによるジョブ同期
- インデックスは再利用されうるため、使っていないときにリセットしなければならない
新しいジョブシステムのデザインゴール
- SPUに移せなかったコードもジョブ化できるようにする
- ジョブの実行途中でも追加のジョブを生成できるようにする
- gameplayプログラマーにも扱いやすいAPI
- ユーザーがメモリ管理しない
- 1つのシンプルな方法でジョブの同期や連鎖を行う
- APIの使いやすさのためなら、パフォーマンスは二の次
Fibers
- 個別のスタック空間を持ち、実行状態を保存する
- 単一のスレッドで実行される
- 明示的に呼び出す以外で、割り込みが発生しない
- オーバーヘッドは小さい
- ファイバーの付け替えはレジスタの保存と復元のみ行うので、スレッドのコンテキスト切り替えが発生しない
ジョブシステム
- 各コアに対応させた、6つのワーカースレッド
- スレッドが実行単位、ファイバーがコンテキスト
- ジョブは必ずファイバーのコンテキスト内で実行される
- 同期にはアトミックなカウンターを使う
- ファイバーを使う
- スタックサイズが、64KiBものが128、512KiBのものが32の、計160
- 優先度別の3つのグローバルなキューを持つ
- job stealing無し
詳細
- ワーカースレッドはコアに固定される
- 無用なスイッチングを避けるため
- WaitForCounter
- ジョブをファイバーごと待機させる
- “The Last of Us: Remastered”では、1フレームに最大800から1000のジョブを処理する
I/O処理はシステムスレッドで行われるので、そこだけ例外的にデータの読み出しとジョブの発行を行うハンドラで処理する。それ以外はすべてジョブ化されている。
利点
- 既存のgameplay更新処理を簡単にジョブ化できる
- ジョブを待機させるのが簡単
- ファイバー切り替えが超軽量
欠点
- ミューテクスやセマフォなどのシステムの同期プリミティブが利用できなくなる
- 同期はハードウェアレベルでやらなければならない
- アトミックなスピンロックが至るところで使われている
- ロックが長引く場合には特殊なジョブ用ミューテクスを使う
ファイバーのサポート
- 基本的にはスレッドと同様にスタックトレースできるしダンプも吐くことができる
- 寝ているファイバーを異なるスレッドで起こすと、キャッシュされた前のTLSアドレスにアクセスすることがある
- コンパイラに”Fiber-Safe TLS”オプションがあれば使う
- ないなら、TLSアクセスを毎度キャッシュせずに行うよう、翻訳単位を分ける
- ジョブシステムではadaptive mutexを使う
- システムコールを行う前にスピンロックでどうにかしてみる方法
- 優先順位の逆転によるデッドロックを解決する
- 初回のスピンで大抵のシステムコールを回避できる
他のジョブシステム
- 有名所、Intel TBB
- 依存性解決できるが、すべてのジョブの完了を待たなければならない
- ジョブを待機させると、ネストさせることができる
- 子ジョブが完了しない限り親ジョブを再開できない
厳しい現実(Harsh realization)
- 殆どのシステムはジョブ化してしまった
- GPUの最適化はガッツリなされていた
- CPUのクリティカルパスが25msかかり、これ以上の細分化をしてもCPUの限界が見えていた
- コアがアイドル状態にある時間が多く存在した
- 30FPSを達成した段階で残り2ヶ月ほどしかなかった
この段階では、処理待ちのアイドル時間が目立って存在した。また、ゲームロジックを完全に処理し終えてから、レンダーロジックを処理するようになっていた。
60FPSを目指すにあたって仕事量を目算してみる。
このゲームは1フレームに最大で100ms相当のCPU処理を行う。
単純に考えてCPUは6コアあるので、60FPSで動作するとなると16.66ms * 6 = 99.96msだけの仕事量をこなせる。つまり、理論上は100ms相当の仕事量をこなすことは可能ではありそう。
Frame Centric Design
以前のデザインではゲームロジックが完了したあとに、レンダーロジックを処理するようになっていた。
そこで、フレームをゲーム、レンダー、GPUの各ステージに分けて考えることで、ゲームロジックとレンダーロジックを並列に処理するようにした。
- 各ステージは完全に独立して動作する
- ステージは次のフレームを他を待たずに開始できる
- エンジンデザインの複雑さが緩和される
- 並列処理に使われるロックが少なくてすむ
- 重たいジョブ化されたステージ更新処理の中で同期をおこなうためにのみロックが使われる
PS3時代のメモリ割り当て
- 線形なアロケータ
- 連続したメモリブロックとそのオフセットを用いる
- アロケートは単純なオフセットの更新で行われる
- 解放はオフセットを0にするだけでよい
- シングル/ダブル/トリプル フレーム
- フレーム開始時に新しいブロックに取り替え、オフセットを0にする
メモリブロックはその寿命をフレーム単位で指定することができる。
これらは、フレームが1つであればうまく機能した。
マルチフレームの難しいところ
- “いつメモリを解放したらいいの?”
- “毎フレーム新しいバッファが欲しいんだけど”
- “memory stomping1を回避しつつ取り替えるにはいくつバッファがあればいいの?”
- “フレームの経過時間はどれを使えばいいの?”
Frames
あちらこちらで言及されるフレームだけど、意外と人によって解釈が違ったりする。
ゲームロジックだったり、GPUだったり、ディスプレイだったり。
メモリの寿命を指すときにも使われる。ダブルバッファとかトリプルバッファとかはどうすればいい?
そこで、フレームを再定義することにした。
このときは、“処理されて最終的にスクリーンに表示されるデータの集まり”とした。
重要なのは、計測時間ではなくデータの集まりとしたこと。
FrameParams
FrameParamsは各フレームが表示されるまでに必要なデータを持ち、すべてのステージ間を渡り歩く。
FrameParamsはフレーム毎の状態(フレーム番号、delta time、スキニング行列など)を含む。
必要なデータにアクセスするための、各ステージの入り口になる。
FrameParamsは、各ステージが個別のインスタンスの上で動作するため、競合しない資源である。
delta time、カメラ位置、スキニング行列、描画するメッシュのリストなどが毎フレーム分コピーされる。
各ステージの開始/終了タイムスタンプが保存される。
特定のステージが完了したかどうかをテストするのは、タイムスタンプをチェックするなどですむ。
メモリの寿命はそのメモリを使っているフレームの完了を待つだけでよくなる。
16のFrameParamsを切り替えて使うことで、直近の15フレーム分をトラックできる。
Tagged Heap
メモリは異なるアロケータ、異なる寿命を持つ。サイズは最悪の場合を想定して確保してあるので、100から200MiBの無駄が発生している。
Tagged heapを導入した。Tagged heapは、PS4のTLBサイズである2MiBを基本サイズとしたブロックベースのアロケータで、ブロックはuint64_t型のタグを持つ。ブロックをfreeする関数は持たず、特定のタグをまとめてリセットできる。
アロケータはブロックを1つ持っていて、ブロックが空になるまでその領域から要求に応じた割り当てを行う。
高速化のため、ワーカースレッドごとにブロックを1つ持つ。