DirectXの話 第191

Work Graph 事始め その2

24/01/06 up

あけましておめでとうございます。
2024年最初の記事は昨年に引き続きWork Graphの記事その2です。

Work Graphのノード

前回の記事ではWork Graphを実行したわけですが、実際に実行したシェーダは1つだけで、通常のCompute Shaderと比較してもInput Recordが存在するかどうか位の差しかありません。
しかし、Work Graphの真骨頂は複数のシェーダを、GPU側で判断して実行できるという点です。

前回の記事でも使っていましたが、下の図はWork Graphの概念図です。
ここで各処理となるノードがシェーダ1つで、そのノードを繋いで処理を実行していきます。

今回はこのノードをどのように繋いで実行していくのか、という部分の基本を解説していこうと思います。

ノードのタイプ

Work Graphのノードにはいくつかのタイプが存在します。
タイプについての詳細はDirectX-Specsを参考にしていただくのがいいかと思うのですが、ここでも簡単に紹介しておきます。
ただ、グラフィクス系のノードは現段階ではサポートされていませんので、サポートされている3つのノードについて解説します。

Broadcasting

Broadcastingノードは最も一般的なノードです。ほぼ通常のCompute Shaderと同じと考えて良いです。
通常、Compute ShaderはNumThreads修飾子で指定されるスレッドグループをDispatch命令のDispatchGridの数だけ実行することになります。
これが可能なのはBroadcastingノードのみです。
そのため、SV_DispatchGridセマンティクス、もしくはNodeDispatchGrid修飾子が利用できるのはこのノードのみとなります。

また、BroadcastingノードはInpute RecordをDispatchGrid単位で1つだけ入力することができます。

これらの特徴から、多くの場合でBroadcastingノードはエントリーポイントとして利用されます。
もちろん、中間ノードとしても利用することもできます。
今回のサンプルでもエントリーポイントとして利用しています。

Coalescing

CoalescingノードはDispatchGridの存在しないCompute Shaderという感じのノードです。
DispatchGridが存在しませんので、基本的にはNumThreadsで指定したスレッドグループ単位でプログラムが実行されます。

このノードの特徴はInput Recordにあります。
BroadcastingノードがDispatchGridごとに1つだったのに対して、Coalescingノードではスレッドグループ単位で複数のInput Recordを受け入れることができます。
Input Recordの数はMaxRecords修飾子で指定した数を最大としますが、必ずこの数だけ入ってくるとは限りません。
そのため、プログラム側ではCount命令を利用して実際に入ってきたInput Record数を確認してから処理をする必要があります。

使い道は色々ありそうですが、動作が分かりづらい部分もあるノードです。
基本的には中間ノードでスレッドグループで使用できる共有メモリを利用したい場合に選択する感じでしょうか。

Thread

Threadノードは単体で実行されるノードです。Compute Shaderでありながら、NumThreads修飾子が存在しないという、ある意味わかりやすいノードです。
このノードはInput Recordがスレッドに対して1つです。つまり、Input Recordを前段で出力するとその数の分だけスレッドが実行されるということになります。

スレッドグループという概念が存在しないため、共有メモリは使用できないようです。
ただし、GPUで実行されるプログラムですので、Wave単位では実行されます。
当然Wave Intrinsicsを使うことができますが、基本的にはあまり使わないのではないかと思います。

完全に独立した処理を行いたい場合に選択することになるかと思います。
例えば、Deferred Renderingのライティングを行う際に、Broadcastingノードでライティング不要なピクセルを除外し、ライティングが必要なピクセルだけThreadノードに投げるという使い方ができるでしょう。

実際のノード呼び出し

それでは実際にWork Graphを利用してノードの呼び出しを行ってみましょう。
今回利用したサンプルは以下となります。

WorkGraphサンプル

前回のサンプルからシェーダコードを変更した感じです。
C++プログラム側はリードバックバッファの出力部分とDispatchGridを修正している程度ですので、ここからはシェーダコード側の解説となります。

