DirectXの話 第192

Work Graph 事始め その3

24/01/14 up

Work Graph事始め記事の第3段になります。
今回で一旦Work Graphの事始め記事は終了しようかと思います。
プレビュー版じゃなくなったらサンプル用ライブラリにラッパーを実装するかもなので、それまでは多分やらないかなぁ…
グラフィクス系の機能が追加されたら定かではない。

Work Graphを使わない処理の分岐

Work Graphを利用しない場合、GPUを利用した処理の分岐は主に2つの方法を用いることになると思います。

1つは分岐命令を利用することです。
if文やswitch文を用いることでシェーダ内部で動的に分岐する方法です。
例えばシェーディングモデルごとに別のライティング処理を行いたい場合は以下のような手法を用いることがあります。

if / switch 命令は[branch]修飾子をつけることで動的分岐となります。
この修飾子を付けていない場合、シェーダは動的分岐を行わない場合があります。
動的分岐を行わない場合、上記の例であればすべてのcase文を処理したうえで、正しいcase文の結果を選択するようになります。

動的分岐の場合、シェーダはWave内のスレッドごとに処理のマスクを行います。
スレッドごとにマスクされた処理はそのスレッドでは実行されませんが、同じWave内の別のスレッドで実行される場合はその処理が終わるまで次の処理に進めません。
最悪の場合、1つのWaveですべての分岐が実行されることがありますが、運良く1つの分岐先しか実行されない場合はそこまで負荷が高くなりません。
とはいえ、分岐の利用は場合によってレジスタ数や共有メモリを多く使ってしまい、占有率が悪くなるということもあります。

もう1つの分岐方法としてはExecuteIndirectを利用したビニングがあります。
ビニングシェーダでシェーディングモデルごとにまとめ、同じシェーディングモデル単位でExecuteIndirectを行う方法です。
この方法はシェーディングモデルごとに処理シェーダを用意することになりますが、シェーダ内での動的分岐を避けることができます。
負荷が低い処理と高い処理が1つのシェーダにまとめられてしまうようなことがなくなり、各シェーディングモデルの処理が最適化されます。

問題点としては、ExecuteIndirectのための引数バッファと、データを参照するためのUAVを用意しなければなりませんし、ビニング処理が行われるまで実行されるべきシェーディングモデルの種類と数が確定できませんので、CPUから呼び出すExecuteIndirectは最大数で実行する必要があります。
特にUAVバッファの取り扱いは結構面倒なので、ちょっとした分岐であれば動的分岐で十分です。
複雑な処理を行う場合でもビニング処理を挟むことで負荷が上がってしまうこともありますので、実装する場合はプロファイルをきちんと行うほうが良いです。

Work Graphを使った処理の分岐

前回までの記事を見ていただければわかるのですが、Work Graphでは1つのノードから呼び出された別のノードは自動的にスレッドをまとめてWaveとして実行してくれます。
つまり、ノード内から呼び出す別のノードを選択できるのであれば、自動的に同じ処理がWaveにまとめられるわけです。
これはビニング処理を、引数バッファやUAVバッファを介さずに処理することができるということにほかなりません。

Work Graphならではの分岐処理は処理ごとに呼び出すノードを変更することで実現します。
この方法には2種類の方法が存在します。

1つはNodeOutputを複数持つ方法です。
この方法は分岐的な処理より、1つのノードから複数の処理を実行するというイメージのほうが大きいです。
以下はこの手法の一例です。

上のプログラムでは分岐として利用していますが、もちろんNextNodeAnotherNodeの両方を実行するようにしても問題ありません。

この方法の大きな特徴は全く別の処理を実行することができるという点です。
上記の例であれば、NextNodeAnotherNodeは全く関連性がなく、NodeLaunchの種別が違っていたり、DispatchGridの指定方法が違っていても動作します。

しかし、1つのノードからあまりにも多くのノードを実行しようとすると混乱の元になりますし、レコード数やレコードサイズなどの制限に引っかかってくる可能性があります。
呼び出し過ぎには注意が必要です。

もう1つはノード配列を使う方法です。
ノード配列はその名の通り、ノードを配列のように扱ってインデックスで呼び出しノードを切り替えることができます。
配列として利用できるノードは同じInput Recordを利用する、同じNodeLaunchであるなどの制限はあるものの、前述のライティングの処理切り替えには扱いやすいです。

使用方法は少し複雑ですので、サンプルをベースに解説していきます。
今回のサンプルも前回のものを修正したバージョンとなります。

Work Graphサンプル

