DirectXの話‎ > ‎

DirectXの話 第147回

DirectX12で非同期コンピュート  17/02/19 up

DirectX12の前回の記事が1年半前だったことに今気づく。
DirectXメインでやってたサイトでしたが、Vulkanに浮気してました。
が、不肖もんしょ、DirectXに戻ってきました。
理由?
GLSLよりHLSLの方が使いやすかったから。

というわけで、戻ってきて早々に Vulkan でも実装した FFT を DirectX12 でも実装し、非同期コンピュートで処理するサンプルを作りました。
サンプルはGitHubに置いてあります。


こちらのSample002が該当するサンプルとなります。

FFT についてはやはり解説はしません。
非同期コンピュートについてもVulkanの話 第9回を参照してください。

基本的な考え方は Vulkan のものと変わりません。
グラフィクス処理を行う Graphics Queue とは別に、グラフィクスのことは行えないが Compute Shader は起動することが可能な Compute Queue、また、シェーダを動作させることは出来ないがコピー処理は行える Copy Queue が存在します。
これはDirectX12だけでなくVulkanも同じですね。
基本的に Graphics Queue は他の Queue の処理は全て行えます。
Compute Queue は描画に関する処理は行なえませんが、Compute Shader を使った処理とコピーは行なえます。
Copy Queue はシェーダの起動は不可能です。基本はコピーのみです。

まず、DirectX12 で非同期コンピュートを行うには Compute Queue を作成する必要があります。
作成方法は Graphics Queue と変わりません。ID3D12Device::CreateCommandQueue() 命令で作成するだけです。

D3D12_COMMAND_QUEUE_DESC desc{};
desc.Type = D3D12_COMMAND_LIST_TYPE_COMPUTE;
desc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;
desc.Priority = D3D12_COMMAND_QUEUE_PRIORITY_NORMAL;
auto hr = pDevice->CreateCommandQueue(&desc, IID_PPV_ARGS(&pQueue_));
if (FAILED(hr))
{
    return false;
}

Graphics Queue を作成する場合は Type に D3D12_COMMAND_LIST_TYPE_DIRECT を指定しましたが、Compute Queue の場合は D3D12_COMMAND_LIST_TYPE_COMPUTE を指定します。これだけ。
なお、Copy Queue を作成する場合は D3D12_COMMAND_LIST_TYPE_COPY を指定します。

ID3D12CommandAllocator, 及び ID3D12GraphicsCommandList を作成する際にも D3D12_COMMAND_LIST_TYPE_COMPUTE を選択してください。
コマンドリストですが、ID3D12ComputeCommandList というインターフェースは存在しません。
Graphics Queue と共用なので、Compute Queue 用のコマンドリストとして作成しても Graphics Queue 用の命令が使用できます。
使用するとデバッグレイヤーがエラーを出したりしますので注意してください。

さて、コマンドリストの作成ですが、今回は Compute Queue 用に複数のコマンドリストを作成します。
とりあえず10個のコマンドリストを作成しましたが、詳細は後で解説します。

Descriptor Heap Root Signature の作成方法は通常と同じ方法なので割愛します。
しかし、Pipeline State Object の作成方法は Compute Queue 用のものが存在します。
作成の場合は D3D12_COMPUTE_PIPELINE_STATE_DESC 構造体で情報を設定し、ID3D12Device::CreateComputePipelineState() 命令で作成してください。
ただし、作成されるオブジェクトは ID3D12PipelineState インターフェースであり、Graphics Queue 用と同じ型です。
そのため、設定も ID3D12CommandList::SetPipelineState() 命令を使用します。 
これとは逆に、生成時は特に Graphics Queue 用と区別がつかないのに、描画時に Compute Queue 独自の処理を行うのが Root Signature で、設定命令は ID3D12CommandList::SetComputeRootSignature() 命令を使用します。

FFT 計算用のコマンド積み込み命令ですが、main.cpp の LoadFFTCommand() 関数が全てです。予め作っておいてロードしておく、というようなことはしていません。
今回は FFT の計算回数を動的に変更できるようにしているためと、Graphics Queue と Compute Queue で動作させることが出来るようにするために、FFT 計算処理を行う際にコマンドをロードするようにしています。

この FFT 計算用コマンドロード内で重要なのはリソースバリアの張り方です。
Graphics Queue では Transition Barrier というリソースの状態遷移を行うリソースバリアを張ります。

