2023年5月1日月曜日

DirectX12 Multithread対応の続き

以前マルチスレッド対応をしたが、今回はその続き。

マルチスレッドに対応する際、参考にしたのがマイクロソフトのサンプルだけど、このサンプルって実は特殊なのでは無いかと思った。

複数のコマンドリストを用意して、それぞれを担当するスレッドがコマンドリストにコマンドを追加していく。
これはある1シーンのためにカスタマイズされた専用のプログラムで使う分には問題ないし効果も高そう。

ただ、自分が作っている汎用的な3Dエンジンの場合、ちょっと無理があるかもしれないと思い始めた。


今までは1つのコマンドリストにシーケンシャルにコマンドを追加していた。
必要なパラメータを順番に追加していけば描画はされた。

新しくマルチスレッド対応になった場合、シングルスレッドで追加するか、マルチスレッドで追加するかを切り替えられるようにした。シングルスレッドの場合は今までと同じ使用感。
マルチスレッドにした場合、最初のコマンドリストだけで実行するコマンド、全部のコマンドリストで同じ実行が必要なコマンド、パラレルに設定できるコマンドなど呼び側でかなりいろいろ考慮が必要で、投入コマンドがかなり多くないと寧ろ遅くなる結果になっていた。


DX12 Do's And Don'ts


nVIDIAのサイトでこんなドキュメントを見つけた。
全てきちんと理解できたらかなりクオリティが高くなりそうだけど、読み取れた一部でも改良していきたい。


ExecuteCommandListsとFence


以前コマンドリストのラッパを刷新した際、それまで対応してなかったReadbackも対応した。ExecuteCommandListsを呼び出したら、その後メインスレッドorクロージャスレッドでその実行完了を待つ。Readbackデータ読み取りたい場合は、メインスレッドで待ってその後参照する。読み取り不要な場合はクロージャスレッドで終わった段階で各種コマンドの開放を行うようにしていた。この時、フェンスを立てることになるが、このフェンスがパフォーマンスに多大な影響を与えてしまう。
PIXで見るとExecuteCommandLists単位で、隙間が開いている。
これが普通なのかと思っていたけど、全くダメな作りだった。


PSOの切り替え


シェーダが同じものをまとめて描画し、ExecuteCommandListsを呼び出していた。
それしかできないと思っていた。
勘違いしていたのが、Reset関数にPSOを指定するからなんだけど、Resetのタイミングで指定するのだから切り替えにはまたResetする必要があるのだろうと。だが実は違った。
SetPipelineStateという関数があり途中から設定できる。設定できるだけでなく切り替えも出来る。
これはnVIDIAのドキュメントに書いてあるわけではないけど、読んでいるうちにそういう事が出来るのだろうと、リファレンスを調べたらあった。
SetPipelineState、Set[Graphics|Compute]RootSignatureを呼べば途中でシェーダの切り替えも出来ることを知った。


コマンドリストは15~30


コマンドリストは15~30以下と書かれていて驚いた。
これをみて今までの考え方が根本から間違っているのではないかと思った。
シェーダを切り替える度にコマンドリストも切り替えて行くといいのではないか?
同時に複数のコマンドリストを扱うのではなく、常に1つのコマンドリストに対して追加し、切り替えのタイミングで次のコマンドリストに移る。ある程度コマンドリストが溜まったらExecuteCommandListsを実行する。


ドキュメントではすべてのコマンドリストを用意してから最後にExecuteCommandListsというのもだめパターンに書かれている。5回~10回に分けて、CPUとGPUが並行処理していく状態を作るのが望ましい。


マルチスレッド対応した際、コマンドリストに投入したいデータが出揃ったタイミングで、一斉にスレッドに追加させていた。
これもマイクロソフトのマルチスレッドのサンプルを最初に見てしまった弊害。
追加する度に別スレッドで並列にコマンドリストにも追加するように作り変えた。
全部のコマンドを作っていざ実行となったときに、今まではこのタイミングから追加し始めていたのが、裏で作るのと同時にコマンドリストに追加をしているため、待ち時間が短縮できた。

描画全体の時間(デバッグ)
揃ってからスレッド実行 :0.0958秒
追加と同時にスレッド実行:0.0707秒

描画全体の時間(リリース)
揃ってからスレッド実行 :0.0642秒
追加と同時にスレッド実行:0.0398秒

ただ残念なことに、マルチスレッドにせずメインスレッドで直接コマンドリストに追加した場合は下記となり、マルチスレッドが意味ないと思わせる結果となった。
デバッグ:0.0618
リリース:0.0289
コマンドリストに追加する重たい処理が少ないケースだからか?
改良版はそこまでシングルスレッドに負けてないので、マルチスレッドが勝つケースもあると信じる。


比較


