Direct3D 12 ゲームグラフィックス実践ガイド 増刷決定!!

こんにちわ。Pocolです。
技術評論社様より販売させていただいております『Direct3D 12 ゲームグラフィックス実践ガイド』ですが,おかげさまで増刷が決定しました!

Uneral EngineやUnity等が台頭する時代に逆行する内容の書籍なので,すぐに絶版するのではないか?という不安。
また,DirectX12の魔導書なども既に発売されていて,私の書籍など見向きもされないのではないか?
発売しても売れるのだろうか,そもそも買おうとすら思ってもらえないのではないか?
…など色々な不安を抱えながら,なんとか発売までたどり着いた書籍です。

この本は本当にいろいろな方にご迷惑をお掛けしながら,何とか出せた書籍ですので,増刷が決定したと連絡を受けて嬉しかったです。
本当に皆様のおかげです。本当にありがとうございます。

ただ正直,もっと批判とか苦情に近い意見が多いんだろうなと覚悟はしていたのですが,想像していたよりも少なく,また良かったという意見も全くない状態でして,著者としては書いてよかったのか,書いて悪かったのかが何にも分からない。受け入れられているのか,そうでもないのか判断に困るという状態です。
分かりづらいところがあれば,SNS等で遠慮なく書いていただきたいですし,逆にダメなところは今後の執筆に活かせるチャンスとなるので,忌憚のないご意見を書いていただけると幸いです。また,もっと書いて欲しいものとかあるのであれば,応援の意味を込めて「良かったよ!」など肯定的な意見を頂けると,励みになります。肯定的な意見が無ければ,「これはもう書かないほうがいいな」という執筆を辞める決断にもなってしまいますので,応援していただけるのであれば,応援の声も頂けるとありがたいです。

書籍のほうですが,12月ころから増刷版が市場流通する見込みと伺っておりますので,現在手に入れられていない方はもうしばらくお待ちいただければと思います。
今後ともDirect3D 12ゲームグラフィックス実践ガイドをよろしくお願いいたします。

タイル分類化による最適化(1)

こんちゃわ。Pocolです。
相も変わらず最適化でヒーヒーいっています。

ライティングシェーダって複数のマテリアルをサポートするとために,大体Uber Shaderになると思うのですが…
それだとやっぱりswtich-caseなどの分岐で重くなりがちです。
分岐を除くと,占有率の改善がみられ,速くなったりすることがあります。
そのため「分岐を無くそう!」というのが今回のネタで,それを実現するための資料について紹介します。


Deferred Lighting in Uncharted 4

まず,1つ目は「Deferred Lighting in Uncharted 4」です。
これはSIGGRAPH 2016のAdvances in Real-Time Rendering Courseで発表されています。
資料は下記からダウンロードできます。
https://advances.realtimerendering.com/s2016/index.html
もう8年前の資料なんですね。びっくり!


ディファードシェーディングすぐに肥大化します。
スキン,布,植物,メタル,髪など…をサポートする必要があります。すべてにライトタイプについて言及はしません。


マテリアル”ID”テクスチャを保存します。
– 実際のマテリアルIDではありません。単にシェーダの使用されるシェーダ機能のビットマスクです。
– 12bitを8bitへ圧縮(機能の相互排他性を考慮)


・各16×16タイルについて、タイル全体のマテリアルマスクを使用してルックアップテーブルにインデックスを付けます。
・ルックアップテーブルは事前に計算されています。タイル内のすべての機能をサポートする、可能な限りシンプルなシェーダーを保持します。


・アトミックにタイル座標を、そのシェーダーがライティングするタイルのリストにプッシュします。
・アトミック整数は dispatchIndirect 引数バッファのディスパッチカウントにもなります。


・既に大きな改善です。
・類似したテクニックは[1]で使用されています。
[1] SPU-Based Deferred Shading in Battlefield 3, http://www.dice.se/news/spu-based-deferred-shading-battlefield-3-playstation-3/


・タイル内のすべてのピクセルが同じマテリアルマスクを持つ場合に使用される、事前に計算されたもう1つのテーブル、「ブランチレス」のpermutationテーブルを作成します。
・クラス分けの際にその条件をチェックし、適切なテーブルを使用します。
・分岐をなくすだけでなく、グローバルなコンパイラ最適化の機会を開きます。


・最も悪い場合である高価なカット―シーンにおけるパフォーマンス改善
ー 4.0ms 最適化無し(“uber shader”)
ー 3.4ms (-15%) 最も良いシェーダを選択することによる
ー 2.7ms (-20%, -30% 全体的に) ブランチレスシェーダを使用することによる
・平均して、ブランチレス・シェーダーは、わずかなコストで、さらに10~20%の改善をもたらします。一方、最適なシェーダーを選ぶと、平均して20~30%の改善が得られます。


・基本性能に影響を与えることなく、マテリアルの複雑さやバリエーションを持たせることができます。
 ー1つのシェーダー(例えばシルクシェーダー)に複雑さを加えても、ゲームの他の部分には影響しません。
・インターフェイスはクリーンかつ透過的に実装されています。
 ー何度か繰り返した後
・ボーナス:分類コンピュートシェーダーは非同期コンピュートで実行され、ランタイムにはほとんど影響しません。


・システムをさらに進化させることができる。
 ーライトタイプに基づいて、異なるコンピュートシェーダーをディスパッチすることもできる。少数派のライトタイプは、複雑さとコストの大部分を追加します。
・イテレーションは難しい
 ー本当に1ビットの価値を学ぶ。
 ー最終的には良いシステムに到達した。
・よりシンプルなものは常に良いです。わずかな性能向上のために、ある機能の犠牲を避けられたと思います。

該当スライドは以上です。
上記で述べられているように,タイルごとに必要なシェーダを分類分けを行います。
非同期コンピュートで実行し,処理時間を隠蔽します。
Uncharted 4では16×16ピクセルのタイルにして,分類分けを実行し,groupId.xを下位16ビット,group.yを上位16ビットとして32bitにパッキングし,バッファに格納します。
同時に,Shader Permutationごとにカウンタをアトミックにインクリメントしますし,dispatchのカウントバッファとして利用します。
こうすることで,必要な数だけコンピュートシェーダを起動することができます。


Grappling With Performance: Rendering Optimization Strategies In Rumbleverse

つづいて,”Grappling With Performance: Rendering Optimization Strategies In Rumbleverse”という資料で,GDC 2023で発表された資料です。
こちらは昨年なので,比較的に最近の資料ですね。
下記に資料がアップされています。
https://gdcvault.com/play/1028790/Grappling-with-Performance-Rendering-Optimization

こちらはライティングではなくReflectionとSubsurfaceが重いという話に焦点が当てられています。

・オリジナルアイデア:タイル分類を使用することで、リフレクションを適用する際の占有率を向上させます。
・Ramy EI Garawanyのプレゼンテーション:Deferred Lighting in Uncharted 4にインスパイアされています。
・アルゴリズム:
1. G-Buffer解析します。
2. マテリアルプロパティに基づいてタイルのリストを構築する。
3. 異なるshader permutations + DispatchIndirect を用いてそれぞれ描画します。

リフレクションに費やされる重い時間は、ここでも同じように最適化できると思いました。8×8のピクセルグループのGバッファプロパティを見て、存在するマテリアルに基づいてリストを構築するタイル分類シェーダを書きます。そして、各リストを DispatchIndirect を使って、各ディスパッチに異なるshader permutationsをバウンドしてレンダリングします。


例えば、このフレームでは、すべてデフォルトでライティングしているピクセルや、両面フォリッジでライティングしているピクセルをはっきりと見ることができます。


そしてここで、実際のタイル分類の視覚化を見ることができます。緑のタイルはデフォルトのライティング、青はすべてのフォリッジ、そして赤は「複雑」でフルシェーダーを実行するシェーディングパスを含んでいます。

しかし、この処理で最も重要だったのは、このタイルまでで、タイルは完全にカリングされ、実行時間に最も大きな影響を与えました。このことから、タイルの分類をSSR+SSSからのカリングワークロードに対しても使用し、分類を実行するコストをレンダリングの複数のステップで共有する方法について考えるようになりました。

