Skip to content
Go back

拙訳「Breaking Down Barriers - Part 2: Synchronizing GPU Threads」

· Updated:

訳注:拙訳は抜粋や意訳を多く含みます。原著を必ず確認してください。

[Pettineo 2018Pettineo, M. 2018. Breaking Down Barriers - Part 2: Synchronizing GPU Threads. The Danger Zone. https://therealmjp.github.io/posts/breaking-down-barriers-part-2-synchronizing-gpu-threads/.]

url

Programming the MJP-3000#

GPUスレッド同期の基礎を説明するため、MJP-3000という架空のアーキテクチャを例を取って話を進めていこうと思う。これは実際のグラフィクスハードウェアよりさらに簡単にできており、込み入ったこと抜きに高次の概念を説明しやすくするだろう。私の説明が実際のGPUが行っていることとまったく同じであるとは考えないで欲しいが、まったく異なるとこの例が役に立たなくなってしまうので、そうならないように、コマンドや挙動は大まかに現実世界のGPUに基づいている。

(画像)

コマンドプロセッサはサブミットされた順に一度にひとつずつのコマンドバッファからコマンドを読み取り、適当なコマンドに遭遇したら、スレッドキューにスレッドのグループを追加する。シェーダコアはFIFOスキームでスレッドキューからスレッドを引き出し、各々独立してシェーダプログラムを実行する。また、シェーダコアはデバイスメモリの任意の位置で読み書きを行うことができる。Current Cycle Countは特定の例のために実行したGPUサイクル数を示す。

いくつかの理由から、MJP-3000の設計者らは彼らのハードウェアがコンピュートシェーダのみを実行可能であると決定した。これは、彼らが複雑なラスタライゼーションパイプラインに頼らない1つのシェーダステージのみに注力する方が物事をもっと単純にするだろうと感じたから、と私は考えている。そのため、コマンドプロセッサはシェーダコアを走らせるためにスレッドを実際に開始させるDISPATCHコマンド1つのみを持つ。DISPATCHコマンドは、実行すべきスレッド数とシェーダプログラムを指定する。DISPATCHコマンドがコマンドプロセッサと遭遇すると、ディスパッチによるスレッドは即座にスレッドキューへ置かれ、待機中のシェーダコアに捕らえられる。コマンドプロセッサは1サイクルでDISPATCHコマンドをパースし、そのスレッドをキューに入れることができる。シェーダコアは1サイクルでスレッドキューからスレッド1つを取り出すことができる。

Dispatches and Flushes#

デバイスメモリに位置する別個のバッファ要素になにかを書き込む32つのスレッドをディスパッチする単純な例をやってみよう。このディスパッチは完了に100サイクルかかるシェーダプログラム”A”を実行しようとする。コアは16つあるので、我々は開始から終了までに約200サイクルかかると予測するだろう。

(画像)

いくつかの同期を導入しよう。例として、バッファの24つの要素に結果をまとめて書き込むプログラムAのスレッドを24つ走らせるとする。プログラムAの完了後、元々出力バッファだったものからこれら24つの要素を読み出し、別のバッファに書き込む新しい結果を計算するために使われるプログラムBを24つ走らせたい。

(画像)

3ステップ目を見て欲しい。ディスパッチAは16の倍数ではないので、下8つのシェーダコアはアイドル状態にならないようにディスパッチBから引き出した。これにより2つのディスパッチがオーバーラップした。これは競合状態、つまり、ディスパッチAのスレッドが終わる前にディスパッチBのスレッドがディスパッチAの出力バッファを読み出すかもしれない状態にあるため非常にまずい。ではここでFLUSHコマンドを導入しよう。コマンドプロセッサがフラッシュに当たると、すべてのシェーダコアがさらなるコマンドを処理する前にアイドル状態になるまで待機する。“フラッシュ”という用語は、実行するのを待っているすべての待機中の作業を”流し出す”ことを暗示するので、この種の操作に対して一般的である。

(画像)

これはディスパッチBがディスパッチAと一切オーバーラップしないことを保証する。

非常に重要なこととして、フラッシュ/バリアがパフォーマンス上の視点からタダではないことに注意したい。この例では304サイクルが406サイクルへ25%増加している。ディスパッチの間にフラッシュがあると、両ディスパッチの終わり際にアイドル状態のシェーダコアが増える。事実、処理時間の増加はアイドル時間の増加とまったく同じである。つまり、フラッシュなしでは、アイドル状態のコアは0%であるが、フラッシュありでは、コアは平均で時間の25%だけアイドルであった。これは単純な結論を導く。すなわち、フラッシュのパフォーマンスコストは使用率の減少と直接結びついている。これはスレッド同期バリアを導入する相対的コストがスレッド数、スレッドの実行時間、シェーダコアの飽和率に依存して変化するであろうことを意味する。

違った見方をすれば、不必要なフラッシュを取り除くと、シェーダコアの数に相対的なパフォーマンス増加をもたらし得る

(画像)

CPUで実行する命令であるかのようにGPUを見ると、この種の処理のオーバーラップは命令レベル並列化の一種であると考えられるだろう。この場合、並列処理はコマンドストリームで明示的に指定され、VLIWアーキテクチャが行うのに似たものを作り出す。

Waits and Labels#

前の例では、ディスパッチAとディスパッチBの間のバリアによって設けられたアイドル時間にディスパッチCを隠蔽することが基本的に可能であった。ディスパッチCが非常に複雑で100サイクルより長くかかるとしたらどうだろうか?次の例は前の例と同じだが、ディスパッチCが400サイクルかかるとする。

(画像)

AとCにはいまだに幾らかのオーバーラップがあるが、シェーダコアの半分がアイドルとなる状態が300サイクルほど続いた。これは、フラッシュがスレッドキューが空になるのを待つ処理であるために、FLUSHコマンドがディスパッチCの終了を待つことが原因である。ディスパッチCをフラッシュに実行されるようにしても、アイドル状態となる区間が残るので、理想的にはならない。

そこで新しい2つのコマンドのサポートを追加する。ひとつはSIGNAL_POST_SHADERと呼ばれ、すべてのシェーダが完了したらメモリのあるアドレスにシグナル値を書き込むようコマンドプロセッサに伝える(しばしばフェンスラベルと呼ばれる)。これの優れた所はこれが”遅延した”書き込みであることである。つまり、その書き込みはその前にキューに積まれたすべてのスレッドが実行し終わったと特定するとスレッドキューによって実際に処理される。これは前のディスパッチがいまだ実行中のうちに他のコマンドをコマンドプロセッサに移動可能にする。もうひとつはWAIT_SIGNALと呼ばれ、あるメモリアドレスがシグナルされるまでストールして待機するようコマンドプロセッサに伝える。これは特定のディスパッチが完了するのを待機するためにSIGNAL_POST_SHADERと共に用いることができるが、コマンドプロセッサがこれらのステップの間により多くの処理を開始できるというおまけを併せ持つ。

(画像)

前の例で新しいコマンドを試してみよう。

(画像)

D3D12やVulkanでプログラミングする場合には、このsignal/waitの挙動は分割バリアを発行するときに現れることが期待されるであろうものである。分割バリアは実質的にリソースの寿命における2つの異なる点を指定させる。つまり、その現在のステート(読み込み、書き込み、など)でそれを用いて行われる点と新しいステートとなるためにリソースを実際に必要とする点である。これを行い、バリアの開始と終了の間にいくつかの処理を発行することで、ドライバはバリア前の処理が終了するまで待機しつつ中間の処理をオーバーラップできることを知るための十分な情報を得る(ことができるかもしれない)。上記の概説した例に対して、D3D12コマンドは以下のように行われるかもしれないだろう。

  • バッファAに書き込むディスパッチAを発行する
  • バッファAを書込可能から読込可能へと遷移開始する
  • バッファCに書き込むディスパッチCを発行する
  • バッファAを書込可能からあ読込可能へと遷移終了する
  • バッファBに書き込むディスパッチBを発行する

現実世界のGPUでは、分割バリアの利益は同期ポイントの除去よりはるかに大きくなる可能性がある。GPUでのバリアはキャッシュのフラッシュやdecompressionステップのようなことを扱う責任も負うので、単なる”アイドル状態のシェーダコア税”以上にバリアの相対コストを増加させる。このことが非依存処理をバリアとオーバーラップさせようとするさらなる動機をもたらす。しかし、バリア処理を描画やディスパッチとオーバーラップさせられるかどうかは完全にGPUアーキテクチャの仕様に依存する。