エントリーポイントとなるのはFirstNode関数です。これは前回と変わりません。
このノードはBroadcastingノードで、C++側から2つのDispatchGridで実行します。

ここからSecondNode関数を呼び出します。UAVへの値の出力もこの関数で行います。
こちらのノードはCoalescingノードで、NumThreadsで最大16スレッドのスレッドグループが実行されるようになっています。

では、まずFirstNodeを見ていきましょう。

前回との大きな違いが引数のNodeOutputと、16行目からのそれらの出力設定です。

NodeOutputはノードへの出力と次回のノードの実行を指定する引数です。
基本的な記述形式は以下のようになります。

[MaxRecords(レコード最大数)] NodeOutput<レコードの型> 出力先ノード名

サンプルではスレッドグループのサイズがFirstNodeThreadXで指定される値となります。(設定は4)
そして、各スレッドが1つのOutput Recordを出力するものとしています。
そのため、Output Recordの最大数もFirstNodeThreadX個としています。
実際に出力するレコードはこの最大数未満でも構いませんが、最大数より大きな値にはできません。

引数名は出力先のノード名である必要があります。
出力引数のように見えますが、ノード自体を引数にしているような印象ですね。

レコードの出力命令は16行目からです。
実際のレコードは引数であるノードに対してメモリアロケーションのような処理を行う必要があります。
サンプルではGetGroupNodeOutputRecords関数を利用しています。
この命令はスレッドグループ単位で実行される関数で、各スレッドごとに実行されるわけではありません。
そのため、実行はすべてのスレッドで同一の呼び出しを行わなければなりません。
分岐を利用して特定スレッドだけ実行しない、という使い方はNGのようです。

出力完了を示すOutputComplete命令も同様で、スレッドグループで必ず実行されるようにしてください。

GetGroupNodeOutputRecords関数で取得されるメモリはNodeOutputで指定した型の配列となります。
配列のどのインデックスに値を出力するかはプログラム側で正しく設定する必要があります。
今回のサンプルではスレッドグループのスレッド数分だけ確保するようにしていますので、SV_GroupThreadIDを利用してインデックスを生成しています。
ここで確保した配列分だけ次のノードでスレッドが動作するので、スレッドごとに出力する/しないを選択するような場合は注意が必要です。

次に、SecondNodeを見てみましょう。

SecondNodeはCoalescingノードです。
スレッドグループは16スレッドで、Input Recordの最大数も同じ16個としています。
出力バッファのインデックスはFirstNodeで生成し、出力値はWavePrefixSumを使って適当に出力しています。
Wave Intrinsicsを使っている理由については後で解説します。

ここで重要なのはInput RecordのCount命令を使ってInput Recordの実際の数以上にスレッドを起動しないようにしている点です。
サンプルではFirstNodeのスレッドグループが4スレッドですので、SecondNodeのスレッド数は最小で4スレッドです。
ですので、16スレッドを起動するようにしていても入ってくるInput Record数が16になるとは限らないのです。
プログラムを正しく動作させるにはこの点に注意が必要です。

では、このプログラムを実行してみましょう。
エントリーポイントのInput Recordは2つで、どちらもDispatchGridはXが2となります。
スレッドグループが4スレッド、1つのInput Recordにつき8スレッド、2回のDispatchGridで16スレッドが起動することになりますが、さて結果はどうなるでしょう?

リードバックバッファの先頭から16個の値を表示していますが、Wave Intrinsicsのおかげでどのようなスレッドグループが作られているのかわかりやすいですね。
FirstNodeのスレッドグループごとにSecondNodeを起動しているわけですが、実際のSecondNodeは2つのDispatchGridから起動された16スレッドが起動していることがわかります。