最速のウェイブフロントは、決して起動しないウェイブフロントであることを忘れないでください!


すべてのパスからライティングのないタイルをカリングし、すべてのピクセルがSSRトレースをトリガーするには粗すぎるかどうかの分類を追加し、スキンマテリアルがないタイルのSSSをスキップし、クリアする必要がありますが,完全なSSSセットアップを必要としないタイルの簡略化クリアを実行します。


Tile Classifyシェーダーのコードで何が起こっているかを少し見てみましょう。分類は8×8タイルで行われますが、サブサーフェススキャッタリングは半分の解像度で行われるため、各グループは16×16のエリアをカバーします。UE4 の GetScreenSpaceDataUnit 関数で gbuffer プロパティをサンプリングした後、wave ops を使用して各 8×8 タイルのビットマスクをマージします。

コードでは、UE4シェーダーAPIコマンドの WaveAllBitOr と WaveAllBitAnd で起こっていることがわかります。これらのウェーブ操作の後、ウェーブフロントの各スレッドは MergedResult に同じマスク値を保持します。

ウェーブ操作を使用する利点の1つは、コンパイラがMergedResultがwave全体で均一であることを知っているため、waveコマンドに続くロジックがすべてスカラーALUになることです。


次に、ウェーブ全体にわたってMergedResultに保持されているビットに基づいてタイルのshader permutationが選択され、結果が最初のスレッドによって書き込まれますinterlocekdされた加算がカウントで発生し、タイル位置バッファにマップされるタイルの一意のインデックスを取得します。タイル位置バッファは、画面上の特定のタイルのピクセル位置を保持し、適用シェーダーで各タイルのピクセル位置を再構築するために使用されます。

なお、8×8タイルを選んだのは、GCNで1ウェーブフロント(64スレッド)のサイズだからです。アンチャーテッド4では16×16タイルを使用し、タイルリストに必要なメモリを25%削減した。タイルロケーションリストは、最大タイル数*permutation数に等しいメモリを必要とします。8×8タイルは、高価なマテリアルパスの境界をより厳しくすることができます。私が8×8を選んだのは、permutation数がより限られているからでもあります。たとえば、フォリッジを含むタイルのパスを追加してみたり、default litしているタイルのパスを追加してみたり。

現在、10個のシェーダーパーミュテーションがあり、その結果、1080の8×8タイルで1.296MBのタイルロケーションバッファになります。ハーフ解像度タイルリストに過剰に割り当てなければ48kbを取り戻すことができるはずですが、メモリのほとんどはSSR+Reflection Applyに使用される8つのpermuationから来ています。

uint bAnySSSProfile = 0;