シェーダを切り替える度にExecuteCommandListsを呼び出していたのを、まとめて1回で実行したものの比較。


シェーダ切り替え毎にExecuteCommandLists実行

Z Pre-passからDeferredRenderingまでをまとめて実行

修正前は、塊の間隔が空いていて無駄に待ってしまっている。全体は343usかかっている。
修正後は、まとめた部分が並列で動くようになっており、全体は297usに短縮している。
PIXでイベント名をつける単位がExecuteCommandListsの実行単位なので、今まできれいに分類されていたのが、1まとめになってしまったけど、そんなことはどうでもいいくらいにパフォーマンスがアップした。

拡張バリアにした結果、400us位かかっていたのが、350usぐらいになって、コマンドリストの実行をまとめた結果、350usから300usぐらいになった。
後半のEmissive部分がまだまとめる余地が残っているのでもう少し削れそう。








2023年4月30日日曜日

Enhanced Barriers

新しいバリアが使えるようになったので、ライブラリを書き換えてみた。


D3D12_RESOURCE_FLAG_ALLOW_SIMULTANEOUS_ACCESS


新しいバリアの説明を読んで、このフラグの存在を知った。
勝手に状態遷移をして、その条件が複雑で分かりづらいと古いバリアの欠点として書かれていた。

今まではこのフラグに頼らず、自前でリソースの読み込みと書き込みが切り替わるポイントで、自動的にリソースバリアを挿入するようにしていた。
だけどこのフラグを使うと一部のリソースを除いて、自動的に遷移してくれるし便利そう。

新しいバリアでも使えるみたいなのでこのフラグを立ててすべてのバリア処理を消してみた。


バッファ


まずリソースの生成関数が変わり、CreatePlacedResource2になった。
以前すべてのリソースをCreateCommittedResourceから、CreatePlacedResourceに変えたけど、今回はCreatePlacedResource2に変わる。
引数の変更点は、D3D12_RESOURCE_DESCからD3D12_RESOURCE_DESC1に変わる(中身はSamplerFeedbackMipRegionが増えた)のと、初期のリソースステータスを渡していたのを、初期のD3D12_BARRIER_LAYOUTを渡すようになったこと。

D3D12_BARRIER_LAYOUTでいうと、バッファに使うのはD3D12_BARRIER_LAYOUT_UNDEFINEDでいいらしい。
前の場合は、UploadとReadbackの場合は、D3D12_RESOURCE_USAGE_GENERIC_READで、それ以外はD3D12_RESOURCE_USAGE_COPY_DESTで始めていた。

そして、D3D12_RESOURCE_FLAG_ALLOW_SIMULTANEOUS_ACCESSを指定することによって、バッファの場合は遷移を何もする必要がない。
書き込む時にD3D12_RESOURCE_STATE_GENERIC_READにして、コピーする時D3D12_RESOURCE_STATE_COPY_SOURCEにして、使うときにそれぞれのステータスにわざわざ遷移させていたけど、新しいバリアでは?それともD3D12_RESOURCE_FLAG_ALLOW_SIMULTANEOUS_ACCESSのフラグのお陰で?何もしなくて良くなった。

というわけでバッファについてはバリア処理が0になった。

テクスチャ


テクスチャの方はバッファのようにバリア0にはならなかった。
ただ、単純なファイルから読み込んだテクスチャはバッファ同様バリア0で行ける。
初期のレイアウトはD3D12_BARRIER_LAYOUT_COMMONで、後は勝手に遷移してくれる。
自分で制御しないといけないのは、D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGETとD3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCILを指定する場合で、このフラグを付ける場合はD3D12_RESOURCE_FLAG_ALLOW_SIMULTANEOUS_ACCESSが付けられない。なので、主にこのリソースについて、書き込み時とテクスチャ参照時にバリアを使うことになる。


グローバル


新しいバリアの3つ目のグローバルバリアについてはよくわからない。

Global barriers control cache flush and synchronization for all indicated resource access types in a single command queue. Global Barriers have no effect on texture layout. Global Barriers are needed to provide functionality similar to legacy NULL UAV barriers and NULL/NULL aliasing barriers.

説明にはこう書かれているが、全く理解できない。とりあえずは置いておく。


中間レンダーターゲット


中間レンダーターゲットはD3D12_BARRIER_LAYOUT_RENDER_TARGETスタートで、テクスチャとして使う場合に、D3D12_BARRIER_LAYOUT_DIRECT_QUEUE_SHADER_RESOURCEに変える。


深度バッファ


深度バッファはD3D12_BARRIER_LAYOUT_DEPTH_STENCIL_WRITEスタートで、テクスチャとして使う場合に、D3D12_BARRIER_LAYOUT_DIRECT_QUEUE_SHADER_RESOURCEに変える。


レンダーターゲット


