DirectXの話 第172回

Amplification ShaderとWave Intrinsic

20/08/30 up

ちょっと難しいGPUの話

GPUはCPUと比べて簡単な処理を大量に実行するのに向いている、とはよく言われますが、それは単純にコア数が多いからとかそういうことだけに起因する話ではありません。

今回の話はその一部の機能を理解していないと理解しにくい内容なので説明しないとなぁ…とか思ってたんですが、@shikihuiku さんがとてもわかりやすい解説をブログに上げてくれています。

HLSLのWave Intrinsicsについて

ざっくりとまとめてしまうと以下のようになります。

例えば、Turing アーキテクチャのGPUで50頂点を処理しようとする場合、50個のThreadが起動するのではなく2Wave/64Threadが起動することになります。

現代のGPUはこのように処理を1まとめにして行うことで高速化を実現しています。

このように考えると分岐が重いと言われる理由もわかるのではないでしょうか?

プログラムがどこまで進んだか?という指標は、通常 プログラムカウンタ(PC) と呼ばれるカウンタが担います。

CPUのThreadは通常、このPCがThread単位で存在しているため、同じ処理を複数Threadで処理しても分岐によって通る道筋は変わります。

これに対してGPUはWave単位にPCを持っているため、Threadごとに分岐の道筋が変わったとしてもどのThreadもすべての分岐の道を通ります。

ただしshikihuikuさんの記事にもある通り、通過はしますが処理はしません。

例えるなら、1台の車で4人が旅行して、それぞれが見たい観光名所が異なった場合、その場所が見たい場所だった人は見に行く、それ以外の人は車で待機する、というような感じでしょうか?

CPUの場合は出発地点と終了地点は同じだけど4台の車でそれぞれが見たい観光名所に移動する、というようなものかな。

なお、NVIDIA社のVolta, TuringアーキテクチャGPUはThread単位でPCを持っているらしいです。

ということは分岐なんかはCPUと同じように分岐してくれるのかもしれませんが、そのへんは定かではありません。

そのような動作をする場合、Wave Intrisicがどのように動作するのかとかがよくわからなくなりますね。

ただ、Wave単位でThreadが起動すること自体は同じらしく、Wave内のすべてのThreadが終了しなければWaveは終了しないという部分だけは変わらないようです。

Volta, TuringアーキテクチャのPCについては偉い人に教えてもらいたいですね。

Amplification Shaderとは?

今回の話題の1つであるWave Intrinsicについては前述のブログが助けてくれるので実例部分でのみ解説を行いますが、もう1つの Amplification Shader についてはここで解説します。

名前が長くて打ち込みが面倒なので、以降はAmp Shaderと記述します。

前回サンプルに実装したMesh ShaderはVertex Shader, Tessellator, Geometry Shaderを1つにまとめたようなシェーダです。

このシェーダはメッシュレット(Meshlet) という単位に分割されたクラスターごとに1つのグループを割り当てて処理を行うようなものでした。

Amp ShaderはこのMesh Shaderの前段で実行されるシェーダで2つの主要な機能を備えています。

1つはMesh Shaderを起動する機能です。

これはかなり画期的な機能で、GPU側から完全なDraw Callに近い描画命令を行える機能と言えます。

これまでのGPU駆動レンダリングでは Draw Indirect 命令を用いていました。

Draw Indirect はDraw Callする際に設定するプリミティブ数やバッファのオフセットなどをGPUから指定するというものではありますが、Draw命令自体はCPU側から行っています。

これに対して、Amp Shaderには DispatchMesh() 関数という、そのままDraw Callな組み込み命令が存在します。

つまりAmp ShaderからMesh Shaderの起動を制御できるというわけです。

もう1つの機能はその起動するMesh Shaderに対してパラメータを送ることが出来るというものです。

まあ、Vertex Shaderも後段のGeometry ShaderやPixel Shaderに頂点出力という形でデータを渡せるので似たようなものですが、Amp Shaderが渡せるペイロードは起動したMesh Shader全てに同じ値が送られることになります。

主な使い方は今回のサンプルのようにカリングされない、つまり描画されるべきメッシュレットのインデックスを渡すとか、インスタンス描画を行った場合のインスタンス番号を送るとかでしょうか。

基本的にはペイロードのサイズは小さくしておいたほうがいいはずですが、最大サイズとしては16kバイトまでです。

注意点としては、Mesh Shaderの出力は32kバイトであることを前回も解説しましたが、Amp ShaderからMesh Shaderを起動する場合、ペイロードサイズとMesh Shaderの出力サイズの合計は47kバイト以内である必要があるようです。

