DirectXの話 第190

Work Graph 事始め その1

23/12/20 up

今年最後の締めくくりはWork Graphを始めてみました、という記事です。

現在のGPU駆動レンダリング

現在、GPU駆動レンダリングはどんなゲームエンジンでも多かれ少なかれ実装されているのではないかと思います。
使用用途は非常に多く、インスタンスやMeshletのカリング、マテリアルなどのビニング、スクリーンスペース技術の複数導入などが考えられます。

GPU駆動レンダリングはGPUが描画・計算した結果から、次に描画・計算すべきものを判断するというものです。
しかし、現在のGPU駆動レンダリングで用いられているExecuteIndirectは、描画すべきものを判断するというより、描画すべきでないものを排除するというものです。
ExecuteIndirectはDraw/Dispatch Callを制御するのではなく、Draw/Dispatch Callの引数を制御するもので、Draw/Dispatch Call自体はCPUが行います。
CPUの段階ではどのCallを行うべきか、また行わないべきかを判断できないため、結局最大値でCallを行い、その引数をGPUが制御することで不要なものを排除しているわけです。

Work Graphとは?

GPU駆動レンダリングを推し進めていけばどうしてもCPUがDraw/Dispatch Callをしなければならないという壁にぶつかります。
GPUでCallができれば、この壁を打ち破り新しい描画システムができるのではないか?
それを実現するのがWork Graphです。
Work GraphはDraw/Dispatch Callの是非を含む、一連のGPU処理をGPUだけで判断して処理するための仕組みです。

以下の図はDirectX-SpecsにあるWork Graphの一例を示したものです。

左上のDispatchGraph()命令を起点とし、多くのノードが接続しているのがわかります。
このノードがDraw/Dispatch Callに当たります。

例えば上部では、再帰的な粗いカリング(Recursive CoarseCulling Shader)が実行され、その後に詳細なカリング(Fine Culling Shader)が実行され、最後にメッシュのマテリアルに合わせた命令が実行されるという一連の流れがあります。
ExecuteIndirectであれば、再帰的な粗いカリングはほとんどの場合1回だけCallされ、その後に詳細なカリングがCallされ、更にマテリアルに合わせたCallが行われるでしょう。
粗いカリングでカリングされてしまうインスタンスであっても、詳細カリングとマテリアルシェーダのCallは行われます。
Work Graphであれば、粗いカリングで描画不要と判断されれば、それ以降の処理を呼び出さずに済むというわけです。
まさに真のGPU駆動レンダリングと言えます!

現在のWork Graph

しかし残念なことに、この記事を書いている2023年12月の段階では、Work Graphはまだプレビュー版という位置づけです。
また、メッシュの描画ができるというようにも見える上図ですが、現在のWork GraphではCompute Shaderにしか利用できず、Vertex-Pixel Pipelineのようなグラフィクス処理は実行できません。
あくまでも開発者がテストをするためにしか使えない状態ですので、製品に組み込むのは基本NGです。

Work Graphを使うにはいくつかの条件を満たす必要があります。

・D3D12 Agility SDKが動作するPCとWindowsバージョン
・Windowsの開発者設定を有効
・Agility SDK 1.711.3 preview
・DirectX Shader Compiler 1.8.2306.6 preview
・Work Graphに対応したGPUドライバ

Windowsのバージョンは普通にバージョンアップしていれば問題ないと思います。
開発者設定も設定は簡単ですが、管理者権限が必要なはずです。
Agility SDKとDXCはNuGetで入手できますが、注意点としてVisual StudioのNuGetパッケージマネージャーからですと安定版しか選択できません。
パッケージマネージャーコンソールを利用して対象のバージョンをインストールする必要があります。
GPUドライバに関しては2023年12月の時点でAMDとNVIDIAが対応しています。Intelは不明。
ただ、ハードウェアがどの段階まで対応しているかは不明です。NVIDIAに関してはRTX4080で動作を確認しました。

Work Graphは新機能としては結構大きなものになっていて、DirectX-Specsを見ても覚えるべき項目が多いことがわかります。

https://github.com/microsoft/DirectX-Specs/blob/master/d3d/WorkGraphs.md

そのため、簡単なコードを用いて複数回に分けて挙動を確認しながら記事を書いていきたいと思います。
以下はそのためのプロジェクトです。

D3D12WorkGraph

今回はまず、普通のCompute Shaderとほぼ同じものを実行するまでの手順です。

デバイスの初期化

デバイスの初期化は基本的な部分に違いはありませんが、ID3D12DeviceID3D12GraphicsCommandListがそれぞれExperimentalが付属したものを利用します。

