DirectXの話 第163回

DescriptorHeapのコピー戦略

19/06/15 up

今回はサンプル用ライブラリのDescriptorHeap周りを大幅に変更したので、それについて書いていこうと思います。

今回の修正は特に動作が変わるということもないのですが、内部的には結構変更しています。

ただ、今までのものもまだ残っていて、残ってる理由がDXRへの対応がまだ終わってないからです。

こちらは別途の戦略が必要になりそうなので、設計考えるところからですね。

DescriptorHeapのおさらい

DescriptorHeapについて簡単におさらいしておきましょう。

まず、D3D12でDraw命令やDispatch命令を出す際、リソースはどのようにバインディングされるかを考えましょう。

D3D11では各シェーダステージの各種リソース(CBV、SRV、UAV、Sampler)をレジスタ番号指定で設定するだけで問題ありませんでした。

これに対してD3D12では、どのような形でバインドするかを RootSignature によって決定します。

RootSignature は64個のスロットを持っていて、各種リソースをどのような形でバインドするかをこのスロット内で設定する必要があります。

スロットの使用数はバインド方法によって違いがあります。例えば、リソースのGPU仮想アドレスを直接設定する場合はスロットが2つ使われます。DescriptorTableを使うとスロット1つです。

GPUアドレスを直接設定すると1回の描画につきリソースは32個までしか使えないわけで、ちょっと複雑なマテリアルでも作成しようものならテクスチャと定数バッファとサンプラーで32個くらい埋まることもあるでしょう。

ではDescriptorTableを使えばいいのでは?ということになります。これを使えば64個までリソースをバインディング出来ます。

正直、64個のリソースがあればほとんどの場合で対応できるでしょう。

さて、ここで話が変わって、RootSignatureでリソース用のスロットが用意されていた場合、ここに必ずリソースを設定しなければいけないかというと、実はそうでもないようです。

シェーダ側で使用しているにもかかわらずRootSignatureでスロットが存在しない場合は問題ですが、その逆でシェーダ側で使用していないレジスタにRootSignatureがスロットを割いていても問題はないわけです。

さて、ここで一旦話は変わってDescriptorHeapの話。

リソースバインディングでDescriptorTableを使用する場合、かならずDescriptorHeapから取得できるDescriptorHandleが必要になります。

DescriptorHandleとはリソースの情報を格納したDescriptorを示すハンドルで、これはDescriptorHeapから確保されるものです。

DescriptorHandleは先頭から連続した情報として取得することも可能で、シェーダ内でレジスタ番号が連続している場合は先頭のハンドルだけでDescriptorTableに設定することが可能となります。

連続したハンドルであれば複数のリソースでもDescriptorTable1つで処理できるというわけです。つまり1スロットのみ。

これならグラフィクスパイプラインのシェーダをフルに活用した場合でも64個に収めるのは簡単になるはずです。

しかし連続したハンドルにリソースを設定するのはちょっと大変です。

定数バッファやテクスチャなどのリソースは当然別々に存在していますし、DrawCall時のリソースのバインディング状態はその際の設定によって様々に変化します。

あるテクスチャはあるマテリアルではレジスタ番号0番ですが、別のマテリアルではレジスタ番号2番で使われるかもしれません。

DescriptorHandleにリソースを割り当てるには CreateXXXView() 関数が必要で、これ1回がそこまで重いということはないようなのですが、毎フレームのレンダリングで毎回行われても余裕がある、というわけではないでしょう。

出来ればViewの作成は1度だけ処理するような状況を作りたいわけです。

また、DescriptorHeapはDrawCall前に使用するハンドルを確保したものを描画コマンドとして送らなければなりません。

この際設定できるのは、CBV_SRV_UAVの3つを確保できるHeapとSamplerのみを確保できるHeapをそれぞれ1つだけです。

もしもそのDrawCallで使用するCBVとSRVがそれぞれ別のHeapから確保されているとするとエラーとなってしまいます。

これらの情報を踏まえて、以降でDescriptorHeapの戦略がどのように変化しているか解説します。

これまでのDescriptorHeap戦略

これまでの戦略ではまず最初に大きめのDescriptorHeapを確保しています。これ以降、DescriptorHeapは確保しません。

