Vulkanの話 第9回

非同期コンピュート 17/01/07 up

前回は Compute Shader を使ってみましたが、Compute Shader の実行はグラフィクス処理の合間になっていました。

このような手法はそもそも DirectX11 世代では普通の使い方でしたが、DirectX12 世代以降ではグラフィクス処理と並列で Compute Shader を実行できるようになりました。

それが非同期コンピュートと呼ばれる技術です。

Compute Shader の登場により、汎用的なGPU利用、すなわち GPGPU が台頭してくるかと思われました。

グラフィクスとは関係ない分野では GPGPU は大いに活用されているのですが、ことゲームに関しては Compute Shader で何をやるかと言われるとグラフィクス関連処理がほとんどです。

GPUパーティクルの物理計算くらいはあるのですが、AIでも使えるのでは?という期待には今のところあまり答えられていません。

C++で記述するのに比べると大変という部分はあるのですが、一番の問題はグラフィクス処理の合間に挟まなければならないという部分なのではないかと思います。

ゲームにおいて、グラフィクスは1フレームごとに描画し直して新しい映像を作り出す必要があり、つまり1フレーム中に処理がすべて完了する必要があるわけです。

それに対して物理やAIはそうとは限りません。

もちろん1フレーム中にすべての処理が完了するのであればOKですが、処理量的に1フレームで完了しないので複数フレームに跨がらせて処理を行いたいというのは普通に考えられます。

DirectX11 世代の手法で複数フレームに処理を跨がらせるのであれば、行いたい処理を複数に分割し、それぞれを1フレームごとに処理させるという形になると思います。

この手法はうまく分解できないと綺麗に複数フレームに処理を並べられませんし、対応も比較的面倒です。

普通の非同期処理のように、別スレッドで処理を流し、処理完了時に同期を取って必要な情報を受け取るなどする方が楽です。

これを実現するのが今回の非同期コンピュートです。

グラフィクス処理の裏で Compute Shader を実行し、完了時にそのデータを受け取ることが出来る機能となります。

今回はこの機能を使って高速フーリエ変換 (FFT) を実装してみました。

FFTについては今回は解説を行いません。

また、コードはIntelが公開しているDirectX11用のシェーダコードをGLSLに書き直して実装しました。

Fast Fourier Transform for Image Processing in DirectX 11

まず最初に行うのはグラフィクス処理を行うキューとは別にコンピュート用のキューを作成することです。

コンピュートキューを作成するにはグラフィクスキューとは別の QueueFamily を利用する方法と、グラフィクスと同じ QueueFamily を利用する方法があります。

どちらでも実現可能ですが、サンプルでは以下のように実装しています。

VulkanSampleLib/source/device.cpp (165)