// loop over each 8x8 tile within the 16x16 pixel area
uint2 PixelOffsets[4] = { uint2(0, 0), uint2(1, 0), uint2(0, 1), uint2(1, 1) };
UNROLL
for(int i=0; i<4; ++i)
{
     uint2 PixelPos = (DispatchThreadId.xy * 2 + ViewDimensions.xy);
     FScreenSpaceData ScreenSpaceData = GetScreenSpaceDataUint(PixelPos + (PixelOffsets[i] * 8));
     FGBufferData InGBufferData = ScreenSpaceData.GBuffer;

     uint bIsDefaultLit = (InGBufferData.ShadingModelID == SHADINGMODELID_DEFAULT_LIT) ? 1 : 0;
     uint bIsFoliageLit = (InGBufferData.ShadingModelID == SHADINGMODELID_TWOSIDED_FOLIAGE) ? 1 : 0;
     uint bIsComplexLit = (InGBufferData.ShadingModelID > SHADINGMODELID_DEFAULT_LIT) ? 1 : 0;
     uint bIsSSSProfile = UseSubsurfaceProfile(InGBufferData.ShadingModelID) ? 1 : 0;

     float Roughness = InGBufferData.Roughness;
     float RoughnessFade = GetRoughnessFade(Roughness);
     uint bSkipSSR = (RoughnessFade <= 0.0 || InGBufferData.ShadingModelID == SHADIGNMODELID_UNLIT) && InGBufferData.ShadingModelID != SHADINGMODELID_CLEAR_COAT;

     // OR results
     uint MergedResult = (bIsSSSProfile << 2) | (bIsComplexList << 1) | bIsDefaultLit;
     MergedResult = WaveAllBitOr(MergedResult);
     uint bAnyDefaultLit = MergedResult & (1 << 0);
     uint bAnyComplexLit = MergedResult & (1 << 1);
     bAnySSSProfile = bAnySSSProfile | (MergedResult & (1 << 2));

     // AND result.
     MergedResult = (bSkipSSR << 2) | (bIsFoliageList << 1) | bIsDefaultLit;
     MergedResult = WaveAllBitAnd(MergedResult);
     uint bAllDefaultLit = MergedResult & (1 << 0);
     uint bAllFoliageLit = MergedResult & (1 << 1);
     uint bAllSkipSSR = MergedResult & (1 << 2);

     // select which permutation
     uint PermutationIndex = NUM_PREMUTATIONS;
     if (bAllFliageList)
     {
         PermutationIndex = 4;
     }
     else if (bAllDefaultLit)
     {
         PermutationIndex = 6;
     }
     else if (bAllComplexLit)
     {
         PermutationIndex = 0;    
     }
     else if (bAnyDefaultLit)
     {
         PermutationIndex = 2;
     }

     // odd half of permutations lacks SSR completely
     if (bAllSkipSSR)
     {
         PermutationIndex += 1;
     }

     // write out the 8x8 data
     // first thread does atomic increment and write, fully unlit tiles are skipped entirely
     if (GroupIndex == 0 && PermutationIndex < NUM_PERMUTATIONS)
     {
         uint TileIndex;
         InterlockedAdd(RWTileDispatchCounts[PermutationIndex * 3], 1, TileIndex);

         uint TileLocationID = TileIndex + (PermutationIndex * NumTiles);
         uint2 TileLocation = (GroupId * 2) + PixelOffsets[i];
         RWTileLocationsBuffer[TileLocationID] = TileLocation.x | (TileLocation.y << 16);
     }
 
     // SSS permutations go beyond the end of the normal reflection tile permutations
     uint SSSPermutationIndex = NUM_PERMUATIONS + 1;
     if (bAnySSSProfile)


それは注目する価値があります – このシェーダの本当に素晴らしい点の1つは、GBufferプロパティをサンプリングするこれらの呼び出しがすべて1つのテクスチャ読み取りにマップされることです。Epicは便利なことに、すべての情報を1つのGBufferターゲットに既にパックしています。このターゲットには、ラフネスとマテリアルIDの両方が保持されています。


Razorに戻って、この分類シェーダーの実行コストを見てみましょう。ベースPS4で1080pの場合、0.18ミリ秒と控えめで、デカールがGBufferを変更し終わるとすぐに実行できます。


この分類処理は、非同期コンピュートを使用したシャドウ深度レンダリングと非常にうまく重なり、ここでは、マスクされたマテリアルのピクセルシェーダーウェイブが実行される前に、いくつかの頂点シェーディング処理と重なっているのがわかります。これはフレームに依存しますが、一般的に非同期で実行することでフレーム時間が約0.1ミリ秒短縮され、PS4では約0.08ミリ秒のコストになります。


適用ステップのパフォーマンスを確認する前に、環境ライティングの適用に適用するコンピュートシェーダだけを見てみましょう。これは元の実装のフルスクリーンピクセルシェーディングパスにすぎず、このコンピュートシェーダーパスは、さまざまなshader permutationを持つDispatchIndirectの繰り返し呼び出しを使用して実行されます。

シェーダは、GroupIdを使用してタイルロケーションバッファを検索し、GroupThreadIdに基づいて個々のピクセル位置にアンパックすることによって始まります。GBuffer が読み込まれた後、ShadingModelID が上書きされることで、オプティマイザがshader permuationに基づいて定義されたプリプロセッサマクロに基づいてデッドコードの除去を実行することができます。

// compute version of reflection and skylighting for dispatching tiles classified by shader featuress needed
[numthreads(8, 8, 1)]
void ReflectionEnvironmentSkyLightingCS(
    uint3 GroupId : SV_GroupId,
    uint3 DispatchThreadId : SV_DispatchThreadID, // DispatchThreadId = GroupId * int2(dimx, dimy) + GroupThreadId
    uint3 GroupThreadId : SV_GroupThreadID, // 0 ... THREADGROUP_SIZEX 0... THREADGROUP_SIZEY
    uint GroupIndex : SV_GroupIndex) // SV_GroupIndex = SV_GroupThreadID.z * dimx * dimy + SV_GroupThreadID.y * dimx + SV_GroupThreadId.x
{
    // lookup into tile data with gorup ID
    uint TileLocationData = TileLocationBuffer[GroupId.x + TILE_PERMUTATION * NumTiles];
    // unpack tile location
    uint2 PixelPos = 0;
    PixelPos.x = (TileLocationData & 0xFFFF) * 8 + GroupThreadId.x;
    PixelPos.y = (TileLocationData >> 16) * 8 + GroupThreadId.y;
    PixelPos += ViewDimensions.xy;

    float3 UVAndScreenPos;
    UVAndScreenPos.xy = (float2(PixelPos.xy + .5f) / (ViewDimensions.zw - ViewDimensions.xy);
    UVAndScreenPos.zw = float2(2.0f, -2.0f) * UVANdScreenPos.xy + float2(-1.0f, 1.0f);

    float4 SvPosition = float2(PixelPos.x, PixelPos.y, 0.f, 1.f);
    float2 BufferUV = UVAndScreenPos.xy;
    float2 ScreenPosition = UVAndScreenPos.zw;

    // Sample scene textures.
    FGBufferData GBuffer = GetGBufferDataFromSceneTextures(BufferUV);

    // Sample the ambient occlusion that is dynamically generated every frame.
    float AmbientOcclusion = AmbientOcclusionTexture.SampleLevel(AmbientOcclusionSampler, BufferUV, 0).r;

    // override GBuffer Data if all pixels have same type
#if ALL_DEFAULT_LIGHTING
    GBuffer.ShadingModelID = SHADINGMODELID_DEFAULT_LIT;
#elif ALL_FOLIAGE_LIGHTING
    GBuffer.ShadingModelID = SHADINGMODELID_TWOSIDED_FOLIAGE+
#elif !HAS_COMPLEX_LIGHTING
    // if no complex lighting pixels we can do this clamp as a hint that everything is either unlit or default lit
    GBuffer.ShadingModelID = clamp(SHADINGMODELID_UNLIT, SHADINGMODELID_DEFAULT_LIT, GBuffer.ShadingModelID);
#endif


では、この0.08ミリ秒が、SSR、反射環境、SSSの適用において何を意味するのかを見ていく必要があります。これが、以前お見せしたシェーディングのオリジナルシーケンスです。


そして,これが我々の新しいフレームです。


ここでは、Tiled Reflection適用シェーダーが1.08msで、0.13ms向上しています。このメリットの約半分は、このフレームでスカイピクセルをカリングしたことによるものなので、スカイピクセルのないフレームではあまり意味がありません。ここで私が指摘したい1つのマイクロ最適化は、最初のバリアの後に、占有率の低い遅いウェーブを最初に並べ、最後に最も速いウェーブを並べるということです。これは、占有率の低いウェーブが、より多くのレジスタが使用可能になるのを待っているためだと思います。また、稼働率の低いウェーブほど稼働時間が長くなる傾向があるため、最速のバッチを最後に置くことで、次のバリアまでに仕事がすぐになくなるようにしています。


TiledReflectionの適用がわずかな利益を得ているのに対し、Screen Space Reflectionsは逆に実に大きな利益を得ています。0.3ms向上していますが、これはDFAOの履歴更新でウェーブがうまく重なっているためで、実際には控えめな改善です。これは、ウェーブがDFAOの履歴更新とうまく重なるようになったためです。これらは別々のバッファに書き込まれ、両方とも反射の適用に送られるため、バリアは必要ありません。SSRを使ったこれらの結果は、これが価値ある最適化になるという確信を最初に与えてくれました。


そして反射を適用した後のサブサーフェスも、0.58msと大幅に改善されています。


ここでは、セットアップとタイルクリアが実にきれいに重なり、タイルクリアはフルセットアップシェーダーよりもはるかに短いウェーブを持っているのがわかる。ブラーステップは純正のタイル分類化と同様で、スキンのあるタイルだけが実行されるため、再結合は非常に高速です。


さて、ここまで説明したところで……このパスが以前はどうだったのか、もう一度思い出しましょう。


そして結果に戻りましょう。これらのパスにより、合計で~1msの節約になりますが、これらの利点はシーンの構図によって異なるため、分類コストを差し引くと、このショットでは合計0.92msになります。


おわりに

今回は,最適化ネタの一つしてタイル分類化の資料を紹介してみました。
ライティング・SSR・Subsurfaceあたりにも適用できるので,かなり最適化に効きそうです。実際にPS4で1ms程度の改善があるという実績があるのも良いですね。
最近だとUE5のNaniteによる描画とかでも使われていますよね。
実装自体は,Wave64モードにしてタイルサイズを8×8にしてWave組み込み命令を駆使するのが個人的には妥当な気がします。
次回は,実装方法について紹介できるといいなと思っています。
他にもいい資料をご存じの方は,是非コメント等でご紹介ください。

アルファテストの改良

こんばんみん。
Pocolです。

ネットで記事を漁っていたら,アルファテストの品質向上の手法についての記事を見つけました。
https://asawicki.info/articles/alpha_test.php5

以前ゲーム開発をしていた際に,キャラクタの髪の毛や動物の毛周りで困ることがあったので,ハッシュ化アルファテストなどを試してみたのですが,結構ちらつきがやっぱりきになっちゃうなーと思っていましたし,意外と計算量多いんですよね。もっと手軽でそれっぽい方法無いかなーって常々思っていたのですが,記事で紹介している手法はかなりシンプルなので,個人的には「これでよくね?」って感じています。
アルファ値を次のように変えるのと,事前準備としてPhotoShopなどでSolidifyフィルタを使用してテクスチャエッジ部分の色を引き延ばしてテクスチャを作成しておけばよいみたいです。

 float alphaNew = max(alpha, (1.0/3/0) * alpha + (2.0/3.0) * threshold);
 if (alphaNew < threshold)
     discard;

Wave組み込み命令トリック

こんばんわ。
Pocolです。

Angry Tomato!さんという方が,“Compute shader wave intinsics tricks”
という記事を書いているので紹介です。
この記事では,以下のテクニックを紹介しています。

  • Branch optimization
  • Calculate on one lane, read on all
  • Serialization of Writing Data
  • Scalarization
  • Multiple wave parallelization
  • Indirect dispatch thread group count calculation
  • Dividing the work between lanes

非常に面白い内容だと思うので,見ていない方は是非見るとよいでしょう。

WaveActiveLerp()について

こんちゃわ。Pocolです。
Wave組み込み命令の記事を漁っていたら,GithubにWaveActiveLerp()の実装を書いている人がいたので紹介しようと思います。
下記に説明の記事があります。
https://github.com/AlexSabourinDev/cranberry_blog/blob/master/WaveActiveLerp.md

実装は,https://github.com/AlexSabourinDev/cranberry_blog/blob/master/WaveActiveLerp_Shaders/WaveActiveLerp.hlslにあって,次のような感じみたいです。

uint WaveGetLastLaneIndex()
{
	uint4 ballot = WaveActiveBallot(true);
	uint4 bits = firstbithigh(ballot); // Returns -1 (0xFFFFFFFF) if no bits set.
	
	// For reasons unclear to me, firstbithigh causes us to consider `bits` as a vector when compiling for RDNA
	// This then causes us to generate a waterfall loop later on in WaveReadLaneAt :(
	// Force scalarization here. See: https://godbolt.org/z/barT3rM3W
	bits = WaveReadLaneFirst(bits);
	bits = select(bits == 0xFFFFFFFF, 0, bits + uint4(0, 32, 64, 96));

	return max(max(max(bits.x, bits.y), bits.z), bits.w);
}

float WaveReadLaneLast(float t)
{
	uint lastLane = WaveGetLastLaneIndex();
	return WaveReadLaneAt(t, lastLane);
}

// Interpolates as lerp(lerp(Lane2, Lane1, t1), Lane0, t0), etc
// 
// NOTE: Values need to be sorted in order of last interpolant to first interpolant.
// 
// As an example, say we have the loop:
// for(int i = 0; i < 4; i++)
//    result = lerp(result, values[i], interpolations[i]);
// 
// Lane0 should hold the last value, i.e. values[3]. NOT values[0].
// 
// WaveActiveLerp instead implements the loop as a reverse loop:
// for(int i = 3; i >= 0; i--)
//    result = lerp(result, values[i], interpolations[i]);
// 
// return.x == result of the wave's interpolation
// return.y == product of all the wave's (1-t) for continued interpolation.
float2 WaveActiveLerp(float value, float t)
{
	// lerp(v1, v0, t0) = v1 * (1 - t0) + v0 * t0
	// lerp(lerp(v2, v1, t1), v0, t0)
	// = (v2 * (1 - t1) + v1 * t1) * (1 - t0) + v0 * t0
	// = v2 * (1 - t1) * (1 - t0) + v1 * t1 * (1 - t0) + v0 * t0

	// We can then split the elements of our sum for each thread.
	// Lane0 = v0 * t0
	// Lane1 = v1 * t1 * (1 - t0)
	// Lane2 = v2 * (1 - t1) * (1 - t0)

	// As you can see, each thread's (1 - tn) term is simply the product of the previous thread's terms.
	// We can achieve this result by using WavePrefixProduct
		
	float prefixProduct = WavePrefixProduct(1.0f - t);
	float laneValue = value * t * prefixProduct;
	float interpolation = WaveActiveSum(laneValue);

	// If you don't need this for a continued interpolation, you can simply remove this part.
	float postfixProduct = prefixProduct * (1.0f - t);
	float oneMinusT = WaveReadLaneLast(postfixProduct);

	return float2(interpolation, oneMinusT);
}

いまのところで,使いどころがパッと浮かばないのですが,知っていればどこかで使えそうな気がしています。
…というわけで,WaveActiveLerp()の実装紹介でした。

WaveCompactValue()について

最近、最適化で忙しいPocolです。
皆さん、お元気でしょうか?

今日は,WaveCompactValue()を勉強しようかなと思いましたので,そのメモを残しておこうと思います。
この関数は,[Drobot 2017]で紹介された手法です。

スライドに掲載されている実装は下記のよう感じです。

uint WaveCompactValue( uint checkValue )
{
    ulong mask; // lane unique compaction mask
    for ( ; ; ) // Loop until all active lanes removed
    {
        uint firstValue = WaveReadFirstLane( checkValue );
        mask = WaveBallot( firstValue == checkValue ); // mask is only updated for remaining active lanes
        if ( firstValue == checkValue ) break; // exclude all lanes with firstValue from next iteration
    }
    // At this point, each lane of mask should contain a bit mask of all other lanes with the same value.
    uint index = WavePrefixSum( mask ); // Note this is performed independently on a different mask for each lane.
    return index;
}

これをHLSLに書き直すと次のような感じになるかとおもいます。

uint WaveCompactValue(uint checkValue)
{
  // レーンのユニークなコンパクションマスク.
    uint4 mask;
    
    // すべてのアクティブレーンが取り除かれるまでループ.
    for (;;)
    {
        // アクティブレーンの最初の値を読み取る.
        uint firstValue = WaveReadLaneFirst(checkValue);

        // mask は残っているアクティブレーンに対してのみ更新される.
        mask = WaveActiveBallot(firstValue == checkValue);

        // firstValue を持つすべてのレーンを次のイテレーションから除外する。
        if (firstValue == checkValue)
             break;
    }
    // この時点で、マスクの各レーンは、同じ値を持つ他のレーンのすべてのビットマスクを含んでいなければならない。
    uint index = WavePrefixSum(mask); // これはレーンごとに異なるマスクで独立して行われる。
    return index;
}

さて,このWaveCompactValue()ですが,どういった使い道があるかというと,分類分けに使用することができます。
元々の[Drobot 2017]では色々なスレッドからAtomic操作をすると重くなるため,Atomic操作を減らす目的のために使われていました。
詳細な説明は,[Drobot 2017]のスライド51にアニメーション付きで載っていますので,そちらを参照してください。
軽く図の説明だけ載せておきます。




ちなみにグループ分けのよう番号を別途作りたい場合は,

uint2 WaveCompactValue(uint checkValue)
{
  // レーンのユニークなコンパクションマスク.
    uint4 mask;

    // グループ分け番号.
    uint groupIndex = 0;
    
    // すべてのアクティブレーンが取り除かれるまでループ.
    for (uint i=0; ; ++i)
    {
        // アクティブレーンの最初の値を読み取る.
        uint firstValue = WaveReadLaneFirst(checkValue);

        // mask は残っているアクティブレーンに対してのみ更新される.
        mask = WaveActiveBallot(firstValue == checkValue);

        // グループ分け番号を更新.
        groupIndex = i;

        // firstValue を持つすべてのレーンを次のイテレーションから除外する。
        if (firstValue == checkValue)
             break;
    }
    // この時点で、マスクの各レーンは、同じ値を持つ他のレーンのすべてのビットマスクを含んでいなければならない。
    uint index = WavePrefixSum(mask); // これはレーンごとに異なるマスクで独立して行われる。
    return uint2(index, groupIndex);
}

のように実装すると良いみたいです。
WaveCompactValue()はタイルの分類分けやマテリアルの分類分けなんかの場面で有効活用できそうな気がしています。
…というわけで,WaveCompactValue()を使って分類分けすれば,無駄なAtomic操作を減らせるので,高速化できるよ!という話でした。

参考文献

・[Drobot 2017] Michal Drobot, “Improved Culling for Tiled and Clustered Rendering Call of Duty Infinite Warfare”, SIGGRAPH 2017 Advances in Real-time Rendering and Games course, https://advances.realtimerendering.com/s2017/index.html

Bilateral Upsampling

こんにちわ,Pocolです。
今日はバイラテラルアップサンプリングについてメモをしておこうと思います。
パフォーマンスを稼ぐために,低解像度で描画しておき,それを元解像度に戻したいという場面が,ゲームグラフィックスでは多々出てきます。具体的には,SSAOやSSRなどの計算です。
ただ単にバイリニア補間で元解像度に戻してしまうとエッジ部分などでアーティファクトが発生してしまうことがあります。
こうしたアーティファクトを避けるために使われる手法の中の一つとして,Bilateral Upsamplingがあります。

通常のバイリニア補間は4点から計算を行います。

バイラテラルアップサンプリングは,法線と深度によってバイリニアウェイトを修正します。サンプルは以下のように,バイリニアの重み,法線の類似度による重み,深度の類似度による重みの3つによって重みづけされます。

バイリニアの重みは以下です。

法線の重みは次のように求めます。

深度の重みは次のように求めます。

以上から求められた重みを使ってサンプルを重みづけします。下図の通りです。

実装例ですが,もんしょさんが「DirectXの話 第121回 Bilateral Upsampling」の記事にてサンプルコードをアップしてくださっています。有難いです。
シェーダコードを抜粋すると下記の通りです。

float4 RenderUpsamplingPS( OutputVS inPixel ) : SV_TARGET
{
	const float2 kScreenSize = g_ScreenParam.xy * 2.0;
	const float2 kScreenHalfSize = g_ScreenParam.xy;
	const float4 kBilinearWeights[4] =
	{
		float4( 9.0/16.0, 3.0/16.0, 3.0/16.0, 1.0/16.0 ),
		float4( 3.0/16.0, 9.0/16.0, 1.0/16.0, 3.0/16.0 ),
		float4( 3.0/16.0, 1.0/16.0, 9.0/16.0, 3.0/16.0 ),
		float4( 1.0/16.0, 3.0/16.0, 3.0/16.0, 9.0/16.0 )
	};

	// Hi-Resピクセルのインデックスを求める
	int2 hiResUV = (int2)(inPixel.texCoord0 * kScreenSize + float2(0.1, 0.1));
	int hiResIndex = (1 - (hiResUV.y & 0x01)) * 2 + (1 - (hiResUV.x & 0x01));
	float4 hiResND = texNormalDepth.Load( int3(hiResUV, 0), int2(0, 0) );

	// Low-Resから4ピクセルの法線・深度を求める
	int2 lowResUV = (int2)(inPixel.texCoord0 * kScreenHalfSize.xy + float2(0.1, 0.1));
	float4 lowResND[4];
	float lowResAO[4];
	switch (hiResIndex)
	{
	case 0:
		lowResND[0] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(0, 0) );
		lowResND[1] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(1, 0) );
		lowResND[2] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(0, 1) );
		lowResND[3] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(1, 1) );
		lowResAO[0] = texHDAO.Load( int3(lowResUV, 0), int2(0, 0) ).r;
		lowResAO[1] = texHDAO.Load( int3(lowResUV, 0), int2(1, 0) ).r;
		lowResAO[2] = texHDAO.Load( int3(lowResUV, 0), int2(0, 1) ).r;
		lowResAO[3] = texHDAO.Load( int3(lowResUV, 0), int2(1, 1) ).r;
		break;
	case 1:
		lowResND[0] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(-1, 0) );
		lowResND[1] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(0, 0) );
		lowResND[2] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(-1, 1) );
		lowResND[3] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(0, 1) );
		lowResAO[0] = texHDAO.Load( int3(lowResUV, 0), int2(-1, 0) ).r;
		lowResAO[1] = texHDAO.Load( int3(lowResUV, 0), int2(0, 0) ).r;
		lowResAO[2] = texHDAO.Load( int3(lowResUV, 0), int2(-1, 1) ).r;
		lowResAO[3] = texHDAO.Load( int3(lowResUV, 0), int2(0, 1) ).r;
		break;
	case 2:
		lowResND[0] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(0, -1) );
		lowResND[1] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(1, -1) );
		lowResND[2] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(0, 0) );
		lowResND[3] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(1, 0) );
		lowResAO[0] = texHDAO.Load( int3(lowResUV, 0), int2(0, -1) ).r;
		lowResAO[1] = texHDAO.Load( int3(lowResUV, 0), int2(1, -1) ).r;
		lowResAO[2] = texHDAO.Load( int3(lowResUV, 0), int2(0, 0) ).r;
		lowResAO[3] = texHDAO.Load( int3(lowResUV, 0), int2(1, 0) ).r;
		break;
	case 3:
		lowResND[0] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(-1, -1) );
		lowResND[1] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(0, -1) );
		lowResND[2] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(-1, 0) );
		lowResND[3] = texHalfNormalDepth.Load( int3(lowResUV, 0), int2(0, 0) );
		lowResAO[0] = texHDAO.Load( int3(lowResUV, 0), int2(-1, -1) ).r;
		lowResAO[1] = texHDAO.Load( int3(lowResUV, 0), int2(0, -1) ).r;
		lowResAO[2] = texHDAO.Load( int3(lowResUV, 0), int2(-1, 0) ).r;
		lowResAO[3] = texHDAO.Load( int3(lowResUV, 0), int2(0, 0) ).r;
		break;
	}

	// 法線のウェイトを求める
	float totalWeight = 0.0;
	float ao = 0.0;
	for( int i = 0; i < 4; ++i )
	{
		// 法線のウェイトを求める
		float normalWeight = dot( lowResND[i].xyz, hiResND.xyz );
		normalWeight = pow( saturate(normalWeight), 32.0 );

		// 深度のウェイトを求める
		float depthDiff = hiResND.w - lowResND[i].w;
		float depthWeight = 1.0 / (1.0 + abs(depthDiff));

		// 総合する
		float weight = normalWeight * depthWeight * kBilinearWeights[hiResIndex][i];
		totalWeight += weight;
		ao += lowResAO[i] * weight;
	}

	ao /= totalWeight;

	return float4(ao, ao, ao, 1);
}

