Skip to content
Go back

Render graphs and Vulkan — a deep dive

· Updated:

web

閲覧日:2017年10月17日

拙訳

Introduction

VulkanやD3D12といったモダンなグラフィクスAPIはエンジン開発者に新たな挑戦をもたらす。これらAPIによりCPUオーバーヘッドは劇的に減少した一方で、ドライバの”良い”経路に当たっているときはGPUパフォーマンスに関するギャップを埋めることは難しくなるのは明らかであり、我々はGPU束縛である。OpenGLやD3D11のドライバは(はっきりと)あらゆる種類のトリックを使ってでもGPUパフォーマンスを改善するためならいかなる苦労も惜しまない。開発者として我々がこれに払うコストは予測不可能なパフォーマンスであり、より高いオーバーヘッドである。我々は柔軟性、パフォーマンス、使いやすさのバランスを取るこれらのAPIのために素晴らしいレンダリングバックエンドを構築する方法を考え出す中にあるため、グラフィクスバックエンドを書くことは再び面白くなっている。

先週、私は自分の見解としてのVulkanレンダリングエンジンであるGraniteというサイドプロジェクトをリリースした。そのようなプロジェクトは界隈にたくさんあり、それぞれにはそれぞれの利点があるが、私は特にレンダグラフ実装を議論したい。