uint32_t graphicsQueueIndex = 0; uint32_t computeQueueIndex = 0;{ // グラフィクス用のキューを検索する // NOTE: Computeも可能なキューを検索 graphicsQueueIndex = FindQueue(vk::QueueFlagBits::eGraphics | vk::QueueFlagBits::eCompute); if (graphicsQueueIndex == kQueueIndexNotFound) { return false; } computeQueueIndex = FindQueue(vk::QueueFlagBits::eCompute, vk::QueueFlagBits::eGraphics); computeQueueIndex = (computeQueueIndex == kQueueIndexNotFound) ? graphicsQueueIndex : computeQueueIndex; float queuePriorities[] = { 0.5f, 0.3f }; float computeQueuePriorities[] = { 0.3f }; std::vector<vk::DeviceQueueCreateInfo> queueCreateInfos; { vk::DeviceQueueCreateInfo info; info.queueFamilyIndex = graphicsQueueIndex; info.queueCount = (computeQueueIndex == graphicsQueueIndex) ? 2 : 1; info.pQueuePriorities = queuePriorities; queueCreateInfos.push_back(info); } if (computeQueueIndex != graphicsQueueIndex) { vk::DeviceQueueCreateInfo info; info.queueFamilyIndex = computeQueueIndex; info.queueCount = 1; info.pQueuePriorities = computeQueuePriorities; queueCreateInfos.push_back(info); } // 中略 vk::DeviceCreateInfo deviceCreateInfo; deviceCreateInfo.queueCreateInfoCount = static_cast<uint32_t>(queueCreateInfos.size()); deviceCreateInfo.pQueueCreateInfos = queueCreateInfos.data(); deviceCreateInfo.pEnabledFeatures = &deviceFeatures; deviceCreateInfo.enabledExtensionCount = (uint32_t)ARRAYSIZE(enabledExtensions); deviceCreateInfo.ppEnabledExtensionNames = enabledExtensions; deviceCreateInfo.enabledLayerCount = ARRAYSIZE(kDebugLayerNames); deviceCreateInfo.ppEnabledLayerNames = kDebugLayerNames; vkDevice_ = vkPhysicalDevice_.createDevice(deviceCreateInfo);}

FindQueue() を使ってグラフィクスが使用できないがコンピュートは使用できる QueueFamilyIndex を取得します。

見つからない場合はグラフィクスキューと同じ Family を使います。

同じ Family の場合はキューの優先順位をコンピュートを下げて設定して2つ作ります。

コンピュートのみの Family がある場合は vk::DeviceQueueCreateInfo をコンピュート分用意します。

VulkanSampleLib/source/device.cpp (239)

// コマンドプール作成 vk::CommandPoolCreateInfo cmdPoolInfo; cmdPoolInfo.queueFamilyIndex = graphicsQueueIndex; cmdPoolInfo.flags = vk::CommandPoolCreateFlagBits::eResetCommandBuffer; vkCmdPool_ = vkDevice_.createCommandPool(cmdPoolInfo); cmdPoolInfo.queueFamilyIndex = computeQueueIndex; vkComputeCmdPool_ = vkDevice_.createCommandPool(cmdPoolInfo);// 中略// コマンドバッファ作成{ vk::CommandBufferAllocateInfo allocInfo; allocInfo.commandPool = vkCmdPool_; allocInfo.commandBufferCount = vkSwapchain_.GetImageCount(); vkCmdBuffers_ = vkDevice_.allocateCommandBuffers(allocInfo); allocInfo.commandPool = vkComputeCmdPool_; vkComputeCmdBuffers_ = vkDevice_.allocateCommandBuffers(allocInfo);}

コマンドプールはグラフィクスとコンピュートで別々に作成しますが、同一 Family なら不要ではあります。

作っておいても損はないようなので作りました。

コマンドバッファは一応スワップチェインのフレームバッファ数だけ作成しています。

ここでも作成元となるプールはそれぞれ分けます。

さて、実際の FFT 計算のコマンド生成部分は割愛します。

生成済みのコンピュートキューを取得し、初回のみコマンドの積み込みを行います。

Compute FFT というボタンを押すと計算が開始されますが、今回は並列動作がわかりやすいようにするために5000回も同じ処理を回しています。

GPUによっては時間がかかりすぎるかもしれないので注意してください。

対応するコードは以下の関数です。

Sample006/main.cpp (1173)

void RunFFT(vsl::Device& device);

実際のコマンド積み込みは1185行目の ifブロック部分ですね。

こちらはどんなにボタンを複数回押しても1回しか処理が通りません。

重要なのはその後の処理です。

FFTの計算が並列で処理されたあと、処理完了時にFFTをテクスチャとして貼り付ける処理を行います。

これをやるにはドローコールしているホスト側 (C++側) がFFT計算終了を待たなければなりません。

そこで使用されるのがフェンスです。

フェンスはVulkanに用意されている同期手法の1つで、デバイスとホスト、つまりGPUとCPUの同期をとる時に使用されます。

Sample006/main.cpp (1260)

// フェンスの作成 computeFence_ = device.GetDevice().createFence(vk::FenceCreateInfo());// フェンスを使ってSubmit vk::SubmitInfo submitInfo; submitInfo.pCommandBuffers = &cmdBuffer; submitInfo.commandBufferCount = 1; device.GetComputeQueue().submit(submitInfo, computeFence_);

submit() を行う前にフェンスを作成し、submit() 命令の第2引数として渡します。

submit() はもちろんコンピュートキューで行います。

フェンスを指定して submit() すると、処理完了時にフェンスの状態が更新されます。

CPU側では毎フレーム、フェンスの状態をチェックして、完了したらテクスチャ変更等の処理を行います。

Sample006/main.cpp (1279)

void EndCalcFFT(vsl::Device& device){ if (computeFence_ && (device.GetDevice().getFenceStatus(computeFence_) == vk::Result::eSuccess)) { device.GetDevice().destroyFence(computeFence_); computeFence_ = vk::Fence(); isFFTComplete_ = true; viewType_ = 1; OutputDebugString(L"\n"); } else if (computeFence_ && (device.GetDevice().getFenceStatus(computeFence_) != vk::Result::eSuccess)) { OutputDebugString(L"."); }}

この命令は毎フレーム呼び出されます。

getFenceStatus() 命令で対象のフェンスが完了するのを待ちます。

完了していない場合はデバッグ出力に "." を出力し続けます。

並列動作していることをわかりやすくするためのデバッグ用コードです。

フェンスを張った処理が完了すると、getFenceStatus() の戻り値が vk::Result::eSuccess になるので、こうなったらフェンスを削除、テクスチャ切り替えのためにゴニョゴニョします。

グラフィクス側は特に何もしません。いつも通りに描画していれば、ボタンを押されたときだけFFTの計算処理が5000回走ります。

さて、ここまでで非同期コンピュートを行う方法がわかりましたが、今回の手法では非同期コンピュートが終了するのは数フレーム先です。

しかし、非同期コンピュートは使いたいけど同一フレーム内で同期したい、という場合もあります。

例えば Clustered Rendering のライトカリング処理。

現在主に使われている実装では、Clustered Rendering のライトカリングは描画を行わなくても実行できます。

ライトカリングはライティング前には終わっていないといけないのですが、シャドウマップ生成やZ Pre-Passの際には不要です。

なので、グラフィクス処理とコンピュート処理を並列に実行できますし、それによってGPUのストールも減らすことが出来ます。

とはいえ、同一フレームの描画処理内で同期を取りたいわけで、その場合はホストが絡まない形で同期されなければなりません。

そのような場合はフェンスでは対応できないため、別の同期処理を利用する必要が出てきます。

Vulkanには同期処理が4種類存在し、それぞれがそれぞれ別の利用方法と制限を持っています。

まずはすでに使用されているフェンスです。

これはデバイスとホストの同期をとるための方式で、ゲームでは非同期ロードなどと同じような文脈で使われます。

今回使ったように、複数フレームに跨る処理が終了したら本流の処理に渡す、というような感じです。

2つ目はバリアです。これはフェンスとは対極の、基本的には各コマンドバッファ内での同期を行います。

GPUによって何らかのバッファに書き込みが行われている最中に次の命令が降ってくると、手すきのGPUがその処理を実行し始めます。

これが問題になるのが次の命令が前の命令で書き込んでいるバッファを使用する場合です。

このような場合は前の命令がバッファへの書き込みを完了するのを待ってから次の命令を実行しなければいけないわけで、バリアはそのような同期処理をバッファへの処理が終了したら次へ、という形で実装しています。

3つ目はイベントです。

イベントは通常、コマンドバッファ間の同期をとるために使用されます。

コマンドバッファの途中にイベントの発火命令を仕込み、別のコマンドバッファの途中でそのイベントの発火を待つ、ということも可能です。

これだけ見ると大変使いやすいように見えるイベントですが、同一の Queue Family 内でしか使用できないという制限があります。

キュー自体は別でもかまわないのですが、そのキューの Family が同一である必要があるようです。

今回のコンピュートキューを同期させる場合、コンピュートのみのキューを作成してしまうとイベントが使用できなくなります。

ただ、Validation Layerでエラーが吐かれても、動作としてはそれっぽく動作してたりするので、別の Family でも対応できるようにならないものでしょうかね?

4つ目はセマフォです。

セマフォは複数のキューの間で同期をとるために使用します。

コマンドバッファを submit する際に終了時に発火するセマフォと、発火を待つセマフォを指定することが出来ます。

待っているセマフォが発火すると、submit したコマンドバッファが実行されます。

キューの同期というより、キューに submit したコマンドバッファ単位での同期と言ったほうが正確でしょう。

今回のサンプルではコンピュートキューで実行するFFT計算を同期処理に変更する機能も追加しています。

"Sync FFT" のチェックをONにして "Compute FFT" ボタンを押すと1フレームで同期処理が行われます。

この際に使用しているのはセマフォです。

コードは提示しませんが、Sample006/main.cpp 内を "isSyncFFT_" で検索していただければどのような手法で同期しているかわかるかと思います。

セマフォの欠点はコマンドバッファの途中で停止することが出来ないという点です。

Clustered Rendering のライトカリング処理、シャドウマップ描画、モデル描画(Forward Lighting) があったと仮定しましょう。

理想的な処理の流れは以下のようになります。

セマフォを使用する場合はどうしても処理ごとにコマンドバッファを生成、その同期を行わなければなりません。

図のように、シャドウマップ描画のコマンドバッファ、ライトカリングのコマンドバッファ、モデル描画のコマンドバッファを用意する必要があります。

イベントであればグラフィクスキューの2つの処理はコマンドバッファを分けず、同期すべき部分でイベントを待てばいいだけなのですが、Queue Family が違う場合は使用できませんので諦めましょう。

というわけで、いつもどおりにサンプルは以下から。

Sample006が今回のサンプルです。

GitHub

次回は未定ですが、そろそろOBJファイルでもいいから読み込んでモデルが表示できるようにしたいですね。