…ということで,Bilateral Upsamplingの話でした。
もしかしたら,Quad系のWaveIntrinsics使って実装した方がナウいかもしれないですね(※試してないので,出来なかったらごめんなさい)。

※追記
Quad Intrinsics使って実装できました。
WaveGetLaneIndex() % 4でhiResIndexを算出します。一度現在位置での,lowResNDとlowResAOを先頭の方でサンプリングしておき,あとはループでQuadReadLaneAt(lowResND, i)と QuadReadLaneAt(lowResAO, i)で,処理対象を持ってきます。これでswitchケース分が丸っとなくせるのと,テクスチャフェッチ回数が減らせます。

サヨナラGLSL…

こんにちわ,Pocolです。
久しぶりにa3dの更新作業をやっています。
今日は,ついにGLSLから脱却する対応を入れました。

…といっても大したことはしていなくて,DXCからSpir-Vを生成するようにしただけです。
ただ,普通のHLSLの記述だと対応できないそうなので,マクロをかまして対応するという感じにしました。
マクロの定義は下記の通り。

#ifdef __spirv__
#define A3D_LOCATION(index)              [[vk::location(index)]]
#define A3D_RESOURCE(var, reg, index)    [[vk::binding(index)]] var
#define A3D_FLIP_Y(var)                  var.y = -var.y
#else
#define A3D_LOCATION(index)
#define A3D_RESOURCE(var, reg, index)    var : register(reg)
#define A3D_FLIP_Y(var)
#endif