また、D3D12EnableExperimentalFeatures関数を用いてシェーダモデルとステートオブジェクトのExperimentalを有効にする必要があります。
これらを有効にすることに失敗した場合、Windowsの開発者設定がOFFになっている可能性がありますので、その場合はONにして再度試してみてください。

これらはサンプルプログラムのCreateD3DContext関数で行われています。

シェーダコンパイル

シェーダをコンパイルする際の注意点としては、シェーダプロファイルとして"lib_6_8"を利用することです。
シェーダモデル6.8が必要になり、また、レイトレでも使用したライブラリ形式でコンパイルします。
シェーダ自体の解説は後で行います。

ステートオブジェクトの作成

ステートオブジェクトはレイトレのステートオブジェクトと同様にSubobjectのスタックを用いて作成します。
今回必要となる最低限のSubobjectはDXIL_LIBRARY, WORK_GRAPH, GLOBAL_ROOT_SIGNATUREの3つです。

ステートオブジェクトを作成する間にGlobal Root Signatureを作成します。
今回は簡単なシェーダのため、UAV1つだけのものを作成しています。

Root Signature 1.2を利用していますが、特に意味はありません。1.0でも大丈夫じゃないかと。

ステートオブジェクトの作成は以下のようになります。

DXIL_LIBRARY Subobjectは各関数のExport名設定が可能ですが、今回は命令が1つだけなのでインデックスで簡単に対応できるため割愛。
確認はしていないですが、レイトレと同様に関数名をExportしておいた方が本来は便利でしょう。

WORK_GRAPH SubobjectはWork Graphのプログラム名を設定します。
ライブラリ内の特定のエントリーポイントとなるシェーダのみを抽出してプログラムとすることで、1つのライブラリから複数のWork Graphを作成することができるようです。
エントリーポイントを指定すると、(多分)ドライバが自動的に必要なノードを抽出し、まとめてWork Graphとしてくれます。
ただし、フラグにD3D12_WORK_GRAPH_FLAG_INCLUDE_ALL_AVAILABLE_NODESを指定すると、ライブラリに含まれるすべての有効なノードをWork Graphに登録してくれます。
サンプルではだいたいこれでOKじゃないかと思います。

ステートオブジェクトが正常に生成できたら、実際にWork Graphを利用するための準備を行います。
必要なのはWork Graphプログラムのハンドルの取得と、Backing Memoryと呼ばれるバッファの作成です。

GetProgramIdentifier関数を用いて、ステートオブジェクトからプログラムのハンドルを取得します。

Backing Memoryは言ってしまえばWork Graphが使用する作業バッファです。
ノード間のデータのやり取りなど、内部のメモリだけで解決できない部分をBacking Memoryで解決します。
GetWorkGraphMemoryRequirements関数を用いることで使用メモリの最大値、最小値を取得することができます。
最小値はWork Graphを最低限実行するために必要なメモリサイズで、最大値は最も多くメモリを使用する場合のサイズのようです。
最低限で動かすだけなら最小値でも良いようですが、基本的には最大値を確保しておいたほうが安全です。
本サンプルでは最大値分のバッファを生成しています。

また、Backing MemoryはWork Graphを並列実行しないのであれば共有しても大丈夫のようです。
しかし、複数のCommand Queueで並列にWork Graphを実行する場合、動作は未定義となるようです。
基本的にBacking MemoryはQueueごとに作成するほうが良いのではないかと思います。

簡単なシェーダを試してみる

まずはWork Graphのノードを利用せず、単一のシェーダを実行するだけにしてみましょう。
今回の場合はほぼCompute Shaderですので、実際にはWork Graphを利用する必要はありません。

試してみるシェーダコードを以下となります。

エントリーポイントとなるのがFirstNode関数です。
ここで行っているのはInterlockedAddによる単純なアトミック加算です。

まず目につくのはFirstNode関数のたくさんの修飾子ではないでしょうか。
[NumThreads]はCompute ShaderでおなじみのスレッドグループのXYZのスレッド数です。
Work Graphと言っても今のところ使用できるのはCompute Shaderのみですので、この修飾子は絶対に必要になります。

[Shader]修飾子はレイトレでもおなじみですが、ここでは"node"を指定します。
この修飾子によって、この関数がWork Graphのノードであることを示します。

[NodeLaunch]修飾子はノード起動の種別を設定していますが、今回は解説せず、次回以降で複数ノードを使用する際に解説します。

[NodeMaxDispatchGrid]修飾子は、Compute Shader起動時のDispatch Callで指定する引数の最大値です。
Work GraphではBacking Memoryの関係からか、Dispatch Callで指定するXYZのグリッド数の最大値は予め設定しておく必要があります。
この修飾子を指定した場合、入力レコードとしてSV_DispatchGridセマンティクスを指定する必要があります。
また、もしもグリッド数が固定でいいのであれば、[NodeDispatchGrid]修飾子を利用することもできます。