両方の最大サイズを単純に加算すると48kバイトなのですが、そのサイズをフルには使用できないと考えておきましょう。

Amp Shaderの実装を見てみる

ではサンプルの解説を行います。

いつもどおりにGitHubにサンプルはアップ済みです。

D3D12Samples

今回のサンプルは前回と同じSample024です。Amp Shaderによるメッシュレットカリング処理を追加する形にしています。

まずはAmp Shaderの主要部分を見てみましょう。

メイン関数の一番下で DispatchMesh() を使ってMesh Shaderを起動しています。

この関数の第1~3引数はCPU側で実行される DispatchMesh() 関数の第1~3引数と同じです。

そのため、visible_count にはこのスレッドグループでFrustom Cullingによってカリングされなかった、描画されるべきメッシュレットの数を指定しています。

第4引数はCPU側関数にはないもので、これがMesh Shaderに渡されるペイロードとなります。

今回のサンプルではペイロードは描画すべきメッシュレットのインデックス番号を渡しています。その最大数は LANE_COUNT_IN_MESH で定義されています。

この値は1Wave内のThread数を指定しますが、本サンプルではNVIDIA社の32に固定しています。この値であればAMD社のRDNAでも問題なく動作しますが、GCNは64Threadなのでちょっと効率が悪くなるかもしれません。

さて、LANE_COUNT_IN_MESH はこのシェーダのスレッドグループ数としても設定されています。NumThreads の値がそれです。

スレッドグループというのはWaveとはちょっと違っていて、スレッドグループ内で共有メモリをやりくりしたり、同期をとったり出来ます。

例えばNVIDIA社GPUで64スレッドグループを使用した場合、2Waveが起動してこれらが共有メモリを共有することになります。

基本的にはスレッドグループはWaveのスレッド数に合わせたほうが効率が良くなります。

今回のサンプルでWaveのスレッド数に合わせているのはそれ以上にWave Intrinsicが重要になるからです。

上記のコードではWave Intrinsicは2つ使われています。WavePrefixCountBits()WaveActiveCountBits() です。

WavePrefixCountBits() 関数は同一Wave内のvisibleの値をチェックし、自身のスレッド番号より前のスレッド番号までのvisibleがtrueである数をカウントするという命令です。

例えば5番スレッドが実行したこの命令は、0~4番までのスレッドが実行したこの命令の段階でのvisibleの値が何個trueであったかを計数します。

0番と3番のみがtrueであったなら、5番スレッドのこの命令は2を返します。

こうすることで表示すべきメッシュレットインデックス番号を配列に詰めて登録することが可能というわけです。

もう1つの WaveActiveCountBits() 命令は、この命令が呼び出された段階でのすべてのスレッドのvisibleがtrueであった個数を計数します。

つまりこの命令はスレッド番号は無関係にすべてのスレッドで同じ値が返ってきます。

この段階ではメッシュレットインデックス番号が詰めて設定されていますので、描画すべきメッシュレットの数を DispatchMesh() 関数に渡せればOKということになります。

ちょっとここで疑問が湧くかもしれません。

このメイン関数はスレッド数分、つまり今回のサンプルでは32スレッドが動作します。

つまり DispatchMesh() 命令は32回呼ばれているのではないか?

では、0番スレッドだけで1回だけ呼ばれるようにしてみましょう。

さて、結果は?というと、コンパイルエラーとなりました。

この命令はすべてのスレッドで同じように発行される必要があるようです。

もう1つ。

WavePrefixCountBits() 関数は分岐内部で処理されています。

この分岐が動的分岐でなければ命令自体は呼ばれている可能性がありますが、動的分岐となる[branch]を記述した場合はどうなるでしょう?

結果としては特に問題なくコンパイルも通り、動作もしました。

呼ばれているのか、呼ばれていない場合はfalse扱いとなるのかが判然としません。

ではちょっと意地悪な手法も試してみます。該当の分岐を以下のように変更してみます。

この変更では WavePrefixCountBits() 関数によって、visibleがfalseのものを計数するようにしました。

もちろんそれをそのまま index としては使用できないので、WaveGetLaneIndex() 関数で現在のスレッド番号を取得するようにして、そこから減算するようにしました。

この結果はどうなったか?というと、コンパイルは正常に動作しました。

ただし動作は正常ではありませんでした。描画されるインデックスがおかしくなっているような動作でした。

なお、index 計算部分だけ分岐の外に出したところ、正常に動作しました。

このことから、分岐によって呼ばれなかった WavePrefixCountBits() 関数はfalse扱いとなって計数されないのではないかと考えられます。

Mesh Shaderの変更点を見てみる