また、スレッドの順番もなかなか面白いです。
同じスレッドグループから起動したスレッドは順番が固定されていますが、スレッドグループが違うと順番が入れ替わります。
DispatchGridの順番はほぼそのままという形になるようです。
といっても、これはNVIDIAの場合の動作であり、AMDやINTELでは変わるかもしれません。
スレッドの順番についてはあまり参考にしないほうがいいかもですが、仕様としてはなにかあるかもしれません。
DirectX-Specsにはそれっぽい記述はなさそうですが、全て読み込んでいるわけではないので不明です。

今回はSecondNodeのスレッド数と実際に処理したスレッド数がピタリと合っていたので良かったのですが、中途半端ならどうなるでしょう?
DispatchGridのXを3にしてみると以下のような結果になります。

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を利用する必要はありません。

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

DispatchGridごとに起動するスレッド数は12スレッドです。
結果を見ると、1回目のDispatchGridで起動する12スレッドに2回目のDispatchGridから実行される12スレッドのうち4スレッドが加算された16スレッドが起動しています。
余った8スレッドは別のスレッドグループとして動作していますね。
2回目からの4スレッドがどのインデックスのスレッドが利用されているのかは不明ですが、実行ごとに結果が微妙に変わったりするので、順番が重要であれば2つのノードのNumThreadsは同一の値に固定したほうが良さそうです。

別のプログラムも見てみましょう。今度は3つ目のノードまで起動してみます。
SimpleWorkGraph.hlslENABLE_THIRD_NODEを1に設定することで、SecondNodeからThirdNodeを実行するようなWork Graphとなります。

SecondNodeは以下のようになります。

NodeOutputThirdNodeを指定するようにし、UAVへ出力していた部分でThirdNodeへのInput Recordを生成しています。
ここではGetGroupNodeOutputRecords命令を利用せず、GetThreadNodeOutputRecordsを利用しています。
この命令はスレッドグループごとに実行されるものではなく、スレッドごとに実行されます。
そのため、引数として渡す確保すべきレコード数はスレッドごとに変更することが可能です。
ここでは、WavePrefixSumの結果が奇数になった場合に限りレコードを1つ確保し、偶数の場合は確保しません。
確保しないので偶数時はThirdNodeのスレッドが実行されないことになります。

ではThirdNodeを見てみます。

ThirdNodeThreadノードとなります。このタイプのノードはNumThreads修飾子がありません。

UAVへの出力は基本的には変わっていませんが、SecondNodeThirdNodeWavePrefixSumを使うようにしています。

このプログラムを実行した結果は以下のようになります。

偶数は実行されていませんので、結果は0です。
奇数はThirdNodeでもWavePrefixSumが正しく動作していることがわかります。
Threadノードは単独で動作するとはいえ、GPU内部では起動するスレッドがWaveにまとめられていることがわかります。

SecondNodeで起動させなかったThirdNodeは正しく起動していません。
これはWaveGetLaneIndex関数で自身のレーン番号を取得して出力するとわかりやすいです。

奇数のスレッドだけでレーン番号が進んでいるので、起動していないスレッドは正しく排除されています。

今回、Wave Intrinsicsを使ってスレッドの起動とWaveの実行を確認していましたが、Wave Intrinsicsを使うようなことはCoalescingノードとThreadノードでは使うことがほぼないかと思います。
というか、Wave Intrinsicsを使うようなプログラムはこの2タイプでは組まないほうが良いでしょう。

しかし、パフォーマンスを考慮するとWaveの挙動は考える必要が出てくる場面もあるかと思います。
この記事で検証しているWaveの挙動はあくまでも現在のWork Graphでの動作、しかもNVIDIA GPUでの動作ということになるので、今後変更されたり、仕様が変更されることもあるかもしれません。
まだまだプレビュー機能ですので、正式版になってから動作チェックする必要があるでしょうね。

最後に

今回は事始めの2回目ということで、Work Graphの重要な項目であるノードとそのつなぎ方を学びました。
その上で2つ目以降のスレッド、スレッドグループ、Waveの関係もチェックしました。
正式版でも同様の結果になるという保証はありませんが、参考になれば幸いです。