次にFirstNode関数の引数に注目です。
SV_DispatchThreadIDはCompute Shaderおなじみのシステムセマンティクスですが、DispatchNodeInputRecordというテンプレートが設定されています。
この構造体はWork Graphの前段ノードからの入力レコードを示しています。
エントリーポイントの場合は前段ノードがありませんので、CPUがDispatchGraph関数を呼び出す際に設定することができます。

このシェーダでは、dtidをインデックスとし、そのインデックスと入力レコードのValueを乗算して、それをUAVのインデックス番号にアトミック加算をします。
実行したところで特に意味のないシェーダですが、動作を確認しやすいので今後もこのサンプルを拡張していきます。

実行!

では、Work Graphを実行してみましょう。
UAVと、そのUAVのリードバック用のバッファを作成しておいて、Work Graphを実行→UAVをコピーして、UAVの先頭から16個のUINT値をコンソールにプリントするだけの処理です。

Work Graphの実行部分はこちら。

入力レコードはDispatch Gridサイズと値Valueで構成されています。
今回は2つのレコードを用意しました。
これによって2回のDispatch Callが行われることになります。

Global Root SignatureとUAVのアドレスをCompute Pipelineに設定し、Work Graphのプログラムを設定します。
D3D12_SET_PROGRAM_DESC構造体にそれぞれ必要なパラメータを設定します。
D3D12_SET_WORK_GRAPH_FLAG_INITIALIZEフラグは、実行に先立ってBacking Memoryを初期化する命令です。
この際に指定したBacking Memoryを実行しているWork Graphがある場合、Work Graphは正常な動作を行わない可能性があります。
必ずバリアやフェンスを利用して同じBacking Memoryを使用しているWork Graphと動作がかぶらないようにしてください。

DispatchGraph関数を呼び出す際にはD3D12_DISPATCH_GRAPH_DESC構造体を設定します。
前段で設定されたWork Graphプログラムのエントリーポイント関数のインデックス(今回は1つだけなので0を指定)、入力レコードの配列先頭と配列の要素数、入力レコードのストライドを設定し、DispatchGraphで実行します。

結果

このサンプルの実行結果は以下のようになるはずです。

入力レコード0番はグリッドサイズのXが2ですので、スレッドグループ数4と合わせて8スレッドが起動します。
入力レコード1番は同様に16スレッドが起動します。
DispatchThreadIDをインデックスとするため、0番のDispatchと1番のDispatchは最初の8スレッドだけ出力先が被ります。
後半の8スレッドは1番のDispatchのみ出力します。
インデックスにValueを乗算したものを加算しますので、最初の8スレッドでは (10 + 22) * index の値が、後半の8スレッドでは 22 * index が出力されています。
結果はその通りになっていて、間違いなく0番目と1番目のDispatchが動作していることがわかりますね。

しかし、DispatchGraphによって複数のDispatchが行われた場合、その実行順序がどの様になるかは不定になるかと思います。
両方が同時に実行されているかもしれませんし、順番に実行されるかもしれません。
これについてはGPUメーカーやドライババージョンで異なるものと思われます。

例えば、シェーダコードを以下のように変更してみます。

InterlockedAddをコメントアウトし、データ競合が発生することを許容して index * Value の値を格納するようにしてみました。
すると、RTX4080では以下のような結果となりました。

最初の8スレッドは入力レコード0番が、後半の8スレッドは入力レコード1番が出力されています。
InterlockedAddの例からどちらのDispatchも正常に動作していることがわかっていますので、入力レコード1番は最初の8スレッドでも正常動作しているはずです。
しかし結果としては入力レコード0番の結果が反映されています。

なお、代入ではなく加算にしてみたら同じ結果となり、InterlockedAddのように正常な加算は行われていません。
ここから、動作自体は並列、出力自体はキャッシュの問題か出力順番の問題かは不明ながら、入力レコード0番が優先されているという状態です。
もちろん、これはNVIDIAの現行ドライバでの処理であり、AMDやIntelでは別の動作をする可能性もあります。
どちらにしろ、データ競合には十分注意して処理をする必要がある、という並列プログラムでは当たり前のことに気をつけなければなりません。

最後に

今回は事始めということで、Work Graphならではとは言えないサンプルとなりました。
これから先では少しずつ複雑なグラフを作成し、色々な挙動を探っていきたいと思います。
まあ、今年はこの記事で終わり(予定)ですので、来年からになります。

それでは皆さん、良いお年を。