Mesh Shaderの前回からの変更点はカリング処理がなくなったことと、冒頭のメッシュレットインデックスを取得する部分となります。

以下はその主な修正部分のみを抜粋したコードです。

まず、メイン関数の引数としてペイロードが入力されています。これはAmp Shaderから与えられたものですね。

そしてメッシュレットインデックスが SV_GroupID の値をそのまま使うのではなく、その値からペイロードの MeshletIndices を参照するようにしています。

以降の頂点、インデックス処理は変更がありません。

メッシュレットカリングくらいの処理であれば修正点は小さいと言えます。

C++呼び出し部分の変更点を見る

C++側の呼び出し部分にも変更が加えられています。

本サンプルはAmp Shaderでのカリングと、前回のMesh Shaderでのカリングのどちらかを選択できるようにしています。

これはプロファイルを取りやすいようにという理由からですが、呼び出し側がどのように変更されているのかもわかりやすいと思います。

該当箇所は以下のように変更されています。

isEnableAmp_ がtrueの場合はAmp Shaderを使用します。

Mesh Shaderのみの場合は第1引数にメッシュレットの数をそのまま指定しますが、Amp Shaderの場合は1スレッドグループのAmp Shaderが最大で32個のメッシュレットに対応できるため、dispatch_count には上記のような計算を行っています。

メッシュレットが50個なら2で呼び出される、という感じです。

Mesh Shaderのみで実行するか、Amp Shaderも利用するかによってこの部分が変化する可能性は十分あります。

この点には注意が必要ですね。

Amp ShaderとMesh Shader、どちらでカリングするべきか?

さて、今回Amp ShaderとMesh Shaderでカリングを行ってみたのですが、計測結果としては基本的にAmp Shaderでのカリングに軍配が上がりました。

仮に、すべてのメッシュレットがカリングされないという状況であればもしかしたらAmp Shaderを使った方が低速になるかもしれませんが、カリングが十分に期待できるのであればAmp Shaderを使用したほうが良いでしょう。

なぜそうなるのか?

Amp Shaderを使った場合、C++からのAmp Shaderの起動と、Amp ShaderからのMesh Shaderの起動がオーバーヘッドとなる可能性はありますし、ペイロードの配布もそれなりにコストがあるかもしれません。

カリングするならMesh Shaderで行ったほうが単純ではないか?と考えてもおかしくないでしょう。

しかし、カリングが十分に効いてくるということを考えると、Amp Shaderを使った方が有利になることは自明です。

まず、Mesh Shaderのスレッドグループ数を見てください。サンプルでは128を指定しています。

1Waveは32Thread(NVIDIA社の場合)なので、Mesh Shaderを1つ起動すると4Waveが起動することになります。

スレッドグループ内の0番スレッドでカリング処理を行い、スレッドグループの同期を取り、カリングされればそれ以降の処理のほとんどはスキップされます。

つまり、4Waveは起動したけど実際にはほとんど何もせずに終了するという状態になるわけです。

もしもWaveの起動がノーコストで行われるならそれでもいいでしょうが、実際にはコストが掛かります。

Amp Shaderの場合はどうでしょう?

Amp Shaderはメッシュレット32個につき1スレッドグループが起動します。スレッドグループ数は32なので、起動は1Waveだけです。

このメッシュレット32の内、たった1つだけがカリングされたとしたらどうでしょう?

31個のMesh Shaderが起動するわけですが、Wave数は4*31=124です。これにAmp Shaderの分を追加しても125Waveとなります。

直接Mesh Shaderを32個分起動すれば4*32=128Waveです。これだけでもWave数が少ないのがわかります。

このカリング個数が半分だったら?4*16+1=65Waveです。それこそ全部カリングされるようなら1Waveの起動だけです。

少なくとも起動Wave数を考えるとAmp Shaderを使った方が圧倒的に有利となります。

ただし、GPUはWaveの起動数だけが全てではありません。そこで処理される内容やテクスチャサンプリング、使用するレジスタの数など様々な要因が関係してきます。

そのため、Wave数は1つのパフォーマンスの指針でしかない、というところを考慮しておいてください。

とはいえ、パフォーマンスを上げる最大の要因は処理数を減らすことです。Waveを減らすことは十分パフォーマンスに寄与するのではないでしょうか?

まとめ

Amp Shaderはちょっと特殊でわかりにくい部分もありますが、Mesh Shaderを使っていく上では必須のシェーダとなると思います。

そしてこれを使う場合はWave Intrinsicを使って同期をなくすことも重要になってくるのではないかと思います。

もしもMesh Shaderで同期をする必要が出てきた場合、Amp Shaderで対応できないか考えてみるとよいのではないかと思いました。