Vulkanの座標系はDirectXと比べて上下逆になる座標系なので,フリップ用のマクロも定義してあります。
実際のシェーダは下記のように書きます。

///////////////////////////////////////////////////////////////////////////////////////////////////
// VSOutput structure
///////////////////////////////////////////////////////////////////////////////////////////////////
struct VSOutput
{
    A3D_LOCATION(0) float4 Position : SV_POSITION;
    A3D_LOCATION(1) float2 TexCoord : TEXCOORD;
};

///////////////////////////////////////////////////////////////////////////////////////////////////
// PSOutput structure
///////////////////////////////////////////////////////////////////////////////////////////////////
struct PSOutput
{
    A3D_LOCATION(0) float4 Color : SV_TARGET0;
};

//-------------------------------------------------------------------------------------------------
// Samplers and Textures
//-------------------------------------------------------------------------------------------------
A3D_RESOURCE(SamplerState ColorSmp, s0, 1);
A3D_RESOURCE(Texture2D    ColorMap, t0, 2);

//-------------------------------------------------------------------------------------------------
//      ピクセルシェーダメインエントリーポイントです.
//-------------------------------------------------------------------------------------------------
PSOutput main(VSOutput input)
{
    PSOutput output = (PSOutput)0;

    output.Color = ColorMap.Sample( ColorSmp, input.TexCoord );

    return output;
}

あとは,dxc.exeで -spirv のオプションを付けてコンパイルするだけです。
DirectX用とVulkan用のシェーダバイナリの両方が1つのシェーダコードから生成できます。

これで,GLSLともようやくサヨナラです。

GPU最適化備忘録

こんにちわ,Pocolです。
今日はGPU最適化のための自分用のメモを書いておこうと思います。
自分がある程度理解できればいいことを前提に書いているので,「そもそも間違っている」とか「全然正しくない」とか「こうした方がもっといい」とかあれば,ご指摘ください。

P3 Method

基本的には,Louis Bavoil氏が書いている「The Peak-Performance-Percentage Analysis Method for Optimizing Any GPU Workload」に従ってボトルネックを探し,改善を行っていきます。GDCでも講演があるので,興味がある方はそちらの動画を見ると良いと思います。
この手法は次のステップで最適化を行います。

(1) “Top SOL%”の値をチェックする。
– (A) 80%より大きいの場合: SOLユニットから処理を取り除くことを試みる。
– (B) 60%より小さいの場合: top SOL%の値が増加するように試みる。
– 上記以外の場合: (A)と(B)の両方を行う。

(2) Top SOLユニットがSMの場合(あるいは,SOL%の項目が近い場合)
 “SM Throughput for Active Cycles”の値をチェックする。
– (C) 80%より大きい場合: 命令群を適宜スキップしてみたり、特定の計算をルックアップテーブルに移行することを検討してみる。
– (D) 60%より小さい場合: SM占有率(実行中のアクティブワープ数)の増加を試みる & SM発行ストールサイクルの数を減らすことを試みる
– 上記以外の場合: (C)と(D)の両方を行う