次にViewですが、各リソースごとにそれぞれ別途でハンドルに割当を行っています。

そのため、連続したハンドルを使用することは出来ません。

RootSignatureはDescriptorTable1つにつき1つのリソースだけを設定します。

無駄なTableは設定せず、タイトに設定しています。

この方法はサンプル程度のものを作成するならさほど面倒ではありません。

タイトなRootSignatureの作成もShaderReflectionを利用すればシェーダバイナリから情報を拾うことが出来ます。

欠点としては、RootSignatureがランタイムでパイプラインを作成する段階にならないと作成できないという点です。

グラフィクスパイプラインでの描画は頂点シェーダやピクセルシェーダの組み合わせによって必要なRootSignatureが変化します。

例えばメッシュのレンダリングを行う場合、頂点シェーダは同一でもピクセルシェーダはマテリアルによって変化します。

あるマテリアルではテクスチャを3枚使うけど、別のマテリアルでは5枚使うかもしれません。

これはシェーダをコンパイルするオフラインの段階では明確に決定できない情報です。

もちろん、昔懐かしい.fx形式のシェーダであれば、コード内でパイプラインを作成することが出来るので、その段階でRootSignatureを作成することも出来ますが、よくあるシェーダステージごとにシェーダを作成する方法ではパイプラインの作成がランタイム上で行われる形になってしまいます。

ランタイムで行うことに問題はあるのかって?

昔は自分もそう考えてたんですがねぇ…いろいろあるんですよ、いろいろ。

まあ、そんなわけで、オフラインでRootSignatureを決定したいという欲求に対してこれまでのDescriptorHeap戦略では太刀打ち出来ない状況にあったわけです。

新しいDescriptorHeap戦略

新しい戦略でどうしてもやりたいのはRootSignatureを事前に決定したいというものです。

タイトなRootSignatureを作成せず、ルーズに、無駄な宣言もなんのそので作りたいわけです。

しかし前述のとおり、64個ではルーズな設定は難しいです。

絶対に無理とは言いませんが、どのステージでどのリソースをどれだけ使うかというのを制限するのはできるだけ避けたい。

そこで、まずDescriptorTableを各ステージの各種リソースで連続したハンドルのみを使用するようなものを作成します。

使用するレジスタ数はもうこれ以上必要ないだろうというくらいの数を指定しています。

今回はUE4を参考に、CBV, UAV, Samplerは16個、SRVは48個を指定しました。

例えば頂点シェーダとピクセルシェーダが使われる場合のDescriptorTable数は、頂点シェーダでCBV, SRV, Samplerの3つ、ピクセルシェーダはそれに加えてUAVも使えるので4つ、合計7つのテーブル数となります。

フルにシェーダステージを使ったとしても16個のテーブルで済むので、RootSignatureのスロット数としては超余裕。

しかしこの場合、当然ですが連続したDescriptorHandleが必要になります。

もちろん、DrawCallごとにViewを作成するような真似はご法度です。

ここで使用するのは ID3D12Device::CopyDescriptors() 命令です。

これはあるDescriptorから別のDescriptorへ内容をコピーするCPU命令です。CPU命令なので即発行され、GPUに待たされることはありません。

DescriptorHeapが別のものでも問題なくコピー可能です。

戦略としては以下のようになります。

Offline DescriptorHeapはViewを作成するためのヒープです。

このヒープはレンダリングには使用せず、Viewの作成とコピー元として使用されます。

Global DescriptorHeapは実際にレンダリングに使用されるヒープです。

Offline DescriptorHeapで作成したViewを持つハンドルをこちらのヒープで作成した連続したハンドルにコピーします、これをレンダリング時に設定するというわけです。

Offline DescriptorHeapは特に問題がなく、最初に大きめに取得して切り分けてもいいですし、足りなくなったら増やしても問題ありません。あくまでコピー元でしかないので制約はほとんどありません。

しかしGlobalはそうもいきません。

まず、レンダリング時に設定可能なヒープはCBV_SRV_UAVとSamplerでそれぞれ1つだけです。足りなくなったら増やす、というやり方を取るとヒープが別れてしまう可能性があります。

また、レンダリングに使用している最中のハンドルへコピーするのは問題です。まだ使用していない、もしくはすでに使用したあとであることが保証されたハンドルを使用しなければなりません。

