やっていくVulkan入門

補講2. セマフォとフェンスによる同期処理

この記事は2章4節(コマンドバッファ)までの知識、および部分的に3章4節(パイプライン)の知識を前提としています。


GPUに投げた処理は基本的に非同期処理になります。そのため、レンダリングなどの処理をGPUに投げた場合、状況に応じて処理の完了を待つ必要があります。このための機構が「フェンス」や「セマフォ」です。これらの機構を適切に利用することで、処理の順序関係がおかしなことにならないで済みます。

フェンスもセマフォもGPUの処理を待つための機構であることに違いはありません。この2つの主な違いは、フェンスがホスト側で待機するための機構であるのに対し、セマフォは主にGPU内で他の処理を待機するための機構であることです。


フェンス

論理デバイスのcreateFenceメソッドでフェンスオブジェクトを作成できます。

vk::FenceCreateInfo fenceCreateInfo;

vk::UniqueFence fence = device->createFenceUnique(fenceCreateInfo);

フェンスオブジェクトは内部的に「シグナル状態」と「非シグナル状態」の2状態を持っています。初期状態(あるいはリセット直後の状態)は非シグナル状態です。

Vulkanでは様々な処理に対して「この処理が終わったらこのフェンスをシグナル状態にしてね」と設定することができ、論理デバイスのwaitForFencesメソッドでシグナル状態になるまで待機することができます。シグナル状態になったフェンスは、resetFencesメソッドで非シグナル状態にリセットできます。

ピンとこない人は、非シグナル状態とシグナル状態を赤信号と青信号のようなものだと考えると良いでしょう。

特定の処理に対して「終わったらシグナル状態にするフェンス」を設定する方法ですが、GPUを利用するほとんどの関数は引数としてフェンスを渡せます。例として、キューのsubmitメソッドの第二引数などでフェンスを渡すことができます。

vk::SubmitInfo submitInfo;

(中略)

queue.submit({ submitInfo }, fence.get());

waitForFencesで待機するときはこのように書きます。

device->waitForFences({ fence.get() }, VK_TRUE, 1'000'000'000);

第一引数に待機するフェンスを設定します。複数渡すことができます。

第二引数は待機方法を指定します。TRUEの場合、「全てのフェンスがシグナル状態になるまで待機」となります。FALSEの場合、「どれか一つのフェンスがシグナル状態になるまで待機」となります。

第三引数は最大待機時間です。ナノ秒で指定します。1秒=10^9ナノ秒です。Vulkanで時間を指定する場合はナノ秒で指定することが多いので覚えておくと良いでしょう。最大待機時間を超えて処理が終わらなかった場合、vk::Result::eTimeoutが返ってきます。本シリーズではあまり真面目に対処していませんが、ちゃんとしたアプリケーションを作る場合は考慮すると良いでしょう。正常に終わった場合はvk::Result::eSuccessが返ってきます。

resetFencesで非シグナル状態に戻します。これで再びフェンスが使えるようになります。処理開始の直前、もしくは処理待ちの終了直後などにリセットすると良いでしょう。

device->resetFences({ fence.get() });

これでGPUに投げた処理を待機できますね。


フェンスに関する補足

フェンスを初期状態でシグナル状態にしておきたい場合はvk::FenceCreateInfo::flagsvk::FenceCreateFlagBits::eSignaledを設定します。処理の都合でたまに必要になり、本シリーズでも4章7節で使っています。

vk::FenceCreateInfo fenceCreateInfo;
fenceCreateInfo.flags = vk::FenceCreateFlagBits::eSignaled;

vk::UniqueFence fence = device->createFenceUnique(fenceCreateInfo);

セマフォ

動作の基本はフェンスとそこまで変わりません。こちらもシグナル状態と非シグナル状態の2状態があり、シグナル状態になるまで待機することができます。

(タイムラインセマフォという、数値カウンターを持つ特殊で高機能なセマフォもあるようですが、今は考えなくて良いです。Vulkanでは2状態のバイナリーセマフォの方が基本的です。)

論理デバイスのcreateSemaphoreメソッドでセマフォオブジェクトを作成できます。

vk::SemaphoreCreateInfo semaphoreCreateInfo;

vk::UniqueSemaphore semaphore = device->createSemaphoreUnique(semaphore);

フェンスと違い、特定の処理の完了に引っ掛けるには構造体に指定することが多いです(例外もありますが)。例としてコマンドバッファの送信時は、vk::SubmitInfo構造体のsignalSemaphoreCountpSignalSemaphoresを用います。ここに指定すると、送信したコマンドの処理が完了した時に指定したセマフォがシグナル状態になります。

vk::SubmitInfo submitInfo;

// (中略)

vk::Semaphore signalSemaphores[] = { semaphore.get() };
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = signalSemaphores;

queue.submit({ submitInfo });

また、待機のために指定する場合も構造体に指定します。最初に説明したように、セマフォはGPU内で他の処理を待機するために使われることに注意してください。コマンドバッファ送信時は、vk::SubmitInfo構造体のwaitSemaphoreCountpWaitSemaphorespWaitDstStageMaskで待機するセマフォを指定します。

vk::Semaphore waitSemaphores[] = { semaphore.get() };
vk::PipelineStageFlags waitStages[] = { vk::PipelineStageFlagBits::eAllCommands };

submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = renderwaitSemaphores;
submitInfo.pWaitDstStageMask = renderwaitStages;

pWaitDstStageMaskだけ良く分からないと思いますが、これはパイプラインのどの時点で待機するかを指定するものです。

単純に処理の開始時点で待機するということもでき、これでも特に動作に問題はありません。しかし例えば、フラグメントシェーダでだけ特定の処理の結果を待つ必要があるならば、「フラグメントシェーダの処理が始まる時点で待機」ということにして頂点シェーダの処理などはシグナル状態を待たずに始める、といったこともできます。

GPUの並列化能力に余裕がある場合はこのように、必要最低限の部分でだけ処理待ちをする方が効率が上がるわけです。

セマフォはフェンスと違い、明示的にリセットする必要はありません。そのセマフォのシグナル状態を待っていた処理が開始すると、自動でリセットされます。こうした動作をするので、ある1つの処理に複数の他の処理が依存するという場合はその数だけセマフォが必要です。

1つのセマフォのシグナルを複数の処理で待つことは一般的ではないため、フェンスのように信号機に例えて考えるのは適切ではないかもしれません。セマフォとは「ある1つの処理の完了」と「ある1つの処理の開始」の順序関係の取り決めであり、1対1の関係を表します。


この節ではセマフォとフェンスについて説明しました。

同期のための機構は実は他にもいろいろあります。パイプラインバリアやイベント、また本編で扱ったレンダーパスもその1つです。しかしフェンスとセマフォが一番基本的で分かりやすく、また使う機会も多いためこのような形にしました。