(3) その他がTop SOLユニットの場合、そのユニットに送られる作業量を減らすことを試みる。

ここまで出てきた用語は次の意味です。

  • SOL:Speed Of Light。最大スループットを意味する。
  • SM:Streaming Multiprocessor。共有メモリやコンスタンスとキャッシュ,テクスチャキャッシュ,複数のスカラープロセッサなどから構成されるもの。ものすごく雑に言えばUnified Shaderのこと。
  • Throughput。ハードウェアが単位時間あたりに処理できるデータ量のこと。あるいは,その数値を使ってデータ処理能力やデータ転送速度を表す。

さて,最適化する上で肝心なのは「Top SOLをどうやって調べるか?」ということになります。これはNVIDIA Nsight Graphicsを使って調べることができます。
まず,Nsightを立ち上げる前にパフォーマンスカウンターなどを取得できるように設定を変更しておきます。NVIDIAコントロールパネルを開きます。Windowsのタスクバー右下にある^をクリックして,NVIDIA設定を右クリックし,NIVIDIAコントロールパネルを選択します。選択してもコントロールパネルが開かない場合は,何らかの異常が発生している恐れがあるのでグラフィックスドライバーを再インストールして改善するか試してみてください。コントロールパネルを開いたら,メニューの「デスクトップ」から「開発者設定を有効にする」にチェックを入れておきます。これでカウンター等の値がとれるようになるはずです。

図1.NVIDIAコントールパネルでの設定

続いて計測を行います。Nsight Graphicsを立ち上げFrame Profilerとしてアプリケーションを立ち上げます。立ち上げ方はConnect to processのウィンドウで,Activityの項目をFrame Profilerに設定して,Application Executableにアプリケーション実行パスを指定,Working Directoryに作業ディレクトリを設定してください。自動的にアタッチする場合はAutomatically ConnectにYesを,Steamなどのようにいったん別exeをかましてから立ち上げる場合はNoに設定しておき,Target PlatformでAttachタブを選択してから,プロセスにアタッチするという方法を取ります。
アプリケーションが立ち上がったら,左上にオーバーレイが表示されるので,F11を押して測定したいフレームをキャプチャーします。

図2.オーバーレイの表示

キャプチャーすると,画面右上にRange Profilerというものが表示されるので,調べたいドローコール部分をRange Profilerでクリックします。クリックすると,少し時間をおいてから次のようにRange InfoやPipeline Overviewなどが表示されます。

図3.フレームキャプチャー

あとは,Pipeline Overviewに表示されるSM / L2 / VRAM / CROP / RAS の値を見て上記のステップに沿って改善を行います。

図4.Pipeline Overview

(A) Top SOL% > 80% の場合

GPU上で非常に効率的に動作していることが分かります。高速化するためには,このTop SOLユニットを処理から削除し,他のユニットのボトルネックを探していきます。例えば SM が Top SOL ユニットの場合は,命令群をスキップしてみたりなど。もう一つの例としては構造化バッファのロードを定数バッファに移して高速化するなどです。

(B) Top SOL% < 60% の場合

トップ SOL%の値が60%未満場合、トップ SOL ユニットと SOL% が低い他のすべての GPU ユニットは、使用率が低い(アイドルサイクル)、非効率的に動作している(ストールサイクル)、または与えられた作業負荷の仕様により高速パスに当たらないということを意味します。このような状況の例としては、以下のようなものがあります。

  • アプリケーションが一部CPUに制限される
  • Wait For IdleコマンドやGraphic←→ComputeスイッチでGPUパイプラインを何度も消耗している
  • テクスチャオブジェクトからのTEXフェッチは、フォーマット、次元、またはフィルタモードによって、設計上、スループットが低下して実行されます(GTX 1080のこれらの合成ベンチマークを参照)。例えば、3Dテクスチャをトライリニアフィルタリングでサンプリングする場合、50%のTEX SOL%が期待されます。
  • TEXやL2ユニットのキャッシュヒット率が低い、VRAMアクセスがまばらでVRAM SOL%が低い、GPU VRAMではなくシステムメモリからVB/IB/CB/TEXをフェッチするなど、メモリサブシステムの非効率性。
  • 入力アセンブリの32ビットインデックスバッファのフェッチ(16ビットインデックスに比べ半減)

この場合、トップSOL%の値を使用して、非効率な処理を削減することによってこの処理負荷で達成できる最大利得の上限を導き出すことができます。

(C) SM Throughput For Active Cycles > 80% の場合

SM がトップ SOL ユニットで、「SM Throughput For Active Cycles」が 80%より大きい場合、現在の処理負荷は主に SM スケジューラの発行率によって制限されているため、SM の占有率を上げてもパフォーマンスは大きく向上しません(少なくとも、処理負荷は 5%以上増加しない)。

この場合,次のステップは、SMスケジューラの帯域幅を飽和させているのはどのような命令かを把握することです。典型的なのは算術命令(FP32や整数演算)ですが、テクスチャフェッチや共有メモリアクセスなどのメモリ命令もあり得えます。また、SMがトップSOLユニットで、SM Throughput for Active Cyclesが80%以上のワークロードでは、TEXユニットもSMのSOL%に近い値でなければ、TEX命令(SRVおよびUAVアクセス)が性能を制限している可能性はないでしょう。

(D) SM Throughput For Active Cycles < 60% の場合

このGTC 2013の14分の講演のスライド15にあるように、あるワープ命令が発行できない場合(オペランドの準備ができていない、あるいは実行に必要なパイプラインサブユニットの準備ができていないため、これをワープストールと呼びます)、SM命令スケジューラは、別のアクティブワープに切り替えてレイテンシーを隠蔽しようとします。そこで、SMスケジューラがSMアクティブサイクルあたりにより多くの命令を発行できるようにするには、2つの方法があります。

(1)SMの占有率(スケジューラが切り替え可能なアクティブなワープの数)を上げる
(2)SM発行ストール待ち時間を短縮する(ワープがより少ないサイクルでストール状態に留まるようにする)。

アプローチ1:SM占有率を増加する

SMがトップSOLユニット(またはそれに近い)、「アクティブサイクルのSMスループット」60%未満であれば、SMの占有率を上げるとパフォーマンスが向上するはずですが、まず何が制限になっているかを把握する必要があります。

ピクセルシェーダとコンピュートシェーダで最も一般的な SM 占有率のリミッタは、シェーダが使用するスレッドごとの ハードウェアレジスタの数です。

ハードウェアのレジスタ数が最大理論占有率(アクティブサイクルあたりのアクティブワープ数)に与える影響は、CUDA Occupancy Calculatorで確認できます。
Maxwell、Pascal、Volta GPU では、レジスタの他に、以下のリソースでも SM 占有率を制限することができます。

グラフィックスシェーダについて:

  • 頂点シェーダー出力アトリビュートの合計サイズ。
  • ピクセルシェーダーの入力アトリビュートの合計サイズ
  • HS、DSまたはGSの入力および出力アトリビュートの合計サイズ。
  • ピクセルシェーダの場合、ピクセルワープのアウトオブオーダー完了(通常、動的ループや早期終了ブランチなどの動的制御フローに起因する)。CS のスレッドグループは任意の順序で完了できるため、CS にはこの問題があまりないことに注意してください。Gareth Thomas & Alex Dunnによる「Practical DirectX 12」についてのGDC 2016講演のスライド39をご覧ください。

