[CEDEC]「FINAL FANTASY XV」の最適化はこうして行われた
本稿では,この日,3番めに行われたエンジニアリング系のセッションである「AAAタイトル開発における最適化 FINAL FANTASY XV実例紹介」の内容をレポートしたい。タイトルにはFFXVの名前が入っているが,ゲームプログラミング全般に応用できそうな内容となっていたので,広く参考になるのではないかと思う。
FFXVでのゲームループ並列化の変遷
先代ゲーム機のうち,PlayStation 3はメインCPU1基に加え,超高速だが各コアのプログラムサイズとデータサイズが256KBに限定されるSPU(Synergistic Processing Unit)が7基という非対称マルチコアだった。一方のXbox 360は同系のCPUによる対称型マルチコアだったがコア数は三つ。PS3,Xbox 360世代では,CPU関連のプログラミングにおいて,それぞれ特有の最適化を行わなければならなかったが,今世代機は偶然か必然か,PS4,Xbox Oneともにほぼ同系のCPUコアが8コア構成となり,アーキテクチャの共通化が図られたことで,幾分か難度が下がっている。
セッション前半の講演を担当した佐藤達磨氏は,まず,FFXV/Luminous Studioの開発にあたっての,このマルチコアCPUシステムへの最適化について語った。
下図は,東京ゲームショウ(TGS)2014時,EPISODE DUSCAE体験版時,現在開発中の最終版時のCPUの使用率を可視化したものだ。
色がついた横棒の塗りつぶされ率が高いほどCPU利用率が高く,グレーぽいところはCPUが遊んでいる状態を表している。
PS4/Xbox OneのCPUは8コアと説明したのに,この図に4コア分の使用率しか見られないのは,ほかのコアは「描画」「ファイル入出力」「音響」といった非同期処理に利用されており,この図には含めていないためだ。ここで示しているのは,メインのゲームループを担当する4コアの使用率になる。ちなみに,この図は,デバッグ関連のコードを含んでいるため,佐藤氏はこれが除去される最終製品版ではさらに並列性は上がるだろうと予想していた。
タスクスケジューラの改善の流れ
さて,上で挙げた図からも分かるようにFFXVは,並列化したゲームループに三つのバージョンがある。開発チーム内では,各バージョンをそれぞれ「第一世代」「第一世代改良型」「第二世代」と呼称していたという。
ところで,FFXVの並列化されたゲームループとはどんな基本構造になっているのだろうか。
「ゲームループの並列化」については,このセッションでは「基本事項のため,触れない」ということで語られなかったが,本稿では予備知識的に少々語っておくことにしよう。
一般にゲームプログラムは並列化がしづらいと言われてきた。というのも,「プレイヤーからの入力処理」「各キャラクターの状態更新」「ゲーム世界の更新」「AI処理」など,それぞれの処理が互いの処理結果に依存して進行していくため,順序立てて(シーケンシャルに)処理する流れになっているからだ。
PS3,Xbox 360時代に突入してから,この考え方を改めようとする動きが活発になり,並列化を積極的に行う流れが生まれた。
以下のCEDEC2006のスライドは,当時,業界に衝撃を与えた名講演となったカプコン,石田智史氏(技術研究開発部 技術開発室,リードプログラマ)による「次世代機に向けたゲームエンジンの設計」から引用 |
ゲームプログラムを構成する機能ブロック(モジュール)単位での並列化 |
そのアプローチは大別して二つ。
一つは,大量の同系処理をループで回して実行するようなタスクを,複数のCPUコアに分けて実行する方法で,具体的には,複数のキャラクターの状態更新などは,別々のCPUコアに実行させる……というようなことに相当する。
二つめは,前述したような「プレイヤーからの入力処理」「各キャラクターの状態更新」「ゲーム世界の更新」「AI処理」といったゲームプログラムの機能ブロックを別々のCPUコアに実行させるというもの。実行結果の依存関係がある処理もあるため,完全に並列実行ができるのはそうした依存関係がないもの同士だけにはなる。
そうした二つのアプローチで並列実行するために分解したゲーム処理単位を,最新のゲームプログラム(ゲームエンジン)では,「タスク」(「ジョブ」と呼称するゲームエンジンもある)として扱うようにして,こうしたタスクを,空いているCPUコア(CPUコア)に適宜発注して実行させるような仕組み・設計になっている。
ゲームプログラムの並列化アプローチの一つはループの並列化 |
プログラムの実行処理単位を固定的に特定CPUコアに割り当てて並列実行するのではなく,空いているCPUコアに対して,適宜,動的に割り当てて実行させるのが,タスクベースの並列化アプローチだ |
この「空いているCPUコア(CPUコア)にタスクを適宜発注していく」という処理を実行するのが「タスクスケジューラ」である。タスクスケジューラは,依存関係のないタスク群は,なるべく異なるCPUコアに発注して並列実行効率を上げようとすることになる。
佐藤氏によれば,第一世代(TGS2014年版)のタスクスケジューラは,「実行Priority」(実行優先度)と呼ばれた0〜1000までの数値で各タスクの実行順序を管理していたそうだ。
たしかに,下図を見ても分かるが,各Priorityにおいて,処理時間が短くて済んだCPUコアが遊びがちになっている。
この問題を解決するために,タスクスケジューラを改良し,それを実装して発表されたのがEPISODE DUSCAE体験版になる。
「第一世代改良型」では,第一世代で問題の原因となっていた「処理時間の長いタスク」を,Priority境界をまたがって実行できるようにして,改善を試みたという。つまり,同一Priority番号のタスクで処理が早く終えたCPUコアについては,次のPriority番号のタスク割り当てに対応できるようにしたということだ。
この改良の効果は大きく,遊んでしまうCPUコアをかなり減らすことにつながったようだ。CPU使用率はワーストケースで65%にまで引き上げられ,かなり並列実効性が進んだと言える。
そして,この後,最終製品版の開発に向けて,タスクスケジューラを思いきって刷新することを決断している。これが「第二世代」タスクスケジューラに相当する。
その刷新版のタスクスケジューラでは,各タスクの依存関係を吟味し,実行順と並列化を管理できるようになっている。
設計方針としては,「依存先のタスクが終了すると依存元のタスクの処理が開始される」というもの。つまり「依存関係にあるタスク同士は並列実行させない」というシンプルな方針だ。
また,依存関係の解決を効率化するために,「タスクのグループ化」という概念も導入した。同一グループ内のタスクは並列実行を仕掛けられるが,依存関係のあるグループ同士は並列実行を仕掛けられない。
下図は,この新タスクスケジューラの動作を図化したものだ。
この図のグループAとグループBには依存関係があるので,グループAのタスクの実行が終わってから,グループBのタスクの実行が開始される。このため,グループAとグループBのタスクは並列実行は実行されない。ただし,グループA内,グループB内の各タスクは並列実行が行われる。
グループCとグループDのタスクはグループBと依存関係があるのでグループBのタスクの実行が完了してから実行が開始される。グループCとグループDには依存関係がないのでこの二つのグループは並列実行が行われる
こんな具合だ。
佐藤氏は「この依存関係解決型タスクスケジュールの効果は大きい」と振り返る。
「局所的に依存関係を持つタスク」をあるCPUコアで実行させたとき,このタスクとは依存関係のない,別の「局所的に依存関係を持つタスク」を別のCPUコアで実行させれば,大局視点では並列実行ができていることになる。
佐藤氏は,この新タスクスケジューラの完成度には概ね満足できたようだが,欠点として,実行時にどういう順番でタスクが処理されているのかがほとんど把握不能になることを挙げていた。この部分はデバッグのしづらさにつながってくるのかもしれない。
並列化効率の向上への取り組み
例えば描画コマンドの生成タスクなどは,同一メモリ領域へのアクセスが集中する。こういうタスクは,特定のCPUコアで実行したほうが,そうした関連データがキャッシュメモリに載りやすくなるため,多少はメモリアクセスの隠蔽につなげられる。
ただ,この仕組みは一長一短だ。
ある一連のタスクを特定のCPUコアで実行させる設定にしているために,すでに別のCPUコアが空いているのに,続くタスクの実行を発行できない……ということもありえる。そこで,そんなケースでは,この「固定割り当て」ルールをあえて破り,その時点での空きCPUコアに実行させることも許容させている。これは,タスク側に「どのCPUコアで実行するか」という「優先度の設定」で実現しているという。
また,処理時間の長いタスクが控えていることが分かっているとき,これを先に実行開始する仕組みも搭載されている。これは,並列実行が自ずと許容された同一のタスクグループ内で行われる仕組みで,これもまた,同一グループ内の各タスクに実行優先度を設定することで実現している。
特定種別タスクの特定CPUコアへの割り当ては,固執するとパフォーマンス低下に結びつく可能性もある。この問題にも対処 |
この仕組みは,骨数の多い骨物理タスクの実行に利用されているとのこと |
処理時間が短い(粒度が細かい)タスクが大量に並列実行される場合,その同期処理やタスクの出し入れ(コンテクストスイッチ)のオーバーヘッドタイムが増大して並列化の意味が薄れることがある。そこで,粒度の細かすぎるタスクにならないように,その粒度を調整できる機能も備えたとのこと。例えば,100要素のデータ処理を100個のタスクにして並列処理を実行するのではなく,10個分データ処理を10個のタスクに分解して並列実行させるように調整することができる,ということだ。
Mutex回避のための努力
続いて佐藤氏は,並列化を推し進めたときに直面する問題「Mutex問題」について言及した。
Mutexとは,「MUTual EXclusion」(排他制御)のことで,本セッションではとくにメモリアクセスに関連したMutexのことをいっている。
つまり,あるタスクでメモリを読み書きする際,ほかの並列タスクで同じメモリ領域に対して読み書きしてしまうと,実行のタイミングによっては誤った結果になる場合がある。例えば,極端な例だが,あるタスクが計算の途中結果をメモリに保持したとして,そこを別のタスクが上書きしてしまったら,最初のタスクの計算結果は正しくなくなってしまう。
これを回避するために,Mutexを導入する。つまり,あるタスクが特定メモリ領域に読み書きする可能性がある間は,ほかの並列タスクからこのメモリ領域のアクセスを禁止する処理系だ。
具体的には,各CPUコア単位でメモリ管理をするようにして,互いのCPUコアからのアクセスメモリ領域を異なるようにメモリを確保すればいい。
具体的な実装手法を示したものが下図になる。
複数のタスクから構成されるプログラム機能モジュールがあったとして,これの実行初期化時には,実行を仕掛ける予定のCPUコアの個数分,メモリオブジェクト(スレッドローカルストレージ:TLS)が生成される。各スレッドは,このスレッドローカルストレージに対してインデックスを通してアクセスするのだが,キモとなるのは,別スレッドのスレッドローカルストレージにもインデックスアクセスが行えるという点だ。ただし,アクセスは,今回設計した専用のスレッドローカルストレージアクセス用のAPIを通じて行う必要はある。
要は,タスク単位のMutexの発動を回避し,スレッドローカルストレージに対してのみのMutexに限定することで,長い時間,互いのタスクが待ち合う状況を低減させたというわけだ。もっと簡単に言えばMutexの粒度を小さくした……と換言してもいいかもしれない。
この図をもう少し噛み砕いて解説しよう。
これは,ある1キャラクターに着目した場合を表していて,例えば自分の周囲の環境の認識し,攻撃対象モンスターの位置の把握いった,いわば知覚処理に相当する検索タスクを並列に行わせ,結果は各CPUコアごとのスレッドローカルストレージへと並列に格納していく。そして,全スレッドローカルストレージに集まった検索結果に対して,今度は逐次処理として検索を行い([Serach Filter]のところ),キャラクターが取るべきアクションを決定する。いうなれば,検索タスクを検索結果収納先のメモリごと並列化したイメージだ。
以上のような,並列実行性に配慮した高度なタスクスケジューラや,それに関連した賢いタスク実行システムにより,現在開発中の最終版は,EPISODE DUSCAE体験版時のときと比べて,同一シーンで全CPUコアで実行されているオブジェクト数は約3倍にもなったとのこと。もの凄い最適化効果である。
既存プログラムコードの高速化
佐藤氏は,FFXVのプロジェクトには途中からの参加だったそうで,既存のプログラムコードの最適化も多く担当したという。
ということで,続いて,佐藤氏は並列化とは別の,今回のXXFVにおける,既存のプログラムコードの高速化に関する工夫について言及した。
まず取り組んだのは,呼び出し回数の多い関数の高速化だった。
紹介されたのは,SSE組み込み命令を用いた関数の引数の引き渡しにおいて,参照付き(const &)を用いて関数呼び出しをしている事例だ。これだと,スタックポインタにポインタ変数が積まれて,ポインタ変数を介して引数をとってくる実装形態になり,メモリアクセスが余計に掛かる。
これを改善するには,この参照での引数受け渡しをやめることだ。これだけで直接レジスタに値をロードして計算に移ることができるようになる。
要するに,これは,逆アセンブラを使うことで,コンパイル後のアセンブリ命令に無駄がないかチェックして最適化を施した……ということの事例である。
αRGBのピクセルデータや(w,x,y,z)のような複数要素からなるベクトルデータを1レジスタに収納しているようなArray of Strucutre(AoS)型データに対する内積(dp)演算は,専用の内積命令を使うよりも,掛け算命令(MUL)と水平加算(HADD)を使ったほうが速いそうで,そうした命令に置き換えたとのこと。
このほか,佐藤氏は「const *」「const &」のような参照型引数を与える仕組みにおいて,「restrict」を組み込むことで,ポインタの参照先をローカルコピーさせるコードの生成を抑止できること,関数の呼び出しコードではなく関数そのものをそのプログラムコードに展開してしまう「関数のInline化」にも触れた。関数のInline化は,ベクトルデータを取り扱う計算で,しかもその関数自体の命令数が少ない場合に大きな高速化が発揮されたとのこと。オーバーヘッドの削減が功を奏したということだろう。
restrictの追加はポインタの参照先がほかの関数で変更されないことをコンパイラに伝えるための命令である |
関数のInline化は,呼び出し先の関数を呼び出し元に関数そのものののプログラムコードとして展開してしまうもの。実行時の関数呼び出しオーバーヘッドは削減できるがプログラム長は長くなる |
こうした最適化は,FFXVでは,骨物理の高速化に貢献したとのことである。
FFXVにおけるVFXの最適化(1)〜EPISODE DUSCAE体験版編
ここでいうVFXとは,爆炎,爆煙,火花,水しぶき,稲妻などの,映像に彩りと派手さを与えるようなグラフィックスエレメントのこと。FFシリーズに限ったことではないが,こうしたパーティクルエフェクト的なVFXは,一つ一つは細かいものの,シーン内に同時に描かれる物量がとてつもなく多い。
FFXVのVFX制作環境については,昨年のCEDEC2015の「FINAL FANTASY XV -EPISODE DUSCAE-2のエフェクトはこうして作られた〜Luminous VFX Editorの紹介〜」というセッション(参考PDF)で詳しく語られている。
小野氏はまずこのセッションを軽く振り返ったので本稿でも触れておくが,FFXV/Luminous StudioのVFXエディタは,入出力ポートを持つ機能ブロック(マス)をつなぎ合わせてVFXを設計していけるノードベースのビジュアルプログラミング的なツールになっている。FFXVのさまざまなVFXは,このツールを用いて,アーティスト達の手によって制作された。
VFXのシェーダは,管理・編集のしやすさの観点から,長く万能なシェーダを,変数/データの与え方で機能を変調したり,あるいは分岐させたりするUber Shader方式(万能シェーダ方式)を採用していたそうだ。Uber Shaderに付きものの大規模な分岐については,速度が重視されるものについては分岐を排除して別シェーダ化するなどして高速化をしていたが,それでも不十分だったようだ。なにしろそのUber Shaderはフルフル状態では命令数が1800個に上り,GPU内部のレジスタを激しく消費するために,GPU内グラフィックススレッドの並列稼動率(≒Wavefront占有率)が30%前後という芳しくないGPU動作状態に陥っていたという。
また,あまり,意味のない高負荷処理の多用も追い打ちを掛けていたようで,例えば,すべてのエフェクトのライティングをPer Pixel Lighting(ピクセル単位のライティング)で実行していたこと,フォグ効果,大気散乱効果までもがそのUber Shaderに組み込まれていたことも,高負荷の原因だったと考察している。
そこで,初期リリース版から3か月後に提供された体験版2.0の開発にあたって最適化を敢行している。
例えば,形状が曖昧で物量で表現するようなオブジェクトの描画は,Per Vertex Lighting(頂点単位のライティング)で代用し,ピクセル単位のライティングはこの結果から線形補間で対応することで,命令数を1800から1101にまで削減している。
フォグ効果や大気散乱効果は遠方でのみ効果を発揮するとして,近景エフェクトからこのシェーダを排除することで,そうしたエフェクトの命令数を925へと減量した。さらに,必要に応じて,効果が薄いと思われる条件分岐の排除,高次関数利用の簡略化なども行ったものは命令数を777にまで縮小している。
これで,体験版2.0の最低フレームレートは17fpsから26fpsにまで向上したという。
FFXVにおけるVFXの最適化(2)〜PLATINUM DEMO編
今年3月に公開された「FINAL FANTASY XV PLATINUM DEMO」の開発にあたっては,シェーダシステムは,テクスチャ同士のブレンド機能,他者との描画境界を低減させるSoftParticle機能,アルゴリズムで変調が可能なプロシージャル機能,パーティクルごとに使える乱数機能などが導入され,さらに高度化した。
この時点で,設計上はUber Shader方式だが,ランタイムで実行されるものは事実上,Uber Shader方式を諦めるように手を入れた。つまり,用途別に別シェーダとして生成して,総命令数の削減を行ったというわけだ。体験版2.0開発時で実行していたことをさらに押し進めたと言うことだ。
裏を返せば,この方策はシェーダのバリエーション増にもつながり,シェーダ群のメモリ占有率も上がるわけだが,開発チームは速度を優先させた,ということである。
描画負荷のさらなる低減のために,Uber Shaderの用途別バリエーション化をさらに押し進めた |
そして,PLATINUM DEMO時は,EPISODE DUSCAE体験版時には先送りにされた「エフェクト発生時の処理負荷」と「Updateの処理負荷」の低減にも手を入れている。
「エフェクト発生時の処理負荷」の低減は細かい工夫の組み合わせで実行されたようだ。具体的には,メモリ確保手法の改善,エフェクト発生時に集中して行われていた各種初期化処理の実行を時間軸上に分散,エフェクトは連続使用がなされるケースがあるので関連データをキャッシュする……といった最適化要素を小野氏は挙げていた。
先送りされてきたVFX発生時の処理負荷軽減に着手。細かい対処の積み重ねで対応している |
もう一つの「Updateの処理負荷」の低減は,EPISODE DUSCAE体験版時では,インタプリタベースで実行していた仕組みをPLATINUM DEMO時ではコンパイルしてネイティブ化して実行することで対応したとしている。具体的には,エフェクト内に含まれる計算式にIDを振って,コンパイルしてネイティブコード化したうえで関数化し,実際の実行時にはIDからネイティブコード化された関数を呼び出す方式に変えたそうだ。結果として,状況によってはEPISODE DUSCAE体験版時の2倍以上のパフォーマンスが発揮できるようになったとのこと。
すでに発生済みのVFXのパラメータ変調がUpodate処理に相当。この負荷の低減にも取り組んだ |
具体的には,インタープリター実行方式からネイティブコード化で対応したとのこと |
FFXVにおけるVFXの最適化(3)〜E3 2016公開版編
これで,VFX周りの最適化は一通り済んだように思えたが,小野氏は,特定状況における描画負荷の高さがまだ気になったという。
それはサイズの大きいエフェクトが視点近くで大量発生するような場合だ。
E3 2016公開版のタイタン戦デモでは,タイタンが巨大サイズのエフェクトを発生させるうえに,大量の槍兵が乱戦を展開するため,描画負荷は相当なものになった。
この最適化により,シェーダの最短命令数は235個にまで削減。GPU内グラフィックススレッドの並列稼動率もほぼほぼ100%に到達したという。
しかし,この最適化の際,「動的分岐を排除してのシェーダのバリエーション化」を実数パラメータの違いに対してまで行ったことで,シェーダ数が膨大になってしまう。これは,同一命令群でできているシェーダであっても,実数値の定数パラメータがわずか0.01違っても別シェーダとなってしまうためだ。そこで,この「動的分岐を排除して別シェーダ化」する最適化の条件を,実数パラメータを取り扱う場合に限ってはやや厳しめにすることによって,バリエーションの増大を抑制したという。
こうした地道な最適化の組み合わせ,積み重ねによって,ついに高負荷なシーンでも30fpsの維持が達成できたとのことだ。
FFXVにおけるVFXの最適化(4)〜製品版編
最後に,小野氏は,現在も開発が進行中の最終製品版に対して実行している最適化について紹介した。
結論から言えば,E3 2016版までで基本的な最適化は終わっているようで,製品版に向けて適用された最適化はいわば「チューニング」に近いものといえる。まぁ,発売が近づいているので,処理メカニズムを大きく変えることには手を出しにくいということなのかもしれないが。
さて,水面にキャラクターが入り込んだときにでる水しぶき,草木を切ったときに舞う葉……のようなエフェクトは,戦闘時には複数のモンスターや,味方キャラクターが行う行動に対しても発生するため,その数はそこそこ大量になる。
ただ,こうしたエフェクトは,大量発生するとはいえ,いってみれば同種エフェクトである。そこで,一度発生したエフェクトのインスタンスを使い回すことで処理負荷を低減させている。つまり,「エフェクトを出現させて消滅させる」というサイクルを繰り返すのではなく,「消滅させないで,もう一度発生させる」というような処理にしている,ということだ。
E3 2016版にもあった,巨人タイタンとの戦闘シーンでは,タイタンからの押しつぶし攻撃を剣で受け止める瞬間に,ランダムな位置で火花や光源の発生が起きている。これも,発想としては,前出の水しぶきと葉の舞と同じだ。このケースでは「消滅させないで,もう一度,別の場所で発生させる」という処理系にしている。
以上が,このセッションで語られた内容になる。
ゲームロジック側の並列化の話は,非常に実践的で,CEDEC 2006でのカプコン石田氏のセッション「次世代機に向けたゲームエンジンの設計」のときもそうだったが,自社開発のゲームエンジンの根幹メカニズムの仕組みを明らかにしてくれるという意味で価値が高かったと思う。
VFXの話は,エンジン世代(バージョン世代)ごとに「どう最適化を推し進めていったのか」という,立ち塞がる問題に対して一つ一つ対処を積み上げていく経緯にも興味を感じた。最初はとにかくシステムとして美しく設計して,機能がちゃんと動作することを重視して開発し,泥臭い最適化は後回し……という流れはソフトウェアエンジニアリングの方策としても非常に参考になる講演だったように思える。
FFXVの発売日は延期されてしまったようだが,少なくとも年内に発売されるようなので,今から楽しみに待ちたいものである。