そこで、大容量のヒープを予め確保し、これをスタックのように使う手法を取り入れました。

Global DescriptorHeapは今回50万個分を最初に確保しています。なお、最大で100万個まで確保できるようなので、これでも足りないようならまだ増やせます。実際には足りなくなることはほぼないと考えます。

この50万個のうち、まず2000個ずつをスタックとして各コマンドリストが取得します。足りなくなればまた2000個確保します。

コマンドリストごとに取得するのには理由があって、まずコマンドリストは複数作成してフレームごとに別々のコマンドリストを使います。

これはハンドルと同様で、まだレンダリングが終わっていないコマンドリストを使い回すわけにはいかないので、コマンドリストが描画に使用されていないことを保証できるだけの数を用意して使い回すようにするためです。

私のサンプルシステムでは3フレーム分のコマンドリストが用意されていますので、最初に6000個のハンドルが確保されることになります。

また、コマンドリストは複数用意して複数スレッドからコマンドロードを行うので、コマンドリストごとにハンドルを持っておいた方がスレッド競合が起こらないという利点もあります。

しかしこの方法、CBV_SRV_UAVのヒープには使用できるのですが、Samplerのヒープには使用できません。

なぜかというと、Samplerのヒープは最大で2048個分しか確保できないためです。大量に確保して安心安全、とはいかないわけです。

ですのでSamplerは少々異なる戦略を採っています。

連続したハンドルにコピーする、という点では変わりませんが、CBV_SRV_UAVのようにDrawCallのたびにコピーするという手法は採りません。

まずコマンドリストごとに2048個のヒープを作成します。

通常、ここにコピーしていくわけですが、確保するハンドルの数はすべてのステージで使用されるサンプラーを合計した数だけ確保します。

そこにOfflineからのハンドルをコピーしますが、コピー元のハンドルからハッシュキーを計算して辞書登録するようにします。

これをキャッシュとして使用し、同じ組み合わせがあった場合はそのキャッシュを利用しようという魂胆です。

Samplerは他のリソースと異なり、同じものを使い回すことが多いリソースです。

メッシュのマテリアルなどはほとんどの場合でUVラップありのトライリニアフィルタや異方性フィルタを使うだけだったりしますし、システムがいくつか生成しておけばほぼその使い回しで済むでしょう。

さすがに2048個では足りなくなる可能性も高いですが、サンプル程度なら十分足りそうですし、実際のゲームアプリになってもそこまで多く必要になることはないだろう、という考えです。

まあ、実際、サンプルでは問題なさそうです。

この辺の実装はSampleLib12の descriptor_heap.h 辺りにほとんど書いてあるので、興味がある方はそちらを見てください。

また、この実装のためには一旦必要なViewをまとめなければならなくなったので、DescriptorSetクラスを作成しました。

設定されたViewのCpuHandleを保持するだけですが、最大のレジスタ番号も記録しておくので無駄なハンドルを確保しないようになってます。

速度について

コピー処理が入ることで速度面でペナルティがあることが予想されます。

また、不要なテーブルの設定によりドライバやGPU側の負荷が増える可能性も否定できません。

そこでCPU及びGPUの計測を行ってみたのですが、優位な差は出ませんでした。

無理やり大量のDrawCallを発生させてみたのですが、それでも変化は見られず。

マシンスペックに依る部分もあるかもですが、速度的なペナルティはほぼ考えなくても大丈夫じゃないかと思います。

不具合について

この手法、基本的には問題なく動作するのですが、Debug Layerを使用している場合にちょっと問題が発生します。

レイトレーシング可能なGPUでのみ起こるのですが、CopyDescriptors() 命令を発行するとCPUハンドルがオーバーラップしてるというエラーが発生します。

これはDebug Layerの不具合らしく、コピーそのものは問題なく行われて、動作にも影響はありません。

DescriptorHandleのCPUハンドルは以前は仮想アドレスを返したのですが、DXRの関係で現在は仮想アドレスを返しません。

どんな値が返ってくるかチェックしたのですが、どうもIDのようなものが返ってきていました。

最初は3で、そこから4,5,とインクリメントされたものがCPUハンドルのスタートアドレスとして返ってきています。