コンピュートシェーダについて:

  • スレッドグループのワープがSM上でall-or-none方式で起動するため、スレッドグループのサイズはSMの占有率に直接影響します(つまり、スレッドグループのすべてのワープが必要なリソースを利用でき、一緒に起動するか、または何も起動しないかです)。スレッドグループのサイズが大きくなると、共有メモリやレジスタファイルなどのリソースの量子化が粗くなります。アルゴリズムによっては純粋に大きなスレッドグループを必要とする場合もありますが、それ以外の場合は、開発者はできるだけスレッドグループのサイズを64スレッドまたは32スレッドに制限するようにすべきです。これは、64 または 32 スレッドサイズのスレッドグループが、シェーダプログラムに最適なレジスタターゲットを選択する際に、シェーダコンパイラに最も柔軟性を与えるからです。
  • さらに、レジスタ使用量が多く (>= 64)、SM (HLSL の GroupMemoryBarrierWithGroupSync()) におけるスレッドグループバリアのストール時間が長いシェーダでは、スレッドグループのサイズを 32 に下げれば、64 と比較してスピードアップする可能性もあります。64 レジスタのガイダンスにより、SM あたり最大 32 スレッドグループの制限(CUDA Occupancy Calculator のアーキテクチャ依存の「Thread Blocks / Multiprocessor」制限)が、SM 占有率の主要制限要因にならないようにします。
  • CUDA Occupancy Calculatorに様々な数値を入力することで、スレッドグループごとに割り当てられる共有メモリの総バイト数もSMの占有率に直接影響を与えることが分かります。
  • 最後に、短いシェーダ(例えば、TEX命令と2つの算術命令)を持つDispatchコールのシーケンスでは、SMの占有率は、上流ユニットのスレッドグループの起動率によって制限される場合があります。この場合、連続したDispatchコールをマージすることが有効な場合があります。

CUDA Nsightのドキュメントページ「Achieved Occupancy」には、Compute ShadersのSM占有率制限の候補がいくつか挙げられています。

  • スレッドグループ内の処理負荷が偏っている。
  • 起動したスレッドグループが少なすぎる。これは、GPU Wait For Idles の間に SM を完全に占有するのに十分なワープを起動しないグラフィックシェーダの問題にもなりえます。

注:スレッドグループのサイズを小さくするには、実際には2つのアプローチがあります。

  • アプローチ1:スレッドグループのサイズをN倍に下げ、同時にグリッドの起動次元をN倍にする。
  • アプローチ2:N>=2個のスレッドの作業を1個のスレッドに統合する。これにより、マージされたN個のスレッド間でレジスタを介して共通データを共有したり、アトミック演算(HLSLのInterlockedMinなど)を用いて共有メモリではなくレジスタで削減を実行したりすることができます。さらに、このアプローチには、N個のマージされたスレッド間でスレッドグループの統一操作を自動的に償却する利点もあります。しかし、この方法によるレジスタの肥大化の可能性には注意する必要があります。

注:もし、あるワークロードの SM 占有率が、主にフルスクリーン・ピクセルシェーダとコンピュー トシェーダのどちらでレジスタカウントが制限されているかを知りたい場合は、次のようにしてください。

  • スクラバーで追加し… “Program Ranges” を実行し、調査したいシェーダプログラムの範囲を見つけます。Program Range を右クリックし、Range Profiler を起動し、Pipeline Overview Summary の “SM Occupancy” 値を確認します。
  • スクラバーの「Time(ms)」行をクリックして、Range内のいくつかのレンダーコールを選択します。タブを API Inspector に切り替え、ワークロードのサイクルのほとんどを占めるシェーダステージ(PS または CS)を選択し、シェーダ名の横にある「Stats」リンクをクリックします。
  • 「Shaders」Nsightウィンドウが表示され(下図14参照)、「Regs」列にシェーダのハードウェアレジスタ数が表示されます。シェーダの統計情報が入力されるまで数秒待つ必要があることに注意してください。
  • CUDA Occupancy Calculator グラフを使用して、このレジスタカウントに関連する Max Theoretical Occupancy を調べ、このシェーダの Range Profiler が報告する実際の 「SM Occupancy」 と比較します。
  • もし、達成した占有率が最大占有率よりずっと低ければ、SMの占有率はスレッドごとのレジスタの量だけでなく、他の何かによって制限されていることが分かります。

あるシェーダに割り当てられるレジスタの総数を減らすには、DX シェーダのアセンブリを見て、シェーダの各ブランチで使用されるレジスタの数を調べればよいです。ハードウェアは、最もレジスタを必要とするブランチに対してレジスタを割り当てる必要があり、そのブランチをスキップするワープは、最適ではない SM 占有率で実行されます。

フルスクリーンパス(ピクセルまたはコンピュートシェーダ)の場合、この問題に対処する典型的な方法は、ピクセルを異なる領域に分類するプリパスを実行し、各領域に対して異なるシェーダの並べ替えを実行することです。

  • コンピュートシェーダについては、このSIGGRAPH 2016のプレゼンテーションでは、シェーダの順列ごとにスレッドブロックの数を変えながら、画面上の異なるタイルに特殊なコンピュートシェーダを適用するためにDispatchIndirectコールを使用したソリューションが説明されています。
    “Deferred Lighting in Uncharted 4” – Ramy El Garawany (Naughty Dog).
  • ピクセルシェーダについては、異なる特殊化アプローチを使用することができます。フルスクリーンのステンシルバッファをプリパスで満たし、ピクセルを分類することができます。それから、ピクセルシェーダ実行の前に起こるステンシルテストに依存し(これは我々のドライバによって自動的に行われるべきです)、現在のシェーダの順列が触れていないピクセルを破棄するステンシルテストを使用することによって、複数の描画コールを効率的に実行することができます。このGDC 2013のプレゼンテーションでは、このアプローチでMSAA遅延レンダリングを最適化します。”The Rendering Technologies of Crysis 3″ – Tiago Sousa, Carsten Wenzel, Chris Raine (Crytek).

最後に、あるコンピュートシェーダのSM占有率制限をよりよく理解するために、我々のCUDA Occupancy Calculatorスプレッドシートを利用することができます。使用するには、GPU の CUDA Compute Capability とシェーダのリソース使用量(スレッドグループサイズ、Shader View からのレジスタ数、共有メモリサイズ(バイト))を記入するだけです。

アプローチ2:SM発行ストール待ち時間を減らす

SM占有率を上げる以外の方法でSM Throughput For Active Cyclesを上げるには、SM issue-stall cyclesの回数を減らすことです。これは、命令発行サイクル間のSMアクティブサイクルで、オペランドの1つがレディでない、または命令を実行する必要があるデータパス上のリソース競合が原因で、ワープ命令がストールしている間です。

PerfWorksのメトリクスsmsp__warp_cycles_per_issue_stall_{reason}は、ワープが{reason}で停止した命令イシュー間の平均サイクルを示しています。PerfWorksメトリックsmsp__warp_stall_{reason}_pctは、サイクルごとにその理由で停止したアクティブなワープの%です。これらのメトリックは、Range ProfilerのUser Metricsセクション、およびSM Overview Summaryセクションで、降順にソートされて公開されています。ワープが停止する理由としては、以下のようなものが考えられます。

  1. “smsp__warp_stall_long_scoreboard_pct” PerfWorks メトリクスは、L1TEX (local, global, surface, tex) オペレーションのスコアボード依存を待つためにストールしたアクティブワープのパーセンテージを示します。
  2. “smsp__warp_stall_barrier_pct” PerfWorks指標は、スレッドグループのバリアで兄弟ワープを待つためにストールしたアクティブワープのパーセンテージを示します。この場合、スレッドグループのサイズを下げるとパフォーマンスが向上する場合があります。これは、各スレッドが複数の入力要素を処理するようにすることで実現できる場合があります。