レンダグラフ実装はYuriy O’DonnellのGDC2017でのプレゼンテーション[O'Donnell 2017O'Donnell, Y. 2017. FrameGraph: Extensible Rendering Architecture in Frostbite. Game Developers Conference. https://www.gdcvault.com/play/1024612/FrameGraph-Extensible-Rendering-Architecture-in.]に端を発する。このトークはD3D12が中心になっているが、私はVulkanで実装した。

(注:ここではレンダグラフとフレームグラフは同じことを意味する。また、Vulkanで言及することはD3D12にも当てはまるかもしれない…多分。)

The problem

レンダグラフはモダンAPIにおけるひどく迷惑な問題を根本的に解決する。どうやって手動による同期を扱うのだろうか?すぐに分かる代替案を見ていこう。

Just-in-time synchronization

最も素直なアプローチは基本的には直前に同期を行うことである。テクスチャにレンダリングしようとしたり、リソースをバインドしたり、似たようなことをするたび、自分自身に「このリソースは同期する必要がある保留中の仕事があるか?」と問いかける必要がある。そうであれば、その直前でなんとかしてそれを扱う必要がある。読み込みが1000回以上あっても書き込みが一度しかなかったりするため、この種の追跡は非常に辛いものになる。マルチスレッディングは非常に辛いものになる。2つのスレッドが1つのバリアを見つけた場合どうなるだろうか?あるスレッドが”勝つ”必要があるので、これを扱うために大量の役に立たないスレッド間同期に悩まされることになる。

追跡する必要があるのは実行にとどまらず、Vulkanにおいてイメージレイアウトやメモリアクセスの問題もある。特定の方法でリソースを使うには固有のイメージレイアウトが必要になる(または、単にGENERALでもよいが、この場合フレームバッファの圧縮が使えなくなる)。

本質的に、我々が欲しいものがジャストインタイムでの自動的な同期であるならば、基本的にはOpenGLやD3D11に立ち返れば良い。ドライバはすでに死ぬほど最適化されているのに、どうして中途半端に再実装したいと望むのだろうか?

Fully explicit synchronization

別の側面から見れば、我々の選択するAPI抽象化は自動的な同期の一切を取り除くため、アプリケーションはすべての同期点を手動で扱う必要がある。間違いを犯したときは、“面白い”デバッグ講義の準備をしておいてください。

単純なアプリケーションならこれでうまく行くが、この道を下り始めてしまえば、どれだけ厄介なことになっているかにすぐに気付くことになる。一般的に、レンダリングパイプラインはブロックに区分けされているだろう。あるモジュールにフォワードやディファード、クールな某といったレンダラがあったり、他のモジュールにポストプロセスパスが散在していたり、再投影ステップのコピペが持ち込まれていたりして、あちらこちらに新しいテクニックを追加するので、同期戦略を練り直さなければならなくなる。そうして、モノが腐っていく。

なぜこんなことが起こるのか?

死ぬほど単純なポストプロセスパスの疑似コードをいくつか書いて考えてみよう。

// 私が最後にこのイメージから読み出したのはいつだったか?たしかポストチェインの後の最後のフレーム…
// write-after-readハザードを回避したい。
// イメージ全体を書き込もうとしている。
// そうであれば、UNDEFINEDから以前のコンテンツの"破棄"へ遷移したほうがよさそうだ…
// 理想では前のフレームからVkEventの追跡をきちんと行っていたいが、さすがに面倒だ…
// このレンダターゲットはどこから割り当てられた?
BeginRenderPass(RT = BloomTheresholdBuffer)

// このイメージはおそらく前のフレームで書き込まれているだろうが、誰も知らないだろう。
BindTexture(HDR)

DrawMyQuad()
EndRenderPass()

この種の問題は一般的には大きく幅のあるパイプラインバリアで解決される。パイプラインバリアはグローバルな同期問題について局所的に推理させるが、常に最適な方法とはならない。

// 安全になるようにすべてのフラグメントの実行が完了するのを待つために、これはwrite-after-readとHDRレンダパスの面倒を見ていることになる…
// 非同期コンピュートで使われないと仮定すれば…ふむ、これならうまく行きそうだ。

PipelineBarrier(FRAGMENT -> FRAGMENT,
    RT layout: UNDEFINED -> COLOR_ATTACHMENT_OPTIMAL,
    RT srcAccess: 0 (write-after-read)
    RT dstAccess: COLOR_ATTACHMENT_WRITE_BIT,
    HDR layout: COLOR_ATTACHMENT_OPTIMAL -> SHADER_READ_ONLY,
    HDR srcAccess: COLOR_ATTACHMENT_WRITE_BIT,
    HDR dstAccess: SHADER_READ_BIT)

BeginRenderPass(...)

そうして、我々はそれが以前のパスであると仮定したのでHDRイメージを遷移したが、今後異なるパスを遷移の間に追加するかもしれない。…つまり、依然としてイメージレイアウトの追跡を続ける必要がある。まぁ、世界の終わりでもなければ。

FRAGMENT -> FRAGMENTのワークロードを扱うだけだとすると、これはそれほどひどいことではなく、なんだかんだ言ってレンダパス間で起こるオーバーラップはそれほどない。コンピュートをごちゃまぜにし始めると、頭がどうにかなりそうになる。なぜなら、このようなパイプラインバリアをあちこちにポンポンと置いていくことはできないので、効率的な実行オーバーラップを達成するためにフレームについての非局所的な知識が必要になる。加えて、異なるキューで非同期コンピュートを行うため、セマフォが必要になることもあるかもしれない。

Render graph implementation

大抵はrender_graph.hpprender_graph.cppを参照していると思います。

注:これは巨大な知識の吐き出しbrain dumpである。順番にモノを見てゆくので、これを読みながら一緒にコードを追ってみてください。

注2:実装では用語として”フラッシュflush”と”無効化invalidate”を用いている。これはVulkan仕様の専門用語ではない。Vulkanではそれぞれ”make available”と”make visible”を使っている。フラッシュはキャッシュのフラッシュを指し、無効化はキャッシュの無効化を指す。

基本のアイデアは”大局的な”レンダグラフを持つことである。モノをレンダリングする必要があるシステムの全要素はこのレンダグラフに登録する必要がある。我々はどのパスがあり、どのリソースが参加し、どのリソースが書き込まれるか、などなどを指定する。これは、アプリケーションのスタートアップに一度か、毎フレームに一度か、でなければ必要な時に行われることがある。主なアイデアはフレーム全体の大局的な知識を形作り、より高いレベルでそれに応じた最適化を可能にすることである。モジュールは、バックエンドAPIが自動でスケジューリングせずに依存性を扱うときに直面する主要な問題を解決するため、全体像を見ることを可能にしつつそれらの入力と出力について局所的に推論することができる。レンダグラフはバリア、レイアウト遷移、セマフォ、スケジューリングなどの面倒を見ることができる。

レンダパスからの出力はある次元を必要とするが、かなり素直である。

イメージ:

struct AttachmentInfo {
    SizeClass size_class = SizeClass::SwapchainRelative;
    float size_x = 1.0f;
    float size_y = 1.0f;
    VkFormat format = VK_FORMAT_UNDEFINED;
    std::string size_relative_name;
    unsigned samples = 1;
    unsigned levels = 1;
    unsigned layers = 1;
    bool persistent = true;
};

バッファ:

struct BufferInfo {
    VkDeviceSize size = 0;
    VkBufferUsageFlags usage = 0;
    bool persistent = true;
};

そして、これらのリソースがレンダパスに追加される。

// ディファードレンダラのセットアップ

AttachmentInfo emissive, albedo, normal, pbr, depth; // デフォルトはスワップチェーンの大きさ
emissive.format = VK_FORMAT_B10G11R11_UFLOAT_PACK32;
albedo.format = VK_FORMAT_R8G8B8A8_SRGB;
normal.format = VK_FORMAT_A2B10G10R10_UNORM_PACK32;
pbr.format = VK_FORMAT_R8G8_UNORM;
depth.format = device.get_default_depth_stencil_format();

auto& gbuffer = graph.add_pass("gbuffer", VK_PIPELINE_STAGE_ALL_GRAPHICS_BIT);
gbuffer.add_color_output("emissive", emissive);
gbuffer.add_color_output("albedo", albedo);
gbuffer.add_color_output("normal", normal);
gbuffer.add_color_output("pbr", pbr);
gbuffer.set_depth_stencil_output("depth", depth);

auto& lighting = graph.add_pass("lighting", VK_PIPELINE_STAGE_ALL_GRAPHICS_BIT);
lighting.add_color_output("HDR", emissive, "emissive");
lighting.add_attachment_input("albedo");
lighting.add_attachment_input("normal");
lighting.add_attachment_input("pbr"));
lighting.add_attachment_input("depth");
lighting.set_depth_stencil_input("depth");

