Direct3D 12を始める – Fence (1)

Windows 10 Technical Preview向けのWindows SDKには、最新のDirectXのライブラリが同梱されています。
その中でもまだ情報が少ないDirect3D 12に焦点を当て、筆者が調べたことをまとめて記事にしていきます。

前回はCommand Queue、Command List、Command Allocatorについて説明しました。
今回はコマンドを管理する上で重要となるフェンスについて説明します。

現時点でSDKはプレビュー版であり、リリース版では仕様変更の可能性があります。
書きかけにつき今後図を追加するかもしれません。


■フェンスとは

Fenceは、Command QueueにサブミットしたCommand Listの完了を検知するために使います。

ID3D12Device::CreateFence()で作成します。

前回の記事で、

Command AllocatorのReset()は、…今までバインドされたすべてのCommand ListがGPUでの実行を終えるまで、Reset()を呼び出してはいけません。 この順序を守らないと、…継続動作できなくなります。

と説明しましたが、なぜ正しく動作できないかを詳しく説明します。

Command Allocatorに溜め込まれたDrawCallは、GPUの大部分を占めるコア(ALU)を制御する専用プロセッサ(NVIDIA GeForceのGiga Thread Engine、AMD RadeonのCommand Processor、Intel HD GraphicsのCommand Streamer)が理解できる命令に変換され、グラフィックスメモリに転送され、そのあとプロセッサをキックしてコマンドが消化されていきます。
Command AllocatorをReset()すると、このグラフィックスメモリを先頭領域まで巻き戻し、コマンドのサブミットによって上書きしていきます。
つまり、コマンドが完全に消化される前にReset()してしまうと、プロセッサが実行中の命令を上書きし、何が起きるか分からなくなります。

そこで、Command AllocatorのReset()を呼び出す前にFenceを使って同期を取り、サブミットしたコマンドが完了した後にリセットします。


■Fenceの仕組み

Fenceをうまく扱うには、FenceとCommand Queueがお互いにどんな振る舞いをしているかを知る必要があります。

Fenceは内部でUINT64のカウンタを持っています。
デフォルト値は0です。
このカウンタの値はID3D12Fence::GetCompletedValue()で取得できます。
また、ID3D12Fence::Signal()で設定できます。

ただ、FenceのカウンタをCPUから操作しても、あまり使い道がありません。
大抵の場合は、Command Queueと、CreateEvent()で作成したHANDLEと連携して使います。

ID3D12CommandQueue::Signal()を呼び出すと、以前にサブミットされたコマンドがGPU上での実行を完了している、あるいは完了し終わったとき、自動的に第1引数で指定したFenceの値を第2引数の値に更新します。
ドキュメントでは、この値を”fence value”と呼んでいます。
“fence value”は、原則として前の値より大きい値を指定します。
シンプルなアプリケーションでは、1から順に増えていくフレーム番号を指定すれば良いでしょう。
(ドキュメントにはfence valueを1ずつ増やすID3D12CommandQueue::AdvanceFence()というAPIが紹介されていましたが、VS2015 RCの登場とともに消滅しました。)

この値の更新をCPUが検出するには、2つの方法があります。
イベントオブジェクトを使う方法と、ポーリングする方法です。

ID3D12Fence::SetEventOnCompletion()を使うと、fence valueが第1引数の値になったとき、第2引数のイベントオブジェクトをシグナル状態に遷移させます。
イベントオブジェクトはCreateEvent()で作成します。初期状態は非シグナル状態にしておきましょう。
あとは、通常のスレッドプログラミング同様、WaitForSingleObject()でスレッドを寝かせれば、GPUの実行完了後に叩き起こしてくれます。

一方、ID3D12Fence::GetCompletedValue()の値が変化するまでビジーループを回せば、これと同等の結果が得られます。
ですが、格好悪いのでお勧めしません。

別のケースとして、Command Queueが別のCommand Queueの実行完了を待ちたい場合を考えます。
例えば、Compute Command Queueで物理演算を実行し、Graphics Command Queueでその結果を使うような場合です。
先の例と違い、GPU内で完結するケースなので、CPUと同期を取る必要がありません。
この場合、ID3D12CommandQueue::Wait()を呼び出します。
引数はSignal()で渡したものと同じ値を使います。
すると、通常は実行順序が保証されないCommand Queue同士の実行順を保証させることができます。
(なお、リソース間の同期を取る仕組みとしてBarrierもありますが、これはQueueを跨いだ同期には使えません。)

試しにWait()で自分のCommand Queueの完了を待たせた場合も正しく動作しているように見えましたが、Microsoftが想定する実装ではないと思うので、止めておいたほうが無難です。


■遅延の考慮