“sm__issue_active_per_active_cycle_sol_pct” が 80% より低く、 “smsp__warp_stall_long_scoreboard_pct” がワープストールの理由のトップなら、そのシェーダは TEX-latency limited であることが分かっているはずです。これは、失速サイクルのほとんどが、テクスチャフェッチ結果との依存関係 から来ていることを意味します。この場合は…

  1. シェーダのコンパイル時に反復回数がわかるループがある場合(ループ回数ごとに異なるシェーダの並べ替えを使用する場合もある)、HLSL の [unroll] ループ属性を使用して FXC にループを完全に展開させるようにしてみてください。
  2. シェーダが完全にアンロールできない動的ループ(たとえば、レイマーチ ングループ)を実行している場合は、テクスチャフェッチ命令をバッチして、TEX 依存のストールの数を減らしてください(HLSL レベルで、 独立したテクスチャフェッチを 2~4 のback-to-back命令のバッチに まとめることによって)。
  3. シェーダが与えられたテクスチャのピクセルごとにすべての MSAA サブサンプルを反復している場合、そのテクスチャの TEX 命令の 1 バッチで、すべてのサブサンプルを一緒にフェッチします。MSAA サブサンプルは VRAM 内で隣り合わせに保存されるため、一緒にフェッチすれば TEX ヒット率は最大になります。
  4. テクスチャの負荷が、ほとんどの場合、真になると予想される条件に基づいている場合(例えば、if (idx < maxidx) loadData(idx))、負荷を強制して座標をクランプすることを検討する(loadData(min(idx,maxidx-1)))。
  5. TEXキャッシュとL2キャッシュのヒット率を向上させることで、TEXレイテンシーを減少させてみてください。TEX および L2 ヒット率は、サンプリングパターンを微調整して隣接ピクセル/スレッドがより多くの隣接テクセルをフェッチするようにし、該当する場合はミップマップを使用し、さらにテクスチャのサイズを小さくしてよりコンパクトなテクスチャフォーマットを使用することによって向上させることが可能です。
  6. 実行されるTEX命令の数を減らしてみてください(おそらく、TEX命令述語としてコンパイルされる、テクスチャ命令ごとのブランチを使用します、例としてFXAA 3.11 HLSLを参照してください、例.”if(!doneN) lumaEndN = FxaaLuma(…);”).

トップSOLユニットがSMではない場合

どのユニットかを確認して,下記に従って対処を行います。

(1)トップSOLユニットが,TEX, L2, あるいは VRAMの場合

トップ SOL ユニットが SM ではなく、メモリサブシステム ユニット(TEX-L1、L2、および VRAM)の 1 つである場合、性能低下の根本原因は、GPUフレンドリーではないアクセスパターン(通常、ワープ内の隣接スレッドが遠く離れたメモリにアクセスする)により発生した TEX または L2 キャッシュのスラッシングである可能性があります。この場合、最上位制限ユニットは TEX または L2 であるかもしれませんが、根本的な原因は SM によって実行されるシェーダにある可能性があるため、最上位 SOL ユニットが SM の場合を使用して SM のパフォーマンスをトリアージ方法で決定する価値があります。

トップ SOL ユニットが VRAM であり、その SOL% 値が悪くない(60% 以上)場合、このワークロードは VRAM スループットが制限されており、別のパスでマージすることでフレームが高速化されるはずです。典型的な例は、ガンマ補正パスと他のポストプロセッシングパスをマージすることです。

(2)トップSOLユニットがCROPあるいはZROPの場合

CROP がトップ SOL ユニットである場合、より小さいレンダーターゲット形式(例:RGBA16F の代わりに R11G11B10F)を使用してみたり、Multiple Render Targets を使用している場合、レンダーターゲットの数を減らしてみたりすることができます。また、ピクセルシェーダでより積極的にピクセルをキルすることは価値があるかもしれません(たとえば、特定の透明効果では、不透明度が1%未満のピクセルを破棄する)。透明レンダリングを最適化するための可能な戦略については、「透明(または半透明)レンダリング」を参照してください。

ZROP が Top SOL ユニットである場合、より小さい深度フォーマット(例:シャドウマップ の D24X8 の代わりに D16、または D32S8 の代わりに D24S8)を使用し、さらに不透明オブジェクトを前から後ろへの順番に描いて、ZCULL(粗視化深度テスト)が ZROP とピクセルシェーダを起動する前に多くのピクセルが廃棄できるようにするとよいでしょう。

(3)トップSOLユニットがPDの場合

前述したように、PDは入力された頂点のインデックスを収集するために、インデックスバッファのロードを行います。PDがトップSOLユニットである場合、32ビットではなく16ビットのインデックスバッファを使用してみることができます。それでも PD の SOL%が増加しない場合は、頂点の再利用とローカリティのためにジオメトリを最適化することを試してみることができます。

(4)トップSOLユニットがVAFの場合

この場合、頂点シェーダーの入力アトリビュートの数を減らすと効果的です。
また、位置ストリームを他のアトリビュートから分離することは、z-only またはシャドウマップレンダリングに有効な場合があります。


頂点アトリビュートを減らす

前述した,P3 Methodでも出てきますが,頂点アトリビュートを減らすと高速になるケースがあります。プラットフォームによってはドキュメントに「XXX以下にすると速くなります」みたいな記述が載っていたり,実際にどのぐらい数でどういう結果が得られたのかをグラフ化してくれていたりします。
ここで重要なのは,なるべくパッキングして数を減らすということです。例えば,下記のようにします。

//変更前
struct VSOutput
{
    float4 Position : SV_POSITION;
    float2 TexCoord0 : TEXCOORD0;
    float2 TexCoord1 : TEXCOORD1;
    float2 TexCoord2 : TEXCOORD2;
    float2 TexCoord3 : TEXCOORD3;
};

// 変更後
struct VSOutput
{
    float4 Position : SV_POSITION;
    float4 TexCoord01 : TEXCOORD01; // TEXCOORD0 and TEXCOORD1
    float4 TexCoord23 : TEXCOORD23; // TEXCOORD2 and TEXCOORD3
};

データ容量は変わっていないですが,これだけで高速化します。過去の経験ではモデル描画のシェーダに対して適用したところ全体で1[ms]程度の高速化が出来た記憶があります。とにかくピクセルシェーダで使っていないアトリビュートがあったら,削る。削れないときはまとめることによって数を減らすということを考えると良いです。


サイズを減らす

計算等に利用するテクスチャサイズを減らすことによってテクスチャキャッシュに載りやすくし,高速化するテクニックがあります。例えば,Deinterleaved RenderingCheckerboard Renderingといったものがこれに当たります。特に縮小バッファにして描画品質があまりにも落ちすぎるのを避けたい場合などに使うと良いです。


テクスチャフェッチをバッチする

NVIDIAのBlog記事にもありますが,レイマーチループのような動的ループ内でテクスチャフェッチをバッチかすることによってTEXのレイテンシーを隠すことができます。
典型的なSSRレイマーチングループのHLSLは次のような感じです。

float MinHitT = 1.0;
float RayT = Jitter * Step + Step;

[loop] for ( int i = 0; i < NumSteps; i++ )
{
     float3 RayUVZ = RayStartUVZ + RaySpanUVZ * RayT;
     float SampleDepth = Texture.SampleLevel( Sampler, RayUVZ.xy, GetMipLevel(i) ).r;

     float HitT = GetRayHitT(RayT, RayUVZ, SampleDepth, Tolerance);
     [branch] if (HitT < 1.0)
     {
          MinHitT = HitT;
          break;
     }

     RayT += Step;
}

ループ内で,隣り合わせで2回テクスチャフェッチするように処理を置き換えます。

float MinHitT = 1.0;
float RayT = Jitter * Step + Step;
[loop] for ( int i = 0; i < NumSteps; i += 2 )
{
     float RayT_0 = RayT;
     float RayT_1 = RayT + Step;

     float3 RayUVZ_0 = RayStartUVZ + RaySpanUVZ * RayT_0;
     float3 RayUVZ_1 = RayStartUVZ + RaySpanUVZ * RayT_1;

     // batch texture instructions to better hide their latencies
     float SampleDepth_0 = Texture.SampleLevel( Sampler, RayUVZ_0.xy, GetMipLevel(i+0) ).r;
     float SampleDepth_1 = Texture.SampleLevel( Sampler, RayUVZ_1.xy, GetMipLevel(i+1) ).r;

     float HitT_0 = GetRayHitT(RayT_0, RayUVZ_0, SampleDepth_0, Tolerance);
     float HitT_1 = GetRayHitT(RayT_1, RayUVZ_1, SampleDepth_1, Tolerance);

     [branch] if (HitT_0 < 1.0 || HitT_1 < 1.0)
     {
          MinHitT = min(HitT_0, HitT_1);
          break;
     }

     RayT += Step * 2.0;
}

ブログ記事では,GTX1080で0.54[ms]だったものが0.42[ms]に高速化したと記載されています。また2回ではなく4回にすることで0.54[ms]から0.33[ms]になるという記述もあります。レイマーチ処理は基本的に重くなりやすいので,アルゴリズム的に最適化出来ない場合は,こうしたバッチ化により高速化するとよさそうです。