まずはノード配列を作成する必要があります。
前述の通り、ノード配列は同じInput Record、同じNodeLaunchを必要とします。
他にもいくつかの制限はありますが、基本的には入り口は同じにしておく必要がある、と理解しておけばよいでしょう。

サンプルではThirdNodeがノード配列となっていますが、前回のサンプルではノード関数名がThirdNodeでしたが、今回のサンプルではノード関数名はThirdNode0ThirdNode1となっています。

今回のNodeLaunchはThreadとしています。
入力引数はInput Recordのみとなっていますので、どちらも同じThirdNodeRecordを入力しています。
処理の内部は、ThirdNode0WavePrefixSumThirdNode1WaveActiveSumを呼び出しているという程度の違いしかありません。

前回までのサンプルとの違いは関数の修飾子です。NodeIDという修飾子が追加されているのがわかるかと思います。
NodeIDという修飾子は、関数に対して別名のIDを割り当てるための修飾子です。
以前までのサンプルでは、あるノードから別のノードを呼び出す際にNodeOutputの引数名をノード関数名で指定していました。
これは正確にはノード関数名ではなくノードIDなのですが、NodeID修飾子を省略した場合はノード関数名をそのままIDとして流用します。
そのため、NodeID修飾子を用いると明示的なID付与が可能になります。
単純に別名のIDを割り当てたい場合は、以下のように指定します。

[NodeID("別名ID")]

また、NodeID修飾子は配列のインデックスを指定することもできます。サンプルではこの方法を用いています。
記述方法はIDのあとにインデックス番号を指定するだけです。

[NodeID("別名ID", インデックス番号)]

上記のサンプルコードではThirdNode0がインデックス0番、ThirdNode1がインデックス1番を指定しています。

次に呼び出し側を見てみましょう。呼び出し側はSecondNodeとなります。

このサンプルでは複数のNodeOutputも利用していますが、こちらは解説を割愛します。
一応ですが、ForthNodeはBroadcastingノードとなっており、DispatchThreadIDが0番のときのみ実行するようになっています。

注目すべきは入力引数となっているNodeOutputArrayです。
テンプレート引数がThirdNodeRecordとなっていますので、同じInput Recordでしか配列として扱えないことがわかりやすいでしょう。

また、重要なのはNodeArraySize修飾子です。
この修飾子は名前からも分かる通り、ノード配列の総数を指定します。
この修飾子を省略した場合、配列サイズは1として規定されてしまうので、配列を使用する場合は必ず指定するようにしましょう。
なお、NodeOutpuArrayのサイズの範囲外にアクセスしようとすると、Device Removeのエラーが発生しました。
ノード関数のNodeID指定に加えて、NodeArraySizeの指定とその範囲外にならないような処理を作成することが重要です。

Output Recordの確保には operator[] を使用します。その後はNodeOutputと同様の手法で確保すれば問題ありません。

結果

今回のサンプルの実行結果ですが、ForthNodeの実行はWaveの確認のまぎれとなるため、ForthNodeを実行しない場合の結果を示します。

0番開始の偶数インデックスのスレッドはThirdNode0が、奇数インデックスのスレッドはThirdNode1が実行されるようになっています。
ThirdNode1WaveActiveSumを実行するため、すべて同じ結果となっています。
各スレッドのValueの値をチェックしてもらえればわかりますが、ThirdNode1の結果のみを合算しているものになっているのは間違いありません。

単にThirdNode0が呼び出されているスレッドだけ非アクティブになっている可能性もあるので、どちらのノードでもWaveGetLaneIndexを用いてスレッドインデックスを取得して出力するようにしてみた結果が以下です。

どちらのノードでも同じスレッドインデックスが取得されているので、別のWaveで動作していることがわかります。
ノード配列を使っても、複数のNodeOutputを利用しても、ノードが同じものをまとめてWaveで実行してくれますので、引数バッファやUAVを利用するより簡単にビニングができるでしょう。

ただ、実際のWork GraphがExecuteIndirectより高速に動作するかという点についてはきちんとプロファイルを取ってみないとわかりません。
正式版になったら、VisibilityBufferのマテリアル処理なんかに利用してパフォーマンスが有利かどうかは確認してみたいところです。

最後に

Work Graphはまだまだプレビューではありますが、現段階でもDispatch命令限定で使用用途がありそうに感じます。
といっても、正式版になっても同じように動作するのか、APIに変化はないのかなどわからない部分も多々あります。
しかし、次世代のGPU駆動レンダリングの機構としては非常に興味深いものです。
今後に期待しましょう。