lighting.add_texture_input("shadow-main"); // 外部の依存性
lighting.add_texture_input("shadow-near");

ここではリソースをレンダパスで使えるようする方法が3つ提示されている。

  • Write-only。リソースは完全に書き込まれる。レンダーターゲットでは、loadOpCLEARDONT_CARE
  • Read-write。入力を維持したり、上書きしたりする。レンダーターゲットでは、loadOpLOAD
  • Read-only。以下略

コンピュートでも似たような話で、非同期コンピュートで行う適応的な輝度更新処理は以下のように記述する。

auto& adapt_pass = graph.add_pass("adapt-luminance", VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
adapt_pass.add_storage_output("average-luminance-updated", buffer_info, "average-luminance");
adapt_pass.add_texture_input("bloom-downsample-3");

例えば、ここで輝度バッファはRMW(Read-Modify-Write)を得る。

実際の処理を行うために毎フレーム呼ばれる可能性があるコールバックも必要である。G-Bufferでは以下のようになる。

gbuffer.set_build_render_pass([this, type](Vulkan::CommandBuffer& cmd) {
    render_main_pass(cmd, cam.get_projection(), cam.get_view());
});

gbuffer.set_get_clear_depth_stencil([](VkClearDepthStencilValue* value) -> bool {
if (value) {
    value->depth = 1.0f;
    value->stencil = 0;
}
return true; // CLEARかDONT_CAREか?
});

gbuffer.set_get_clear_color([](unsigned int render_target_index, VkClearColorValue* value) -> bool {
    if (value) {
        value->float32[0] = 0.0f;
        value->float32[1] = 0.0f;
        value->float32[2] = 0.0f;
        value->float32[3] = 0.0f;
    }
    return true; // CLEARかDONT_CAREか?
});

レンダグラフは、リソースを割り当てたり、これらのコールバックを操作したりして、最終的に適切な順序でGPUにサブミットする責任を持つ。このグラフを終わらせるには、特定のリソースを”バックバッファ”に昇格させる。

// これはアドホックなデバッグにとても使いやすい。:)
const char* backbuffer_source = getenv("GRANITE_SURFACE");
graph.set_backbuffer_source(backbuffer_source ? backbuffer_source : "tonemapped");

では、実際の実装に入っていこう。

Time to bake!

いったん構造をセットアップすれば、レンダグラフをベイクする必要がある。これは一連の手順を進み、各々がパズルのピースひとつひとつを埋めてゆく…

Validate

とても素直で迅速な、レンダパス構造中のデータが理にかなっていることを保証するための正気度チェックである。

ここで面白いことのひとつは、我々が色入力の次元が色出力と一致しているかをチェックできることである。これらが異なれば、そのままloadOp = LOADをせずに、レンダパスの開始時にスケーリングしたblitを行うことができる。これは、内部解像度のアップスケーリングを行うようなときにとても便利である。このときのloadOpDONT_CAREになる。

Traverse dependency graph

我々はレンダパスの非循環グラフを持つ。これはつまりレンダパスの配列に平坦化する必要がある。我々が作るリストは全てのパスを順々にサブミットすることであった場合に妥当なサブミッション順となる。このサブミッション順は最大限最適化されていないかもしれないが、あとで近づかせる。

ここでのアルゴリズムは素直である。我々はボトムアップにツリーを走査する。再帰を使って、バックバッファに書き込む全てのパスのインデックスをプッシュして、そのらのパスすべてに対して、リソースに対する書き込みをプッシュして…最上位のリーフに達するまで続ける。こうして、パスAがパスBに依存する場合、パスBは常にリスト中でパスAの後に見つかるようになることを保証する。そして、リストを遡って、重複分を取り除く。

パスが他のパスと良い”マージ候補”であるかも登録する。例えば、ライティングパスはGバッファパスからの入力アタッチメントを使い、いくつかのカラー/デプスアタッチメントを共有する…タイルベースのアーキテクチャでは、Vulkanのマルチパス機能を使ったメインメモリを取らずに、実際にこれらのパスをマージする事ができる。なので、後にやるパスの並べ替えのためにこれを気に留めておく。

Render pass reordering

これはこのプロセスの第1の面白ステップである。理想としては、パス間が最適にオーバーラップしたサブミッション順が欲しい。パスAがあるデータに書き込み、パスBがそれを読み出す場合、“ハードバリア”の数を最小化するためAとBの間に最大限のパスが欲しい。これは最適化のメトリック(距離)となる。

実装されたアルゴリズムはおそらくCPU時間に関して言えばそれほど最適ではないが、仕事は果たしている。まだスケジューリングされていないパスのリストを調べ、以下の3つの判断基準に基づく最適なひとつを見つけ出そうとする。

  • 依存性グラフ走査ステップより前に決定されたマージ候補があるか?(スコア:無限)
  • すでにスケジューリングされた待つ必要があるパスのリストの中で最後のパスは何か?(スコア:その間にオーバーラップする可能性のあるパス数)
  • このパスをスケジューリングすると依存関係が壊れるか?(そうなら、このパスをスキップする)

コードを読むほうがおそらくもっと為になるので、RenderGraph::reorder_passes()を参照のこと。

含めるべきもうひとつの見落としがちsneakyな検討事項は、ライティングパスがいくつかのリソースに依存するが、Gバッファパスがそうでないときである。これは、以下のスケジューリングプロセスを通るため、サブパスのマージ処理を破壊する可能性がある。

  • Gバッファパスでスケジューリングされ、依存性を持たない
  • ライティングパスでスケジューリングを試みるが、依存しているシャドウパスをまだスケジューリングしてなかった

これの泥臭い解決法はマージ候補からマージチェインの第1パスに依存性を引き上げることであった。すると、Gバッファパスはシャドウパスの後にスケジューリングされ、すべてがうまく行く。もっと賢いスケジューリングアルゴリズムはここで助けになるだろうが、私はできるだけ単純にしておきたいと考えている。

Logical-to-physical resource assignment

グラフを構築するとき、read-modify-writeがいくつか現れるかもしれない。ライティングパスではエミッシブを入力してHDRの結果が出力されるが、これらは明らかに同じリソースであり、賢明な方法で依存性を理解するためにはこの抽象的なモノを持つだけでよいので、リソースに記述的な名前を与えることで、サイクルを回避する。複数のパスがある場合、例えばエミッシブからエミッシブへの処理の場合、どのパスが初めに来て、互いにどのように依存しているかは皆目見当がつかないので、潜在的なサイクルを扱わないほうが良い。

今我々が行うことは全てのリソースに物理リソースインデックスを割り当てて、read-modify-writeを行うリソースの別名を付けることである。何らかの理由で別名が付けられない場合、それは書き込みと並行して読み込みを行おうとしている当てにならないサブミッション順を持っているという証である。実装はこの場合には単に事を諦めている。私はこれが非循環グラフで起こると考えていないが、証明はできていない。

Logical-to-physical render pass

次に、隣接したレンダパスを一緒にマージしてみる。これは特にタイルベースのレンダラで重要である。我々は以下の場合にパスのマージを試みる。

  • 両方がグラフィクスパスである
  • 色/深度/入力のアタッチメントを共有する
  • ユニークな深度/ステンシルアタッチメントが1つだけ存在する
  • 依存性がBY_REGION_BITで実装できる。つまり、任意の位置をサンプリングできる”テクスチャ”依存性を持たない

Transient or physical image storage

サブパスのマージ処理についても同様で、タイルベースのレンダラは、(storeOp = STOREの)アタッチメントへ実際には全く書き込まない場合に、そのアタッチメントへの物理メモリの割り当てを避けることができる。これは特にディファードで大量のメモリを節約できるし、例えば後のポストプロセスで使わない場合なら深度バッファでもできる。

以下の場合にリソースは遷移できる。

  • 単一の物理レンダパスで使われている(つまり、storeOp = STOREにする必要が一切ない)
  • レンダパスの開始時に無効化される(loadOp = LOADが必要ない)

Build RenderPassInfo structures

さて、我々はすべてのパスとその依存性と諸々の開けた視界を手にしている。RenderPassInfo構造を作るなら今である。

この部分の実装はGraniteのVulkanバックエンドが行う方法に強く紐付いているが、Vulkan APIと鏡写しに近いため、それほど奇妙なことにはなっていないだろう。VkRenderPassは要求に応じてVulkanバックエンドで生成されるため、ここでは行わないが、潜在的には直ちにベイク可能であると思われる。

実際のイメージビューはあとで割り当てられる(実際には毎フレーム)が、少なくともサブパス情報やアタッチメント数は前もって処理することができる。我々はアタッチメントとして引き出されるべき物理リソースインデックスのリストも構築する。

どのアタッチメントがloadOpCLEARDONT_CAREのいずれかを必要とするかをコールバックを呼び出すことで解決する。入力を持つアタッチメントでは、単にloadOp = LOADを使う(または、スケーリングされたblitを使う)。storeOpには常にSTOREを使う。Graniteは内部的に一時的なtransientアタッチメントを認識しており、それらのアタッチメントには常にstoreOp = DONT_CAREを強制する。

Build barriers

バリアを考え始める時である。各パスごとに、各リソースは3つのステージを経る。

  • 適切なレイアウトへ遷移して、キャッシュを無効化する必要がある
  • リソースが使われる(読み込み又は/及び書き込みが発生する)
  • リソースが最終的に新しいレイアウトになり、あとでフラッシュする必要がある書き込みを行う可能性を持つ

各パスごとに、我々は”無効化”と”フラッシュ”のリストを1つ構築する

パスへの入力は無効化バケットに入れられ、出力はフラッシュバケットに入れられる。read-modify-writeリソースは両方のバケットでエントリを取得する。

例えば、パスでテクスチャを読み出したい場合、この無効化バリアを追加するかもしれない。

  • stages = FRAGMENT(またはVERTEX。ただし、追加のステージフラグをリソース入力に加えなければならない)
  • access = SHADER_READ
  • layout = SHADER_READ_ONLY_OPTIMAL

色の出力では、以下になるかもしれない。

  • stages = COLOR_ATTACHMENT_OUTPUT
  • access = COLOR_ATTACHMENT_WRITE
  • layout = COLOR_ATTACHMENT_OPTIMAL

これは「このステージにはsrcAccessMaskでフラッシュする必要があるこのメモリアクセスを伴う保留中の書き込みがあるよ。このリソースを使いたいなら、同期してね!」とシステムに伝える。

レンダパスを伴う特定のシナリオも理解できる。リソースが入力アタッチメントとread-only深度アタッチメントの両方として使われる場合、レイアウトをDEPTH_STENCIL_READ_ONLY_OPTIMALに設定することができる。色アタッチメントが入力アタッチメントとしても使われる場合、GENERALを使うことができる(プログラマブルブレンディング!)。さらに、入力アタッチメントを持つread-write深度/ステンシルでも同様である。

Build physical render pass barriers

今や、我々は各パスのバリアの完全な見方を持つが、パスをともにマージし始めるときは何が起こるのだろうか?マルチパスは(ディファードシェーディングを考えると)レンダパス実行の一部として内部的にいくつかのバリアを処理している可能性が高いだろうから、ここのバリアは省略できる。これらのバリアは後にVkRenderPassを構築するときにVkSubpassDependencyによって内部的に解決されるので、サブパス間に必要なバリアのすべてについては忘れてしまって良い。

我々が興味を持っていることはリソースが使われる初めのパスのための無効化バリアを構築することである。フラッシュバリアでは、リソースの最後の使用について気にする。

すべてのパスが実行する前と後で同期を扱うことができることを保証するためにここでカバーする必要がある2つのケースが存在する。

Only invalidation barrier, no flush barrier

これはread-onlyリソースの場合である。依然として後のwrite-after-readハザードから自分自身を守る必要がある。例えば、次のパスがこのリソースに書き込み始めたらどうなるか?明らかに、リソースに書き込み始める前にこのパスが完了する必要があることを他のパスに知らせる必要がある。これはaccess = 0を持つ偽のフラッシュバリアを注入することで実装する。access = 0は基本的に「ここには考慮すべき保留中の書き込みはない」ことを意味する。我々ができるこの方法はリソースを読むだけの複数のパスを背中合わせに持つ。イメージレイアウトが同じままであり、srcAccessMaskが0であるなら、バリアは必要ない。

Only flush barrier, no invalidation barrier

これは一般的にwrite-onlyであるパスの場合である。これはパスが始まる前にUNDEFINEDから遷移することでリソースを破棄する可能性があることを知らせる。ただし、レンダパスを始める前にレイアウトを遷移させ、キャッシュを無効化する必要があるため、依然として無効化バリアを必要とする。そこで、フラッシュバリアと同じレイアウトとアクセスを持つ無効化バリアをここに注入する。

Ignore barriers for transients/swapchain

transientのためのバリアはある理由から”取り除かれる”ことに気付くかもしれない。Graniteはtransientアタッチメントでのレイアウト遷移を行うために外部サブパス依存性を内部的に用いる。とはいえ、これはレンダパスがある今となってはある種の冗長なものであるかもしれない。スワップチェインも同様である。Graniteは、スワップチェインのイメージがレンダパスで使われたとき、そのレイアウトをfinalLayout = PRESENT_SRC_KHRに遷移するためにサブパス依存性を内部的に用いている。

Render target aliasing

ベイク処理の最終ステップはグラフでリソースを一時的に別名付けできるかどうかを理解することである。例えば、フレームで完全に異なるときに存在する2つ以上のリソースを持つかもしれない。separable blurを考えてみよう。

  • フレームをレンダリングする(バッファ#0)
  • 水平ブラー(バッファ#1)
  • 垂直ブラー(バッファ#0へ再び戻るping-pong backべき)

レンダグラフでこれを指定するとき、3つの別個のリソースを持つが、明らかに、垂直ブラーのレンダターゲットは初めのレンダターゲットのエイリアスとすることができる。私はここでエイリアシングの結果についてFrostbiteのプレゼンテーションを見てみることをオススメするが、とても文量が多い。

ここで技術的には実際のVkDeviceMemoryを別名付けすることもできるかもしれないが、この実装は直接VkImageVkImageViewを再利用しようとしているだけである。使い終わった他のイメージから直接サブアロケートを試みることで得られるものがあるのかどうかは分からないが、うまく行くことを期待している。何か探すのはメモリに飢えてからにしようと思う。イメージメモリに別名を付けるメリットは、VK_*_dedicated_allocationがあることから、疑わしいかもしれない。なので、いくつかの実装は別名を付けないほうが良いこともあるかもしれない。これに関しては何らかの指標やらIHVのガイダンスが明らかに必要である。

アルゴリズムはかなり素直である。各リソースごとに、リソースが使われる初めと終わりの物理レンダパスを理解する。同じ大きさ/フォーマットを持つ他のリソースを見つけて、それらのパス範囲が被っていなければ、直ちに別名がつけられる!リソース間で”所有権”を遷移できる情報をを注入する。

例えば、以下の3つのリソースを持つ場合、

  • エイリアス0はパス1とパス2で使われる
  • エイリアス1はパス5とパス7で使われる
  • エイリアス2はパス8とパス11で使われる

パス2の終わりに、エイリアス0に関するバリアはエイリアス1へコピーされ、レイアウトはUNDEFINEDに強制される。パス5を始めるとき、イメージを新しいレイアウトへ遷移する前に完了するまでパス2を魔法のように待つ。エイリアス1はパス7の後にエイリアス2へ引き渡す。パス11は”リング”的なやり方で次のフレームのエイリアス0へ制御を戻す。

ここでいくつかの警告が適用される。イメージは各イメージが実際には現フレームにひとつ、前のフレームにひとつの2つのインスタンスを持つという”history”又は”feedback”というものがあるかもしれない。これらのイメージは他のいかなるものともエイリアスを作るべきではない。また、transientイメージは別名付けをしない。Graniteの内部transientイメージアロケータは内部的にこのエイリアシングの世話をするが、レンダグラフにあるので、今や冗長になっている。

もうひとつの検討事項はエイリアシングの追加が必要なバリア数を増加させ、GPUスループットを減少させるのであはないかというものである。エイリアシングコードは追加のバリアコストを考慮に入れる必要があるのだろうか?…少なくともベイク中のVRAMサイズを知っているなら、グラフ中のすべてのリソースに基づいてエイリアシングが実際にその価値があるかどうかについてとてもいいアイデアがある。オーバーラップが最大化するように依存性グラフを最適化することもまたエイリアシングの機会を大幅に減らすことになる。メモリを考慮に入れたいなら、このアルゴリズムは簡単に複雑化する。

Preparing resources for async compute

非同期コンピュートでは、リソースはグラフィクスキューとコンピュートキューの両方によってアクセスされるかもしれない。それらのキューファミリーが異なる(AMD)場合、それらのリソースへEXCLUSIVECONCURRENTのいずれのキューアクセスが欲しいかを決めるなければならない。バッファでは、CONCURRENTを使うことは明らかな選択のように見えるが、イメージではいくらか複雑である。これを恐ろしく複雑にしないという名目で、コンピュートとグラフィクスの両方のパスで本当に必要とされるリソースでのみ、CONCURRENTで行った。EXCLUSIVEで扱うのは、read-after-writeバリアやキューファミリー間の所有権の受け渡しping-pongを考慮しなければならないため、厳しいだろう。

Summary

考えることがたくさんあるが、今やフレームを送り出し始めるためのすべてのデータ構造を適所に持つ。

The runtime

ベイクはとても入り組んだ処理だが、これを実行するのは合理的に単純であり、グラフ中で我々が知っているすべてのリソースのステートを追跡する必要があるだけである。

各リソースは以下を格納する。

  • 最後のVkEvent。「このリソースに触る前に待つ必要があるのは何か」と自問する必要がある場合はこれだ。私は、パイプラインバリアでは説明できない実行オーバーラップを説明できるため、VkEventを選択した
  • グラフィクスキュー用の最後のVkSemaphore。リソースが非同期コンピュートで使われる場合、VkEventの代わりにセマフォを使う。セマフォは複数回待つことはできないので、必要であればグラフィクスキューで一度だけ待たせることができるセマフォを持っている
  • コンピュートキュー用の最後のVkSemaphore。同様に、コンピュートキューで一度だけ待つのに使う
  • フラッシュステージ(VkPipelineStageFlags)。これはリソースを待つ必要がある場合に待つ必要があるステージ(srcStageMask)を含む
  • フラッシュアクセス(VkAccessFlags)。これはリソースを使う可能性がある前にフラッシュする必要があるメモリのsrcAccessMaskを含む
  • 毎ステージ無効化フラグ(各パイプラインステージごとのVkAccessFlag)。これらのビットマスクはがリソースの使用が安全であるというパイプラインステージとアクセスフラグを追跡し続ける。無効化バリアがあることは分かっているが、すべての関連するステージとアクセスビットがすでに準備完了である場合、そのバリアを完全に取り除くことができる。これは同じリソースを何度もすべてSHADER_READ_ONLY_OPTIMALレイアウトで読む場合に最適である
  • リソースの現在のレイアウト。これは現時点でイメージハンドルそれ自身の中に格納されるが、これは後にマルチスレッディングを追加する場合には、若干当てにならなくなるかもしれない

各フレームごとに、リソースを割り当てる。最低限、スワップチェインのイメージは置き換えなければならないが、いくつかのイメージは”非persistent”として割り当てられていたかもしれない。その場合は、すべてのフレームで新しいリソースを割り当てる。これは、フレーム間バリアの除去とさらなるメモリ使用(さらなるGPU上の処理中コピー)を交換するシナリオで役に立つ。これはおそらく大きなレンダターゲットではひどいアイデアであるが、それぞれが数kBの小さなコンピュートバッファではどうだろうか?GPU処理を早期に走らせることができれば、これはおそらく良いことである。

新しいリソースを割り当てる場合、すべてのバリアステートは初期ステートにクリアされる。

今、我々はレンダパスを押し出した。現在の実装はすべてのパスをループし、出てくるバリアを扱う。このループを十分にインターリーブすると、マルチスレッディングの可能性をここに見出すことは明らかである。

Check conditional execution

いくつかのレンダパスはこのフレームで実行される必要がなく、何かが起こった(シャドウマップとか)場合にのみ実行する必要があるかもしれない。各パスはこれを定めることができるコールバックを持つ。パスが実行されなければ、無効化/フラッシュバリアを必要としない。依然としてエイリアシングバリアを引き渡す必要があるので、単にそれを行い、次のパスへ進む。

Handle discard barriers

パスが破棄バリアを持つ場合、単にイメージの現在のレイアウトをUNDEFINEDにセットする。実際にレイアウト遷移を行うときは、oldLayout = UNDEFINEDとする。

Handle invalidate barriers

このパートは結論としては、あるキャッシュが無効化する必要があるか、そして、同様にあるキャッシュをフラッシュする可能性があるか理解することになる。ここでチェックしなければならないことがいくつかある。

  • 保留中のフラッシュはあるか?
  • 無効化バリアは現在のレイアウトではなく別のイメージレイアウトを必要としているか?
  • 未だにフラッシュされていないキャッシュがあるか?

いずれかの質問への答えがYESであれば、何らかのバリアが必要となる。我々は3つの方法の内ひとつでこのバリアを実装する。

  • vkCmdWaitEvents --- リソースが保留中のVkEventを持つなら、適切なVkBufferMemory/VkImageMemoryBDarrierと一緒に
  • セマフォ待機を伴うvkQueueSubmit。Graniteはサブミット時のセマフォ追加の世話をする。無効化バリアと一致するdstWaitStageMaskと一緒に待機セマフォをプッシュする。レイアウト遷移が必要な場合も、dstWaitStageMaskをつかむためsrcStageMask = dstStageMaskを持つvkCmdPipelineBarrierを追加できる。そうして、パイプラインを動かし続ける。セマフォで待機した場合は、一般的にsrcAccessMaskを扱う必要はないので、通常これは単に0とする
  • vkCmdPipelineBarrier(srcStage = TOP_OF_PIPE_BIT)。これは、リソースが以前に使われず、UNDEFINEDレイアウトから遷移する必要がある場合に使われる

バリアは適切な形にバッチ処理され、サブミットされる。バッファはレイアウトを持たないのでとても単純になる。

無効化の後、正しく無効化されたとして適当なステージをマークする。レイアウトが変化した、または、このステップの一部としてメモリアクセスがフラッシュされた場合、このステップの前にすべてを0にクリアする。

Execute render passes

これは簡単なパートで、単にbegin/nextsubpass/endを呼び出して、実際のグラフィクス処理をプッシュするためにコールバックを実行する。コンピュートでは、単にbegin/endを除いたものをすればいい。

グラフィクスでは、開始時にスケールしたblitを、終了時にMIPMAPの自動生成を行うかもしれない。

Handle flush barriers

このパートはより簡単である。単一のキューで使われるだけの少なくともひとつのリソースがある場合、ここでVkEventをシグナルして、関連するリソースすべてへそれを割り当てる。キューをまたいで使われる少なくともひとつのリソースがある場合、2つのセマフォもここでシグナルする(ひとつはグラフィクス用、ひとつはコンピュート用)。

現在のレイアウトも更新し、後の使用のためにフラッシュステージ/フラッシュアクセスをマークする。

Alias handoff

リソースがエイリアスされている場合、リソースのバリアステートを次のエイリアスへコピーして、レイアウトをUNDEFINEDに強制する。

Submission

各パスのコマンドバッファはGraniteへサブミットされる。Graniteは、セマフォを待つかシグナルする必要があるまで、コマンドバッファをバッチ処理しようとする。

Scale to swapchain

すべてのパスが完了した後、バックバッファリソースの大きさが実際のスワップチェインと一致しない場合、スワップチェインへ最終blitを注入できる。そうでなければ、いずれかの方法でそれらのリソースをエイリアスするので、無駄なblitパスが必要なくなる。

Conclusion

たぶんこれは面白かった。この投稿の語数はこの地点で5K近く、レンダグラフは3kSLOCの巨大なものとなる。バグはあると思う(実際これを書いている途中に非同期コンピュートに2つ見つけた)が、事の結末には大変満足している。

今後の目標はこれが再利用可能でスタンドアローンなライブラリにできるかどうかを調べてみることと実数をつかむことである。