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なども同じ。



2023年3月24日金曜日

Heap Allocator

前回、リソースのアライメントが64KBという衝撃を受けた。

中間レンダーターゲットや、深度バッファなどはある程度大きいデータなので、64KBというアライメントでもそんなに問題ない。

問題ありなのは定数バッファだ。
例えばメッシュのマテリアル情報は現在52バイト利用している。
今サンプルで使ってるデータは1メッシュに付き6つのマテリアル情報を持っていて、実質のデータサイズは52×6=312バイトなのに対して、64KBアライメントだとするとVRAMは64KB×6=384KBも消費していることになる。

今まで定数バッファのアライメントは256バイトだと思っていて、52バイトだけど256に補正して無駄な領域ができてるなぁと思っていたけど、そんなレベルではなかった。
256のアライメントは、CBVで連続したメモリを配置する際のアライメントだ。

リソースの管理の仕方が根本的に間違っているっぽい。


Bufferのアライメント


小さいテクスチャは条件を満たすと4KBアライメントにすることが出来る。
Bufferは無条件で64KBになってしまうと書かれていたが、本当なのか?

QueryVideoMemoryInfo関数を使うと、利用しているVRAMのサイズを調べることが出来るので実験をしてみた。

サイズを取得した後に、CreateHeapを呼んで、再度取得して増えたサイズを確認してみる。

サイズを64KBで作成すると、消費は64KB。
4KBで作成すると、消費は64KB。
8KBで作成すると、消費は64KB。ここまでは予想通り。
問題はこの後。
64KB+1バイトで作成すると、消費は64KB+4KB。
4KB+1バイトで作成すると、消費は4KB+4KB。
4KB-1バイトで作成すると、消費は4KB。
8KB+1バイトで作成すると、消費は8KB+4KB。
8KB-1バイトで作成すると、消費は8KB。

4KB、8KBピッタリの場合の挙動は謎だけど、それ以外は4KB単位で確保されるっぽい。これは確保されるサイズであってアライメントとは別なのか?
ということでマテリアル情報の定数バッファは384KBまでは使っておらず、4KB×6=24KBで済んでるということになる。でも大きい。


1つのHeapに複数の定数バッファをまとめる


1つのHeapに対して、複数のリソースを配置できるっぽいのでこれを利用すれば定数バッファの問題は解決するのではないか?
ある程度のサイズのHeapを確保して、CreatePlacedResourceで256バイトずつずらしてリソースを作成しようとしたところ、1つ目は問題ないが2つ目からエラーが発生する。

D3D12 ERROR: ID3D12Device::CreatePlacedResource: This resource cannot be created on this heap, due to unsatisfied resource alignment requirements. The resource must be aligned to 65536. The heap must also be aligned to a value greater to or equal than the resource. The heap is aligned to 65536, and the resource offset in the heap is 256. [ STATE_CREATION ERROR #638: CREATERESOURCEANDHEAP_INVALIDHEAPPROPERTIES]

ヒープのアライメントは64KBで、256のオフセット単位ではリソースは作れないらしい。
ダメ元でオフセットを4KBにしてみたがやっぱりダメ、CreateHeapのD3D12_HEAP_DESCのAlignmentに4KBを設定してもエラーになる。実質の消費サイズが4KBになっているにも関わらず、0以外を設定すると怒られる模様。

次に考えたのが、1つのHeapで複数のCBV。
これはすでに必要サイズを256単位に補正したサイズ×フレーム数分のサイズを確保したHeapに対してフレーム数分のCBVを用意して、フレーム毎に参照するCBVを変えて更新していくということをやっているので実績あり。


Heap管理



ヒープの管理戦略にはいくつか方法があるみたいだ。
そのなかに「何も管理しない」というものがあり、それが今の実装だった。
CreateCommittedResourceを使って、一切Heap管理をしない方法。
サンプルプログラムやプログラムの説明に用いる場合、かなり便利でその範囲ではまともに動作する。

これが製品レベルのプログラムになるとそうもいかないらしい。


Segregated free listsとかBuddy systemとか、名前がついたアルゴリズムがあるみたいだけど、今回実装したのはSegregated free listsに近いのか?

Heapの利用用途で作成時にフラグを指定するが、自分の使い方として下記の5つに分類した。

Upload Buffer


D3D12_HEAP_TYPE_UPLOADで作る。用途は定数バッファやDefaultバッファのコピー用。

Readback Buffer


D3D12_HEAP_TYPE_READBACKで作る。用途はComputeShaderの結果受け取り用。

Default Buffer


D3D12_HEAP_TYPE_DEFAULTで作る。用途はマテリアルなどの定数バッファ。

Default SRV


D3D12_HEAP_TYPE_DEFAULTで作る。用途はテクスチャや構造化バッファ。

Default RTDS


D3D12_HEAP_TYPE_DEFAULTで作る。用途はレンダーターゲット、深度バッファ。


確保したいサイズを64KB単位に補正して、ツリーを検索。見つからない場合は新しくHeapを確保する。このとき、大きいサイズが余っていても使わない。ぴったり一致するサイズが余ってる場合だけ利用する。

開放時は確保サイズをキーとしてツリーに戻す。
ツリーは64KB単位のサイズキーで構成され、似たようなサイズを取り扱うだろうという想定の割り切り仕様。分割もマージもしないので管理上の断片化はしない。
欠点はレアなサイズを利用すると、確保したまま再利用されなくなる。
シーンの切り替え処理の最後で使ってないHeapの開放など入れれば解決できそう。


4KBアライメントテクスチャ対応


前回の記事で扱った4KBアライメントのリソースは、1つのHeapに対して複数のリソースを載せる。
サイズをキー、値にオフセットをツリー管理する。指定サイズギリギリ上回るサイズから切り取って、サイズを減らし、オフセットをずらす。
開放時は、元のオフセットと確保サイズを元にマージできるノードを検索。見つかった場合はマージして再帰する。マージできない場合は単純に追加する。


定数バッファリソース対応


冒頭の定数バッファ用のリソース確保の仕組み。
64KBのHeapを確保&256分割して、1Heapに付き256スロット用意する。
スロット数をキー、値にオフセットをツリー管理する。
確保する際は256バイト単位に補正して、256で割った数が必要スロット数。
必要なスロット数をギリギリ上回るノードを探して、スロット数を減らして、オフセットをずらす。
リソース自体は同じものを参照して、オフセットを元にCBVを用意する。
開放時は、元のオフセットと確保スロット数を元にマージできるノードを検索。見つかった場合はマージして再帰する。マージできない場合は単純に追加する。


Aliasingバリア


マイクロソフトのリファレンスにはCreatePlacedResourceで出来たリソースは非アクティブ状態なので、Aliasingバリアによってアクティブ化する必要があると書いてある。
今回の対応により、すべてのCreateCommittedResourceをCreatePlacedResourceに置き換えたが、Aliasingバリアは未使用。DebugLayerにも何も表示されていない。
最初はリソースを重ねなければ、バリア不要かと思っていたけど、マイクロソフトのSmallResourceのサンプルでは、重ならない小さな4KBアライメントテクスチャをAliasingバリアしている。サンプルのバリア部分をコメントにしても問題なく動く。

具体的な問題が出るまでは、このまま行ってみる。