リソースは状態によっては内部のフォーマットに違いがあったりします。
わかりやすいところでは深度バッファですが、最近のGPUでは高速化のため深度バッファは圧縮されていたりします。
深度バッファが深度バッファとして使用されている状態では圧縮されていても問題はないのですが、シェーダリソースとしてシェーダ内で使用する場合は圧縮されていては正常な値を取得できません。
このような場合に Transition Barrier を張って状態遷移を待ちます。
将来的に他のバッファもある特定状態で高速化するために内部での持ち方を変更する可能性もありますので、リソースの状態は正しく設定しておくほうが良いです。

Vulkan でも同じように状態遷移に対してバリアを張っていましたが、DirectX12 では Compute Queue で Transition Barrier は張れないようです。
ID3D12CommandList::ResourceBarrier() 命令自体は Compute Queue でも使えますが、状態遷移は Graphics Queue でなければ行えないというわけです。
だからといってリソースバリアを張らなければ FFT の処理で不具合が出ます。
FFT を行った後に 逆FFT をかけていますが、これらの処理で Compute Shader は4回実行され、ある Compute Shader で出力された情報を次の Compute Shader でも使用しているのです。
そのため、リソースバリアを張らないと結果が求められる前に次の処理が走って、結果は正常なものではなくなってしまいます。

Compute Queue でリソースバリアを張る場合、リソースバリアの Type に D3D12_RESOURCE_BARRIER_TYPE_UAV を指定します。

D3D12_RESOURCE_BARRIER barrier;
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_UAV;
barrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE;
barrier.UAV.pResource = pResource;
pCommandList->ResourceBarrier(1, &barrier);

状態遷移は出来ませんが、リソースの処理が終わるまで同期は取ってくれます。
基本的に Compute Queue だけで処理を行っているのであれば状態遷移は必要ないようです。シェーダリソースとしてしようするにしろ、Unordered Access View として使用するにしろ状態は変更しなくてもOKっぽいです。
もちろん、描画で更新されたカラーバッファや深度バッファを Compute Queue で使用したい場合は状態遷移の必要がありますが、そのような場合は Graphics Queue でバリアを張ってから Compute Queue で処理、というかたちを取るのがセオリーのようです。

なお、本サンプルでは "Use Barrier" というチェックボックスが存在しますが、これを OFF にするとUAVバリアを張らないようにします。
その結果として逆FFT の結果が壊れるのが確認できるでしょう。

次に非同期コンピュートの同期のとり方です。
Vulkan では4つの同期処理が存在しますが、DirectX12 ではバリアとフェンスの2種類しかありません。
バリアはリソースバリアであり、これは Vulkan のバリアとも同様のものです。
フェンスは Vulkan でいうところのフェンスとセマフォを合わせたようなものです。
残念ながら、イベントのような同期処理は存在しないっぽいです。

DirectX12 でフェンスを使用するためにはコマンドリストを Queue で実行したその後になります。
ID3D12CommandQueue::Signal() 命令を使うことで、フェンスを立てる事ができます。
この命令を発行する前に実行したコマンドリストの実行が完了するとフェンスは Signal() 命令で指定した値を持つことになります。
この値に変化するのを待つことで Vulkan のフェンスやセマフォと同じように取り扱うことが出来ます。

セマフォと同じように使う場合は、立てたフェンスを別の Queue で待つ必要があります。
これには ID3D12CommandQueue::Wait() 命令を用い、どのフェンスがどの値になるまで待つ、という用な形で同期を取ります。
今回は Compute Queue で処理した FFT の結果を待ってから Graphics Queue で処理する方法も実装していますが、ここではこの手法を用いています。
Compute Queue で FFT の計算処理のコマンドリストを実行した後に Signal() 命令でフェンスを立て、Graphics Queue の先頭で Wait() 命令を発行して FFT の処理が完了するのを待っています。

今回のサンプルでは FFT の計算と並列で処理するものが存在しなかったのでただじっと待つだけですが、この計算処理の間に FFT の処理とは無関係な処理を Graphics Queue で処理することも可能です。
これも立派な非同期コンピュートで、グラフィクスに関する一部の処理 (例えばライトカリングなど) を Compute Queue で処理している間にシャドウマップ描画を Graphics Queue で行うなどということも可能です。