DXR対応で内部的なViewの管理が変わったのではないかと思われます。

まあ、エラーが出ても問題ないとはいえ、エラーが毎度毎度出てくるのは困ります。

そこで、DXRに対応している場合はこのエラーを無視するようにSampleLib12は変更しました。

device.cpp (95) に以下のような修正を入れています。

このコードで問題のメッセージが出ないようになります。

追記 2019/06/21

レジスタの歯抜け時のコピー問題

シェーダを記述する際、レジスタ番号をコードから指定することが可能です。まあ、知ってますよね?

これを利用しなければ使用されているシェーダリソースは0番から順番にレジスタ番号が割り当てられるのですが、例えばシェーダコードに記述されていても使われていないリソースはレジスタ番号が割り当てられません。

なのでレジスタ番号を指定してリソースバインディングするようなシステムを作ろうとすると、コード上でレジスタ番号を直接指定したくなるわけです。

その際に発生するのがレジスタ番号の歯抜け問題。例えば以下のような状態。

Texture2D tex00 : register(t0); Texture2D tex01 : register(t2);

t1レジスタが歯抜けになっていますが、もちろんこれは許される記述方式です。

さて、コピー戦略ではレジスタ番号0番からリソースごとにシステムで決めた最大値までを1つの DescriptorTable で管理します。

この場合、歯抜けになったレジスタには基本的に何も設定されないでしょうから、コピー時には無効なアドレスがコピー元アドレスとして渡されることになります。

無効なアドレスに 0 を入れておけば大丈夫かというとそんな事はもちろんなく、当たり前のようにNGです。

なので、有効なアドレスを指定しなければなりませんが、これは別に難しい話ではなく、とりあえずデフォルト用のアドレスとして1つ分のCPUアドレスを確保してそれをダミーアドレスとして歯抜け部分に設定すればOKです。

で、このアドレス、ちゃんと View を設定していなければならないかというと別にそういうわけでもないようです。

少なくともNVIDIAのGPUでは正常動作していますし、Debug Layerでエラーが発生するということもありませんでした。

一応、自分のシステムでは View 用と Sampler 用でそれぞれ別々に確保していますが、これすら必要ないかもしれませんね。

歯抜け問題に対する方法としてはダミーアドレスを使う以外に有効なアドレスのみを1つずつコピーする手法もあります。

CopyDescriptors() 命令はそのような場合の処理にも対応しているので、適切な引数を設定すれば歯抜け部分を無視してコピーしてくれます。

個人的にはそちらの方が面倒だったので、今回はダミーアドレスを設定する方法にしています。

なお、シェーダ側でレジスタ番号を指定しない方法を採用するならダミーアドレスは不要ですが、どのレジスタがどのリソースなのかはシェーダリフレクションを使うなどで適切に対応する必要があります。

Raytracing用のDescriptorHeap戦略

こちらもやはりコピー戦略を採用しました。こちらの方がシステム化しやすいというのが大きな要因です。

ただし、通常のコピー戦略のように GlobalHeap は利用していません。

Raytracing用のリソースのバインドで問題になるのが Global と Local の RootSignature です。

Global は通常の Compute Shader 用リソースとして割り当てることが出来るので、これだけで済むのであれば Global Heap を利用すればOKです。

それに比べると Local RootSignature は Global とは寿命に違いがあります。

Global RootSignature で指定されるリソース類は基本的には1回の描画で切り替わりが発生する可能性が高いです。

例えば違うカメラでレンダリングし直しとか、頂点ライトベイクの場合はサブメッシュ単位で描画を行うとか。

つまり、可能性としては1フレーム中に複数回の変更が行われる可能性があるということです。

これに対して Local RootSignature はマテリアルの情報です。

これらはマテリアルの更新によって変更される可能性は高いですが、通常であれば1フレーム中に1回くらいしか更新は起こりません。

なお、私のサンプルは全て初期化時の1回のみです。

これらを考慮すると GlobalHeap は使用しにくいわけです。

使用するとなると Local RootSignature にバインドされる Shader Table の内容を毎フレーム書き換えねばならなくなります。

しかもそれでも Sampler 用のヒープを考慮すると安心できません。Global と Local で Sampler 用のヒープが別々になってしまうことも、単純なコピー戦略では発生する恐れがあります。