ほぼすべてのGPUは、CPUからのコマンドのサブミットと、GPUでのコマンドの消化が、並列して実行できるようになっています。
サブミット直後にFence同期を入れてしまうと、並列実行が阻害されてしまいます。
格ゲーや音ゲーのように、遅延が1フレームも許されないゲームではこれで問題ないかもしれません。
しかし、最近の映画品質のFPSゲーやオープンフィールドアクションゲームでは、CPUもGPUもフル稼働させないと処理が追いつきません。
そこで、最初に1/60秒かけてCPUからコマンドをサブミットし、次の1/60秒かけてGPUでコマンドを消化します。
Direct3D 12でこのスタイルを実現するには、Command AllocatorをN個作り、1フレームずつ順番に使い、各フレームの先頭でCommand AllocatorをReset()します。

また、Windowsの場合、サブミットされたコマンドの実行は3フレーム遅延されます。
(この挙動についてはNVIDIAの中の人がいろいろ調べて記事にしています。)
IDXGIDevice1::SetMaximumFrameLatency()で変更することができますが、残念ながら現在のSDKではID3D12DeviceからIDXGIDevice1をQueryInterfaceすることができませんでした。

GPUでのコマンド消化が3フレーム遅延する可能性があるということは、Command Allocatorの解放も3フレーム遅延させた方が、フレームレートが不安定な時のカクつきを低減できます。
また、AMDのAlternative Frame Randering (AFR)のように、マルチGPU環境で描画が複数フレームを跨がって実行する場合、最低でも2フレーム遅延しなければマルチGPUの効果がなくなってしまいます。


■サンプルコード

とりあえず動かす方法です。CPUでコマンドをサブミットし、GPUの処理が完了するまで待ちます。

//===== 初期化 =====//
void Init()
{
	D3D12CreateDevice(...);
	mDev->CreateCommandAllocator(...);
	mDev->CreateCommandQueue(...);
	mDev->CreateCommandList(...);
	mDev->CreateFence(...);
	mFenceEveneHandle = CreateEvent(...);
	mFrameCount = 0ull;
}

//===== 描画 =====//
void Draw()
{
	mFrameCount++;
	/* ここでmCmdListへコマンドを記録 */
	mCmdList->Close();
	mCmdQueue->ExecuteCommandLists(...);
	mCmdQueue->Signal(mFence, mFrameCount);
	mFence->SetEventOnCompletion(mFrameCount, mFenceEveneHandle);
	WaitForSingleObject(mFenceEveneHandle, INFINITE);
	mCmdAlloc->Reset();
	mCmdList->Reset(mCmdAlloc, nullptr);
}

次に、CPUがコマンドをサブミットした後、GPUの完了を待たずに、次のフレームの描画準備を開始する方法です。

//===== 初期化 =====//
void Init()
{
	D3D12CreateDevice(...);
	for (int i = 0; i < MaxFrameLatency; i++) {
		mDev->CreateCommandAllocator(...); // mCmdAlloc[MaxFrameLatency]に作成
	}
	mDev->CreateCommandQueue(...);
	mDev->CreateCommandList(...);
	mCmdList->Close();
	mDev->CreateFence(...);
	mFenceEveneHandle = CreateEvent(...);
	mFrameCount = 0ull;
}

//===== 描画 =====//
void Draw()
{
	mFrameCount++;
	const int cmdIndex = mFrameCount % MaxFrameLatency;
	// 前のCommandAllocatorを使いまわすとき
	if (mFrameCount > MaxFrameLatency) {
		// CommandAllocatorを使用したCommandQueueに対し、実行完了時にFenceへ通知するよう命令する
		mFence->SetEventOnCompletion(mFrameCount - MaxFrameLatency, mFenceEveneHandle);
		WaitForSingleObject(mFenceEveneHandle, INFINITE);
		mCmdAlloc[cmdIndex]->Reset();
	}
	mCmdList->Reset();
	/* ここでmCmdListへコマンドを記録 */
	mCmdList->Close();
	mCmdQueue->ExecuteCommandLists(...);
	mCmdQueue->Signal(mFence, mFrameCount);
}

CommandAllocatorを許容する遅延フレーム分だけ用意しているところが、最大の変更点です。
あとは、フレームの終端で同期待ちしていたところを、次のフレームの開始時に行うように変更しています。

CommandListは作成時にCommandAllocatorを要求し、自動的にコマンド記録開始状態になってしまうので、作成直後にCloseしています。
何かカッコ悪いので、早くMicrosoftのサンプル実装を見てみたいところです。

ちなみに、ID3D12Fence::SetEventOnCompletion()の引数に指定する整数は、1以上でなければならないようです。
0を指定すると、描画コマンドをサブミットしていなくてもFenceに対して実行完了であることを通知してしまい、同期として機能しませんでした。
(その挙動を知らずにGithubにコードをコミットしたところ、海外の方から指摘を頂きました。
 仮想PCではそれでも動いていましたが、Radeon搭載PCで動かしたところ、デバッグレイヤーからエラーが通知されました。)

■まとめ

CommandQueueとCPUの間で同期を取る方法について紹介しました。

なお、この説明のために実装したソースコードはgithubにコミットしてあります
MeshサンプルとParallelFrameサンプルを参考にしてください。

広告

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト / 変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト / 変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト / 変更 )

Google+ フォト

Google+ アカウントを使ってコメントしています。 ログアウト / 変更 )

%s と連携中