レンダーターゲットは直接リソースを作らないけど、LayoutBeforeを設定するために初回のレイアウトはD3D12_BARRIER_LAYOUT_PRESENTを設定しておく。
書き込む際はD3D12_BARRIER_LAYOUT_RENDER_TARGETに変える。

新しくAccessBefore/AfterとSyncBefore/Afterが増えた。
この指定がいまいち理解できていないが1つわかった事がある。


実際には良くない例だけど、Presentを呼ぶ前にレンダーターゲットの遷移のみのExecuteCommandListsを実行していた。
この時、下記のように指定。
 AccessBefore=D3D12_BARRIER_ACCESS_RENDER_TARGET
 AccessAfter=D3D12_BARRIER_ACCESS_COMMON
 SyncBefore=D3D12_BARRIER_SYNC_RENDER_TARGET
 SyncAfter=D3D12_BARRIER_SYNC_ALL
特にSyncAfterは何を指定していいのか全く検討がつかない。

実行してみると、デバッグレイヤーにはこんなメッセージが出ていた。

D3D12 WARNING: ID3D12CommandQueue::ExecuteCommandLists: ExecuteCommandLists references command lists that have recorded only Barrier commands. Since there is no other GPU work to synchronize against, all barriers should use AccessAfter / AccessBefore = D3D12_BARRIER_ACCESS_NO_ACCESS and SyncBefore / SyncAfter = D3D12_BARRIER_SYNC_NONE. This information can be used as an optimization hint by some drivers. [ EXECUTION WARNING #1356: NON_OPTIMAL_BARRIER_ONLY_EXECUTE_COMMAND_LISTS]

バリアのみのコマンドリストは何も同期するものも無いからD3D12_BARRIER_ACCESS_NO_ACCESSとD3D12_BARRIER_SYNC_NONEをBefore/Afterに設定すればいいとのこと。
なるほど、今までExecuteCommandListsの枠を超えて、その前のコマンドも含めて考えないといけないのかと思っていたけど、今回の実行単位の中だけで考えればいいということがわかった。

コマンドリストの先頭で遷移させる場合はBeforeにD3D12_BARRIER_ACCESS_NO_ACCESSとD3D12_BARRIER_SYNC_NONEを設定出来るし、次の為にコマンドリストの最後でLayoutだけ変えておくような場合、AfterにD3D12_BARRIER_ACCESS_NO_ACCESSとD3D12_BARRIER_SYNC_NONEを設定する事が出来る。


UAV


テクスチャをロードした後、ミップマップを自動でつくるときにミップマップの階層分UAVを用意して、1つ上の階層を参照して、下の階層を作るというのを順番に行う。
この時PIXのヒントに出ていたのは、「UAVバリアが要らなさそうだから消してみれば?」という提案。
実際に消すと、同期が取れていないのでテクスチャがきちんと書き込まれなくなって真っ黒のテクスチャが出来上がる。
新しいバリアではUAVバリアというものが無くなったので、どうするのかと思ったらドキュメントには下記のように書かれていた。
 LayoutBefore = D3D12_BARRIER_LAYOUT_UNORDERED_ACCESS
 LayoutAfter = D3D12_BARRIER_LAYOUT_UNORDERED_ACCESS
 AccessBefore = D3D12_BARRIER_ACCESS_UNORDERED_ACCESS
 AccessAfter = D3D12_BARRIER_ACCESS_UNORDERED_ACCESS
 SyncBefore = D3D12_BARRIER_SYNC_COMPUTE_SHADING
 SyncAfter = D3D12_BARRIER_SYNC_COMPUTE_SHADING
実際にやってみると自分が使っている場面ではエラーがでて、レイアウトを両方ともCOMMONに変えたらうまくいった。
PIXで確認してみたところ、古いバリアの場合各階層ごとに待ちが発生して結構時間がかかっていたけど、新しいバリアではその待ちが消えていた。結果全体で200usぐらいかかっていた処理が150usぐらいに短縮された。新しいバリア恐るべし。


今回の修正でリソースバリアをライブラリで挿入していた処理を一切なくして、今のところ必要な場面で自分で指定する形に変えた。
もう少し新しいバリアを理解して、このまま行くのか、自動で挿入するかを見極める。


2023年4月24日月曜日

DirectX12 Agility SDK

Agility SDKをインストールはしたけど、結局使えなかった。
ID3D12Device10を指定して、D3D12CreateDeviceをしてもエラーになる。

ここを見つけて、ついにID3D12Device10のCreateに成功した。


NuGetでのインストールは簡単で、NuGetパッケージマネージャから「DirectX agility」で検索してインストールするだけ。


現在の最新は1.610.2で、1.7を使いたい場合は上部の「プレリリースを含める」をチェックしないと選択できない。

インストール後、ビルドするとexeの場所に「D3D12」というフォルダの中に、「D3D12Core.dll」と「D3D12SDKLayers.dll」が配置される。
Agility機能を使ったプログラムをリリースする場合はこの「D3D12Core.dll」を一緒に配布すると、配布した「D3D12Core.dll」を使ってくれるけど、OSのアップデートが進んでSystem32の下の「D3D12Core.dll」の方が新しくなると、新しい方の「D3D12Core.dll」を読み込むようになっているらしい。


肝心のD3D12CreateDeviceだけど、実行するとエラーになる。
実行時のコンソールを見ると、System32の方の「D3D12Core.dll」が読み込まれている。
先程のページを読み進めると、「D3D12SDKVersion」と「D3D12SDKPath」をExportする必要があると書いてある。

extern "C" { __declspec ( dllexport ) extern const UINT D3D12SDKVersion = 710 ; }
extern "C" { __declspec ( dllexport ) extern const char * D3D12SDKPath = R"(.\D3D12\)" ; }

書いてある通り宣言してみる。
でも、System32の方が読み込まれる。
宣言する場所は3DライブラリのDLLのソースに記述していたのを、Exeのソースに移すと直下のD3D12のDLLが読み込まれるようになった。

これでID3D12Device10のCreateに成功して、CheckFeatureSupportのD3D12_FEATURE_D3D12_OPTIONS12も正常に返るようになった。

ついに拡張バリアを試すときが来た。



Flustum Culling 影考慮版

視錐台カリングを以前作ったけど、不具合があることに気がついた。

オブジェクトのカリングと影の描画が連動しているため、カリングされてしまうと影の描画も無条件でやめてしまう。
影の位置が離れている場合や長く伸びている場合、オブジェクトの描画をやめても影の描画はしないといけない場合もある。

影の描画がなくなっている

正しい描画

アンリアルエンジンのサイトでオブジェクトをカリングした状態でも影描画している様に見えた。影はカリングに関係なく無条件に描けばいいのか?

とりあえずその実装をしてみることにしたが、単純にはできなさそう。
現状メッシュ描画の際、オブジェクトのインスタンス単位にWorld Matrixのバッファを用意して、バッファの個数をDrawIndexedInstancedのインスタンス数に指定することで描画している。カリングされなかったものだけバッファを詰めて、インスタンス数を減らして指定する。
このため、単純に影用のバッファを含めてしまうとカリングもできなくなってしまう。

そこで考えたのが、バッファを2重にする方法。
カリングされないバッファの後ろにカリングされた影描画分を詰める。
影描画は全体の数を使って描画し、それ以外はカリングされていない数を使って描画する。
これにより1つのバッファでカリングと全体描画が出来るようになった。

出来るようにはなったけど、これだとパフォーマンスの問題が残る。
広いエリアで全オブジェクトの影描画が行われるとまずいだろう。

視錐台カリングの影考慮を検索してもなかなかヒットしなかったが、いろいろ試していく中で1つ有力な、目からウロコの情報が手に入った。
現状、メインカメラで視錐台カリングを行っているが、影描画用のライトをカメラに見立てて、その視錐台でカリングを行うというアイディア。
全然情報が出てこなかったけど、実は常識?


カスケードシャドウ対応視錐台カリング


カスケードシャドウを行う際、カスケード数分視錐台を準備している。
この視錐台をそのまま視錐台カリング処理に流し込めば、影描画の範囲を絞り込める。
また、カスケード数が4だとして、4回影描画を行うことになるがそれも狭い範囲から段々と数を変えて描画出来るようにする。

左がメインカメラ 右がサブカメラで上からの視点

まず、メインカメラの視錐台でカリングを行う。この結果をほとんどの描画では利用する。
次にカスケードシャドウ用の視錐台を範囲の狭い方から含まれるオブジェクトのチェックを行う。チェックの対象はメインカメラでカリングされたものだけ。
手前から赤、黄緑、青、黄色と影の範囲が広くなっていく。
それぞれの範囲内に含まれるオブジェクトにカリングレベルを設定していく。

影描画の際、今までは全部のカスケードレベルで同数のメッシュ描画を行っていたけど、カリングレベルを使って、その範囲に含まれるメッシュだけを描画するようカスケードシャドウ自体のパフォーマンスにも考慮させた。

左上の3✕3の物体と影

メインカメラで物体はカリングされても、影は残っている





2023年4月23日日曜日

DATA_STATICなTexture

テクスチャを読み込んでミップマップを自動的に作るときは、UAVを経由で書き込むためD3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESSのフラグを付けてリソースを作っていた。

ただ、リファレンスにはUAVを殆ど使わない場合はコピーして使うのを検討したほうがいいと書いてあり、このフラグのコストが高いことが伺える。

このケースでは確かにUAVは最初のミップマップ生成の為だけに使っておりUAV無しのリソースにコピーしたほうが良さそうだ。

毎フレームレンダーターゲットのミップマップを作ってテクスチャとして利用するような場合は、UAV付きのままで使っても良さそう。
実際の処理時間を計測してみるとコピーした方が20~30usぐらい時間が伸びていて、「UAV付きテクスチャを参照のコスト < Copy+UAV無しテクスチャ参照のコスト」になる為、毎フレームコピーするのは逆にもったいない結果になっていた。


CopyTexture


CopyTextureコマンドにサブリソース番号を追加してミップマップの各階層もコピーできるように拡張し、要求側ではミップマップの階層分ループしてコマンドを発行するように修正。

今まではサブリソースインデックス0固定だったものをインデックス指定に変えただけなので特に問題なくできた。


CreateTexture


テクスチャリソース作成関数内でテクスチャをロードしてバッファをコピーするとき、一時的なUAV付きリソースにコピーするように変更。
そのリソースでミップマップを生成して全て出来上がったら、全体をCopyTextureしてUAV付きのリソースは破棄。


実行して試してみたところ問題なく動く。
ただいつもこういった修正後に、デバッグ実行するといつもデバッグレイヤーに何かが表示されている。

今回もやっぱりエラーが出ていた。

D3D12 ERROR: ID3D12CommandQueue::ExecuteCommandLists: Resource(0x000001C4E398C710:'Mesh<MeshAlbedoTex1>') (subresource : 8) is bound as DATA_STATIC on Command List 0x000001C4E34C5510:'DirectCL1'. Its state was changed by a previous command list execution which indicates a change to its data (or possibly resource metadata), but it is invalid to change it until this command list has finished executing for the last time. [ EXECUTION ERROR #1002: DATA_STATIC_DESCRIPTOR_INVALID_DATA_CHANGE]

イマイチよくわからないけど、テクスチャのコピー完了を待たずに次々に処理しているからかと思って、完了を待つようにしたりしたが状況は変わらず。

このメッセージが出力されるタイミングはテクスチャ読み込み時ではなく、テクスチャを描画するタイミングで出力されている。また、一旦エラーが出ると毎フレーム出ることが普通なのに今回のケースでは、最初の1回だけで以降は出ていない。
描画タイミングまでコピーが終わってないと言うのは考えられないし何のことを言っているのか再考してみると、DATA_STATICというのと、最初の1回だけ出力されるというのがヒントになった。

シェーダのルート署名では、純粋なテクスチャの場合DATA_STATIC、レンダーターゲットなどをテクスチャとして使う場合は、DATA_STATIC_WHILE_SET_AT_EXECUTEを指定している。

テクスチャのその前のステータスはCOPY_DESTになっている。
今までUAV付きのテクスチャではこのエラーは出ていなかったが、今回UAVなしになったことでDATA_STATICの恩恵を受けられるようになったからなのか、チェック対象になって怒られるようになったとか?

リソースの遷移は、はじめてライブラリを作った頃はマイクロソフトのサンプルの通り使う状態に遷移させて元に戻すということをしていたが、今は使う場面の状態に合わせて遷移させてそのままという風にしている。そのため、各リソースの作成直後はだいたいCOPY_DESTのままになっている。

DATA_STATICを宣言しているシェーダの実行中にテクスチャの状態遷移を行ってしまっているのがだめだと予想して、コピー直後にステータスの遷移まで完了させてみた。こうすることで以降このテクスチャに対して状態遷移は発生せず、まさにSTATICって感じになる。

この状態で試してみるとエラーは消えて動作するようになった。


2023年4月20日木曜日

DirectX12 Multithread対応

いままでライブラリを構築してきたけど適当なデータ試した感じでは、シングルスレッドでなんの不満もなかった。ゲームの世界のスピード感覚が無いので、マイクロ秒、ナノ秒単位で処理が終わってるし十分速いと思っていた。
また、マルチスレッドといっても、CPUとGPUで別々に動いている時点でマルチプロセッサになってるし、GPU内に処理を任せると内部の大量のスレッドで処理をしてくれるため、何をスレッド化するのかよくわからない。

DirectX12ではマルチスレッドにしないと、DirectX11に比べて寧ろ遅くなるという情報を見つけた。
気になったので、マイクロソフトのサンプルD3D12Multithreadingを確認してみた。


D3D12Multithreading


このサンプル内ではワーカスレッドを3つ用意して、それぞれのスレッドに影とシーンの描画のため、コマンドリストにDrawIndexedInstancedを追加させていた。
1つの頂点バッファにシーン内のオブジェクトがすべて含まれているようで、それぞれのマテリアル単位でDrawコマンドを追加しているっぽい。
1シーンに必要なDrawコマンドの発行回数が1024×2(影とシーン)となっていた。

これだけのDrawコマンドを発行する場合、1スレッドで追加するのと複数スレッドで追加する場合で処理スピードが変わってくると言うことか。
今は数千ポリゴンのメッシュを6回に分けて描画しているだけだったので大して問題を感じていなかったけど、実際のゲームのシーンになってくると段々コマンドリストへの追加処理自体が問題になって来るのだろう。

というわけで、ライブラリをマルチスレッド対応することにした。


コマンドリスト


マルチスレッド化するにあたり、コマンドリストの配置場所が問題になる。
今まではシェーダクラス内にコマンドリストとアロケータを用意して、シェーダを中心に処理をしていた。

ワーカスレッドを用意する形にする場合、各シェーダに用意するのは問題だろう。
そこでコマンドリストとアロケータを引き剥がして、コマンドリストクラスを用意することにした。

まず初期化だけど、CreateCommandListの引数にID3D12PipelineStateのポインタを渡す必要がある。汎用的に使うのにどうしたら良いかと思ったらnullptrを渡せた。コピー専用の場合に使ってた。もうちょっと調べたらCreateCommandList1というID3D12PipelineStateを渡す必要もなく、しかもClose状態で始まるぴったりな関数が見つかった。

次にBegin、Endの関数を追加してこの区間でコマンドを追加出来るようにする。
Beginではシェーダを受け取ってコマンドリストをリセットする。
Endでは今まで受け取ったコマンドのリソースをまとめて状態遷移させるように、先頭のコマンドリストにResourceBarrierを発行するようにした。

今までまとめて状態遷移させるためにすべてのリソースを横断的にまとめて状態遷移させるように専用コードを書いていたけど、リソースが追加される度に変更が必要になる。

これを単純にコマンドを追加するだけで自動的に行なってくれる仕組みができた。
これでコードがスッキリするし、余計なことを考えなくても状態遷移がまとまって問題解決かというと、実はそうでもなかった。

中間レンダーターゲットに描画して、その描画結果をテクスチャとして利用する場合、同じリソースに対し状態遷移が複数発生し、後勝ちで思い通りの遷移にならない。同じリソースが出てきたら最初の遷移だけをするようにして、後は必要なタイミングで遷移する様にした。

スレッド指定


今まで1つのコマンドリストに実行してほしい順番にコマンドを追加していた。
マルチスレッドになっても同じで、例えば4つのコマンドリストと4つのワーカスレッドを用意して、1番目のコマンドリストから実行してほしい順にコマンドを追加して、2番目,3番目,4番目と順番に実行していってくれるものと思っていた。
ExecuteCommandListsの引数には、コマンドリストの数とリストの配列を渡すようになっている。

とりあえずできたので、実行してみるとものすごくチカチカする。



コマンドリストを複数使う場合、1つで使っていたコマンドを振り分けるだけではうまくいかないみたい。


チカチカした原因は、それぞれのコマンドリストで必要最低限のコマンドが存在して、その前のコマンドリストで実行したからといって、後続のコマンドリストでその影響が必ずしもあるわけではなかった。リソースに対するコマンド(ClearRenderTragetなど)はもちろん後続のコマンドリストでも有効だが、コマンドリスト自体に対するコマンドはそれぞれのコマンドリストで実行が必要と理解した。
最低限必要なコマンドは下記だった。
  • SetGraphicsRootSignature(Direct/Compute)
  • SetDescriptorHeaps(Direct/Compute)
  • OMSetRenderTargets(Direct)
  • RSSetViewports(Direct)
  • RSSetScissorRects(Direct)
そこで、コマンドをどのコマンドリスト(スレッド)に追加するかを指定出来るようした。
※上記はDirect/Computeコマンドの場合で、Copyで必要なコマンドはない。


FirstThread


最初に実行する必要のあるコマンド追加。
ClearRenderTragetなど、最初に1回だけ実行するようなコマンドで利用する。


LastThread


最後に実行する必要のあるコマンド追加。
ResourceBarrierなど、最後に1回だけ実行するようなコマンドで利用する。


CommonThread


共通で実行する必要のあるコマンドを追加。
上記のSetGraphicsRootSignatureなど、必要最低限のコマンドはこれで追加すると全スレッドに追加されるようにコピーする。


ParallelThread


並列に実行できるコマンドを追加。
ただ、それぞれのコマンドを一旦キューに追加して、各スレッドがキューから取り出しコマンドリストに追加してもまともに描画されない。
順番にキューに追加されたものを、各スレッドがバラバラのコマンドリストに追加してしまうからだ。

例えばメッシュをレンダリングする際、下記のコマンドを追加する必要がある。
  • IASetPrimitiveTopology
  • IASetVertexBuffers
  • IASetIndexBuffer
  • SetGraphicsRootDescriptorTable
  • DrawIndexedInstanced
そこで順番が関係するいくつかのコマンドをまとめて1コマンドとする仕組みを用意して、その単位で追加できるようにした。


結果


マルチスレッドにした結果、殆ど変わらなかった。むしろ遅くなってるくらい。
1つのスレッドで十分な場合など、最適化したが全体の時間は変わらず、ExecuteCommandLists単位の隙間がやたら長くなっている気がする。

マルチスレッド

シングルスレッド


マルチスレッド版コピー部分

シングルスレッド版コピー部分


最初のコピーはシングルスレッドの方が明らかに速い。
ただこれは数の問題で、3つのBufferコピーをワーカスレッドで1つずつ追加している。規模が大きくなってそれぞれのスレッドが10とか50とかの単位で追加する必要が出てくるとマルチスレッドの効果が出てくると思われる。


マルチスレッド版カスケードシャドウ部分


シングルスレッド版カスケードシャドウ部分


真ん中のカスケードシャドウはマルチスレッドの方が速くなっている。
この様に数がある程度あると効果があるっぽい。
ただし、終わった後の次の処理が始まるまでがやたらと長くて、結局シングルスレッドと同じになってる。
この間隔は状態遷移みたいなので、やり方に問題があるのか?

D3D12_RESOURCE_FLAG_ALLOW_SIMULTANEOUS_ACCESSを使うと、自動的にステータスを遷移してくれるらしく、COMMONの状態から必要な状態に遷移して更に戻ってくれるらしい。ただし、深度バッファは対象外なのと書き込みから読み込みなどの遷移は自分でやる必要がある模様。

後は新しいバリアが出来るらしく、今までのだめなところがいろいろ改善されるみたい。
DirectX 12 Agility SDK 1.7以降で使えるようになるらしい。
NuGet経由で簡単にインストール出来るので試してみたけど、ドライバが対応してないのか、CheckFeatureSupportでD3D12_FEATURE_D3D12_OPTIONS12をチェックしても未サポートだった。また、実際に使うにはID3D12Device10にする必要があるけど、D3D12CreateDeviceで失敗する。しばらく待つしかないのかな?





2023年3月26日日曜日

Readback Heap

今までCPUのメモリからVRAMへ一方通行の使い方しかしていなかった。
Heap管理をまとめて、ReadbackのHeapも用意できるようになったので使えるようにしてみる。


Heapの種類


Heapの種類にはUpload、Default、Readback、Customと4つ定義されている。
Customは使うつもりがないので対象は3つ。

CPUのメモリと各種Heapの関係

CPUのメモリと各種Heapの関係はこんな感じ。
それぞれのHeapに利用用途に合わせて各種Viewを付けて、GPUから参照する。

Upload Heap


Uploadは書き込み専用のポインタをMapして、CPU側からデータを直接書き込める。ただし遅いので常時使うのは小さな定数バッファぐらい。また、Default HeapはCPU側から直接扱えないので、データをコピーするために一時的にUploadを経由させるために使う。


Default Heap


GPUが一番効率よくメモリを使える場所。
CPU側からは直接参照できない。


Readback Heap


Readbackは読込専用のポインタをMapして、CPU側からデータを直接参照できる。

Default HeapをReadback経由で参照する場合、まずデータをコピーする。
Uploadでデータをコピーする際、コピー先のリソースのステータスはD3D12_RESOURCE_STATE_COPY_DESTだったがReadbackでデータをコピーする際は、コピー元のリソースのステータスはD3D12_RESOURCE_STATE_COPY_SOURCEにする必要がある。
コピーの完了を待って、Mapで読み取り用のポインタを取得する。
このときD3D12_RANGEを渡すが、Uploadの場合0,0で初期化していた。これはCPU側から読み取らない宣言だったわけだけど、Readbackの場合は読み取るので、読み取るオフセットを指定する。


UAV経由でデータを書き換える


GPUでデータを書き換える代表格はレンダーターゲットや深度バッファになるけど、Compute Shaderで計算結果を書き込むのはUnordered Access Viewを使うことになる。その時のデータ型にもいろいろ種類があるが、ここでは2つで例を挙げる。

RWStructuredBuffer


StructuredBufferの書き込める版。
データソースとしてStructuredBufferで渡されたデータをCompute Shaderで、計算した結果を同じインデックスのRWStructuredBufferに書き込むといったような使い方をする。

AppendStructuredBuffer


Appendはキューのようなデータ構造で、スレッドの処理が完了した順に追加していく。例えばカリングを行って対象データのみ追加する場合、処理後のデータ数がデータ元と異なる。
その変わったサイズを管理するのがUAV Counter。そのためAppendを使う場合UAVにはカウンタが必須になる。

UAV Counterとは


以前UAVのことについて書いたが、このときはUAVカウンタのことを理解していなかった。
このカウンタはUAVにオプションで付けることが出来て、RWStructuredBufferの場合、IncrementCounter、DecrementCounter関数、AppendStructuredBufferの場合、Append関数を使うとカウントが制御される。
バッファに入っているデータ数を管理するカウンタとして利用できるということがわかった。
そのため、データを書き込む前には一旦0にリセットする必要がある。

(ExecuteIndirectを使う場合、Readback不要でシェーダで処理したUAVリソースとカウンタのオフセットをそのまま渡すようなI/Fになっている)


#define DefRS "RootFlags(DENY_VERTEX_SHADER_ROOT_ACCESS|DENY_HULL_SHADER_ROOT_ACCESS|DENY_DOMAIN_SHADER_ROOT_ACCESS|DENY_GEOMETRY_SHADER_ROOT_ACCESS|DENY_PIXEL_SHADER_ROOT_ACCESS),DescriptorTable( SRV(t0, flags=DATA_STATIC_WHILE_SET_AT_EXECUTE),visibility=SHADER_VISIBILITY_ALL),DescriptorTable( UAV(u0, flags=DATA_VOLATILE),visibility=SHADER_VISIBILITY_ALL)"

StructuredBuffer<uint> Src : register(t0) ;
AppendStructuredBuffer<uint> Dst : register(u0) ;

struct CSInput
{
	uint3 ID : SV_DispatchThreadID ;
} ;

[RootSignature(DefRS)] 
[numthreads( 8, 1, 1 )]
void CSMain( CSInput In )
{
	int i = In.ID.x ;
	if( Src[i] > 30 ) {
		Dst.Append( Src[i]) ;
	}
	return ;
}

試しに作ったサンプルでは、Srcで渡した点数データをDstにAppendしていく。
その際、30点以下の場合は除外するようにした。

100、80、60、50、40、30、20、10の8個のデータをSrcで渡すと
Dstで100、80、60、50、40の5個のデータと、カウンタには5という値が渡る。

Src

Dst

Counter

Readback経由でCounterを取得して、その数分Dstの先頭から値を取得するとGPUで処理した結果が受け取れるようになった。
これでGPUの処理結果を受け取れるようになったので、単純で大量の計算をGPUに任せることも出来る。


今回のハマりポイント


データ元を構造化バッファにしていたけど、最初は定数バッファで試していた。
定数バッファの場合、全体を配列で使えないので構造体内に配列を定義した。

struct _Src
	uint Val[8] ;
} ;

ConstantBuffer<_Src> Src : register(b0) ;
AppendStructuredBuffer<uint> Dst : register(u0) ;

struct CSInput
{
	uint3 ID : SV_DispatchThreadID ;
} ;

[RootSignature(DefRS)] 
[numthreads( 8, 1, 1 )]
void CSMain( CSInput In )
{
	int i = In.ID.x ;
	if( Src.Val[i] > 30 ) {
		Dst.Append( Src.Val[i]) ;
	}
	return ;
}

C++側からは、バイナリで4バイトずつ8つ(32バイト)のデータを書き込んだ。
PIXで確認してもきちんとデータが入っている。
にも関わらず出力結果は構造化バッファと同じにならず、2件、100と40が返却された。

DXILの_Src構造体のサイズ情報が116となっており、32バイトではない。
どうやら配列の1要素単位で16バイトアライメントになっているみたいで、最後の項目だけ4バイトと考えるとサイズが一致する。

試しに渡すバイナリを16バイト単位にすると、同じ結果が得られた。
PIX上のデータには0、4に値が入っていて、1,2,3,5,6,7には値が入っていないように見えるが、シェーダは結果は正しい値になっている。
BufferFormatが実際のデータと合っていないっぽい。

struct _Src
	uint4 Val[2] ;
} ;

ConstantBuffer<_Src> Src : register(b0) ;
AppendStructuredBuffer<uint> Dst : register(u0) ;

struct CSInput
{
	uint3 ID : SV_DispatchThreadID ;
} ;

[RootSignature(DefRS)] 
[numthreads( 8, 1, 1 )]
void CSMain( CSInput In )
{
	int i = In.ID.x / 4 ;
	int j = In.ID.x % 4 ;
	if( Src.Val[i][j] > 30 ) {
		Dst.Append( Src.Val[i][j]) ;
	}
	return ;
}

定義をuint4に変更して、渡すデータも4バイト単位に戻して実行したらうまく行った。

バイナリデータとアライメントが間違っていただけだった。
配列で定義すると、1要素単位で16バイトアライメントになる。
配列にせず、uintを8つ変数定義すると期待通り4バイトアライメントになる。floatなども同じ。