そこで、Raytracing用の DescriptorHeap は別途で作成することにしました。

実装コードは RaytracingDescriptorHeap クラスと RaytracingDescriptorManager クラスです。

RaytracingDescriptorHeap クラスは View 用と Sampler 用の DescriptorHeap を1本ずつ保持しています。

どちらも Global と Local の両方で使用します。

このクラスの初期化時には Acceleration Structure の数、Global RootSignature として登録されるそれぞれのリソースの最大数、1フレーム中での描画最大回数、マテリアル数(Shader Table数)を指定します。

各ヒープの先頭は Global 用で、1回のレンダリングで使用される最大のDescriptor数 x 1フレーム中のレンダリング回数 x スワップチェインのバッファ数をリングバッファの要領で使いまわします。

残った分は Local 用で、こちらはコピー戦略と同様に固定の最大数から Global 用のリソース数を引いた数にスワップチェインのバッファ数をかけた分だけ保持します。

言葉だと分かりづらいので図にするとこんな感じ。

これは View 用のヒープについての記述ですが、Samplerも同様です。

RaytracingDescriptorManager クラスは RaytracingDescriptorHeap クラスの実体を管理するクラスです。

Global と Local の寿命管理は基本的にこのクラスが担当します。

なのでユーザは RaytracingDescriptorHeap クラスを直接取り扱うことはしません。

また、シーンの更新によって Shader Table の数が増減した場合の対応もこのクラスが行います。

シーンが更新されても Global RootSignature の内容はまず変更されません。つまり、View や Sampler の数が増減することはないはず、という前提の設計です。

もし変化するのであれば別のマネージャを用意してもらうほうが良いでしょう。

なぜなら、その場合はほぼ確実にシェーダがやパイプラインステートも変化しているからです。

しかし Local の方はマテリアルが増える可能性があります。シーンにオブジェクトが増えたりした場合ですね。

減る場合は Local 用の Descriptor 数が減るだけなので、すでに確保されている数で事足りるわけですが、増えた場合は足りなくなります。

その場合は新しい RaytracingDescriptorHeap クラスを生成します。

ただし、生成して置き換えてはいOK、とはいかないわけです。なぜなら、そのヒープはまだ描画に使用されているかもしれないからです。

なので削除すべきヒープは一旦マネージャが確保し、数フレーム後に安全な状態で削除するようにします。

これによってシーンの更新にも耐えられる設計になっていると思います。

このクラスを使用する場合の制約としては、レジスタ番号の0番から Global 用リソースが割り当てられていなければなりません。

Global と Local が入り乱れた番号に配置されているとアウトです。

また、Acceleration Structure は t0 から割り当てる必要があります。当然 Global 用としてしか割り当てられません。

Local に AS を割り当てる理由はあまりないと思うので、その必要が出てきた場合はまた別途考えます。

さて、この戦略はどんな場合でもうまくいくかというとそうでもないです。

問題は Sampler 用ヒープの2048個問題。

現在、Sampler は最大16個のレジスタを使用できるようにしていますが、これがマテリアル数分確保されてしまうとかなりの数になります。

しかもスワップチェインのバッファ数分も確保しなければならないので、バッファ数が3の場合は最大で 16 x 3 x マテリアル数 になります。

2048に収めようとするとマテリアル数の最大数は42個だけ。流石にちょっと厳しいですね。

現在のサンプルでは問題になってないのですが、大きなシーンに対応しようとすれば確実に問題が出てくるでしょう。

解決策としては Sampler を使わない、Sampler は Global としてのみ許可という方法があるのですが、なかなか厳しいものがあります。

まあ、一応考えはあるので、その方法を模索しようとは思っています。

今後の話

現在、DXRのサンプルはシーンが固定化されていることを前提としている状態なのですが、リアルタイムに使用することを考えるとシーンが変更されたりシェーダテーブルが変更されたりは考えられることかと思います。

この辺についてもどうしようか考え中で、その辺のオブジェクト管理、更新周りをなんとかしていきたいなぁ、と考えています。

それに伴ってリアルタイムにAccelerationStructureを変更した場合の負荷とかも調べたいところですね。