CPUとGPUの同期としてのフェンス使用は描画完了待ちで行っていますが、こちらは割愛します。
また、ID3D12Fence::GetCompletedValue() を用いると、Queue の処理がフェンスに到達した段階で Signal() 命令の第2引数で渡した値を取得することが出来ます。
これをビジーループなどで待つのではなく、各フレームで1回だけポーリングして、フェンスまで到達したら次の処理をCPUで実行するという方法もあります。
この手法は複数フレームに跨るような重い処理を Compute Queue で実行する際に有効です。
本サンプルでもこれを利用しています。

サンプルのメニューに "Pipe" というドロップダウンリストが存在します。
この項目を変更することで FFT を実行する Queue と同期処理の方法を変更することが出来ます。
"Graphics" は文字通り Graphics Queue で処理します。リソースバリア以外の同期処理は行いません。
"Sync Compute" は Compute Queue で処理しますが、Graphics Queue の先頭でその処理の完了を待ちます。
処理時間としては "Graphics" を選択したときと変わりません。
"ASync Compute" は複数フレーム間で処理を実行します。1フレームに1回ポーリングして、FFT の処理が完了すると実際に結果のテクスチャを使用できるようになります。
FFT の処理が終わるまではメニューは非表示になり、完了すると完了までにかかったフレーム数を "Frame Count To Calc" に表示します。

以下は各同期処理ごとの GPU View の計測結果です。


それぞれどのQueueで処理が行われているかわかります。
"Sync Compute" の場合は Compute Queue が動作している間は Graphics Queue が止まっていますが、"ASync Compute" の場合は止まっていません。

その他のメニューの項目ですが、"Loop" は FFT の処理回数です。
本来は1回でいいのですが、非同期処理をわかりやすくするために同じ処理を指定回数分だけ実行します。
最大1000回ですが、GTX1070 で 1万回実行したら OS が死にましたw

"Calc FFT" ボタンは FFT 計算処理を実行します。
"View" ドロップダウンリストは表示するテクスチャで、"Source" が元素材、"FFT Result" が FFT 処理の結果、"IFFT Result" が逆FFT の結果を表示します。
"Sync Interval" は OFF にすると垂直同期を待たずに Present を行います。

さて、最後にコマンドリストのロード方法についてです。
上の図では "ASync Compute" 状態では Compute Queue のブロックごとに Graphics Queue でも待ちが発生しています。
DirectX12 ではどうやら Graphics Queue の完了待ちの際に Compute Queue の処理も待ってしまうようです。
ただし、待つのはコマンドリスト単位のようです。
つまり、コマンドリストをある程度細かく分けることで、Graphics Queue の動作を阻害しないように作ることが可能なのです。

今回のサンプルでは最大1000回のFFT処理を10個のコマンドリストに均等に分けるようにしています。つまり100回のFFT処理のコマンドリストを10個、ということです。
100回程度であれば1/60フレームに収まる (GTX1070の場合) ため、この程度の分割数にしています。
より重い処理の場合はもっと細く分けたほうがいいとは思いますが、DirectX12 ではある程度の分割をユーザが行う必要があるわけです。

Vulkan では1つのコマンドリストでもある程度時分割してくれました。
とは言え、Compute Queue 実行中は Graphics Queue の動作が極端に遅くなっていましたので、実際には細かくコマンドリストを分割した方がいいものと思われます。

問題は、どこまで細かくするかですが、あまり細かくしすぎても逆効果のようです。
基本的にはコマンドリストは40個くらいまでに抑えたほうがいいようです。1つのコマンドリストにロードされたコマンドが少ないと無駄なオーバーヘッドがかかってしまうようです。
まあ、ある程度の処理ごとにコマンドを分割すれば、たいていのアプリでは問題ないのではないでしょうか?

また、コマンドの実行は10回位までに抑えたほうが良いようです。
ID3D12CommandQueue::ExecuteCommandLists() 命令は複数のコマンドリストを一度に実行させることが出来ますので、フェンスを立てる必要がないコマンドリスト群は1回の実行にまとめたほうが良いでしょう。
なお、本サンプルではまとめてはいませんが、FFT の計算処理はまとめて実行することが可能です。

というわけで今回はここまで。
そのうち非同期コンピュートを使った Clustered Rendering とかやってみたいものですが、更新はまた結構遅くなると思います。