[メモ]UE4のDX11のConstant Bufferの取り扱いについて

UE4のDX11実装におけるConstantBufferの取り扱いについて、見る機会があったのでメモ書きします。

2種類の実装

UE4ではConstantBuffer(以下CB)の実装が2種類存在します。
ひとつは比較的短寿命で、再利用が望めないもので、usfファイルのglobal領域に宣言されている定数を格納するCBです。
もうひとつは、比較的長寿命で再利用を期待される、BEGIN_UNIFORM_BUFFER_STRUCTを用いて生成されるCBです。
これらの実装は今のところ全く異なるようです。

短寿命で、再利用が望めないものの実装

こちらは、CBを以下のDescで生成しています。

BufferDesc.Usage = D3D11_USAGE_DEFAULT;
BufferDesc.CPUAccessFlags = 0;
BufferDesc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
BufferDesc.MiscFlags = 0;

更新は、UpdateSubresource()で、常にCBの全領域書き換えを行っています。これによって、DriverによるBufferのRenamingを可能にしています。
また、SubBufferという概念を導入しており、最大サイズのCBから、再帰的に半分のサイズのCBをあらかじめ作成しています。CPU側は、単にCBの最大サイズが格納可能なバッファをあらかじめ確保しておき、シェーダー定数の更新は一旦そのバッファに対して行います。更新データをGPUに転送する際は、シェーダー定数の更新のあった領域から、Updateに必要なサイズを割り出し、それを包含できる最小のCBに対して、UpdateSubresouce()で更新を行い、そのバッファをAPI側で利用します。
これによって、常に適切なサイズでGPUに対してデータの転送が行えます。ただし、更新するシェーダー定数の実体が小さくても、そのOffsetが大きいと、大きなBufferに対して更新処理が必要となり、常に効率が良いとは限りません。とはいえ、DX11.0で少数のシェーダー定数を効率的に更新する方法は無いので、この手法しかないというのが現状のようです。
また、これらのCBは、レンダリング全体にわたり共有されているので、Driver側で非常に多数のRenamingが発生していると思われます。
これらのバッファは、全シェーダーステージのBindSlot:0に対してのみ用いられています。SubBufferはPS/VSのみに用意されています。

比較的長寿命で再利用を期待されるものの実装

寿命の長いCBは、以下のDescで確保し、Map()/Unmap()の D3D11_MAP_WRITE_DISCARD で書き換えて使用します。

Desc.Usage = D3D11_USAGE_DYNAMIC;
Desc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
Desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
Desc.MiscFlags = 0;
Desc.StructureByteStride = 0;

UE4のコンソール変数の、 r.UniformBufferPooling がFALSEの場合は、以下のDescで、使用のたびに初期値を与えてCBを作成します。

Desc.Usage = D3D11_USAGE_IMMUTABLE;
Desc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
Desc.CPUAccessFlags = 0;
Desc.MiscFlags = 0;
Desc.StructureByteStride = 0;

これらのCBのサイズは、PowerOfTwoに限定されています。つまり、必要なCBのサイズをPoTに繰り上げてBufferを作成します。
r.UniformBufferPoolingがTRUEの場合は、使用済みのCBを直ちに開放せず、Poolに蓄積します。CBのサイズはPoTに限定されているので、Poolに必要なエントリは、数十種類あれば十分となり、それぞれのエントリに、配列でPoolされたCBを格納しておきます。また、Poolは使用が終了したフレームごとに、グループ分けして保存されています。現在の実装では3フレーム分のプールが確保されており、ラウンドロビンで、フレームごとに格納先/取り出し元が変わっていきます。従って、Poolに格納された(CBの使用が終了した)フレームから、Poolから取り出されるフレームは、3フレームの開きがあります。この実装には、コメントが添えられており、あるGPUメーカーのドライバのBugのWorkaroundとして実装された経緯がある模様です。
また、Poolには、無尽蔵に未使用エントリを貯めておくわけではなく、最後の使用から30フレーム以上を経過したものから、徐々にCBを開放していくようになっています。当然ながら、大規模シーンで一時的にCBを大量に使った後には、こうしたエントリが多数発生すると思われます。
長寿命CBは、当然ながらフレームを跨いでCBを保持することが出来ますが、Poolから取り出したCBを任意に更新する仕様にはなっていない様です。CBの更新が必要な場合は、一旦使用中のCBをPoolに戻し、新たにPoolからCBを取り出し、そのCBを更新して使用する様です。

考察

まず、短寿命と、長寿命で、CBのDescが異なりますが、どちらもDriverによるRenaminigが可能な状態での実装となっており、短寿命側では、単にCPU側でバッファを確保するので、Map()を使う理由が無く、UpdateSubresource()を用いているものと思われますが、正確な意図は不明です。
長寿命のCBのPoolの実装には工夫がみられ、現状では、理由はともかく、DriverによるBufferのRenamingを極力避けるような記述になっているのが興味深いです。
また、全てのCBのサイズをPOTに揃えているのも興味深いです。これによって、PoolされるCBの再利用性が高まるだけでなく、アプリが長時間動作した場合のCB領域のフラグメンテーションを減らす効果は容易に想像できます。ただし、これによって無駄な転送領域は確実に増えるので、各CBのサイズがどのPOTになるのかは注意が必要だと思います。

話は変わりますが、たとえば4フレーム分のPoolを用意して、4フレーム以上先のレンダリングをFenceするなどして、CBのRenamingを確実に避ける実装を行えれば、 MapNoOverwriteOnDynamicConstantBuffer の機能が使える環境では、D3D11_MAP_WRITE_NO_OVERWRITE フラグをつけてBufferの更新を行い、この機能が無いPCでは、単に DISCARD で更新するといった実装が可能になると思います。
これによる、性能向上は未確認ですが、D3D11_MAP_WRITE_NO_OVERWRITE が付いている場合は、Driverは、CBが現在GPUから参照中かどうかのチェックを行わないはずなので、Driverの動作パスが速くなる可能性はあると思います。加えて、 MapNoOverwriteOnDynamicConstantBuffer の機能が使用可能/不可な場合での、ソースの分岐は最小限に留めることが出来、どちらも性能が犠牲になることは無いと思います。