訳注:拙訳は抜粋や意訳を多く含みます。原著を必ず確認してください。
[Pettineo 2018Pettineo, M. 2018. Breaking Down Barriers - Part 1: What's a Barrier?. The Danger Zone. https://therealmjp.github.io/posts/breaking-down-barriers-part-1-whats-a-barrier/.]
Breaking Down Barriers - Part 1: What’s a Barrier?#
D3D12かVulkanのプログラミングを少しでもかじったことがある人ならば誰しも、バリアの対処に長々と時間を費やしたことだろう。これらは正しく行おうとするとかなり扱いづらくなることがある。つまり、レンダリングコードを変更するたびに、または、Windowsアップデートを介して新しいバージョンの検証レイヤが導入されるときに、検証レイヤが常に新しい問題を指摘しているように見えてしまう。そして、それに加えて、IHVは、D3D11に匹敵するかそれを超えるGPUパフォーマンスを望む場合には、本当に気を付ける必要があるということ、そして、どのようにバリアを使うかということを伝え続けている。これはただアーティファクトフリーな結果を達成する上での追加の課題である。
では、何がどうなっているのだろうか?そもそも一体全体なぜバリアとやらが必要なのか、そして、これらを誤って使ってしまうとなぜそこまで上手くいかないのだろうか?貴方がかなりのコンソールプログラミングを行ったことのある人やモダンなGPUの低レベルの詳細にすでに親しみのある人であるならば、これらの疑問の答えを恐らく知っているだろう。その場合、この記事はまったくもって貴方用ではない。しかし、貴方がそんな経験の恩恵を持たない人であるならば、私は、貴方がバリアを発行するときのシーンの背後で起こっていることをより良く理解できるよう最善を尽くす。
A High Barrier To Entry#
プログラミングやコンピュータにおける他の例に漏れず、“バリア”という用語はすでにいくらか多重定義されている。ある文脈では、“バリア”は大量のスレッドすべてが動作中のコードの特定のポイントに達するたびに停止しなければならない同期ポイントである。この場合、バリアを動かない壁として考えることができる。つまり、スレッドはすべて動作しているが、バリアに”ヒット”するときに急停止する。
void ThreadFunction() {
DoStuff();
// すべてのスレッドがバリアにヒットするまで待つ
barrier.Wait();
// すべてのスレッドがDoStuff()を呼び出したことが分かっている
}この種のものは大量のスレッドがタスクをすべて実行し終えたタイミングを知りたいとき(fork-joinモデルにおける”join”)、または、他の結果を読む必要のあるスレッドがあるときに役立つ。プログラマとしては、アトミック演算によって更新される変数での”spinning”(条件を満たすまでループすること)、または、待っている間にスレッドをスリープさせたいときにはセマフォと条件変数を用いることでスレッドバリアを実装できる。
他の文脈では、“バリア”という用語は、特にロックフリープログラミングの世界にいくらか入り込んだ場合には、“メモリバリア”を指すだろう(“フェンス”としても知られる)。これらの場合では、コンパイラやプロセッサ自体によって行われるメモリ操作の並べ替えを通常扱っている。これは共有メモリを介して通信するプロセッサが複数あるときに本当に滅茶苦茶にしてしまうことがある。メモリバリアはメモリ操作がバリアの前か後のいずれかに完了することを強制させることで支援し、実質的にフェンスの一方の”側”にこれらを留める。C++では、Windows APIにおけるメモリバリアのようなプラットフォーム固有のマクロやクロスプラットフォームのstd::atomic_thread_fenceを用いてコードにこれらを挿入することができる。一般的なユースケースは以下のように見えるかもしれないだろう。
// DataIsReadyとDataは異なるスレッドで書き込まれる
if (DataIsReady) {
// DataIsReadyの読み出しの*後*にDataの読み出しが起こることを確実にする
MemoryBarrier();
DoSomething(Data);
}これら2つの”バリア”という用語の意味する所は異なるが、これらに共通するものもある。これらは片方が結果を生み出し、もう片方がその結果を読み出す必要があるときに大抵使われる。別の言い方をすると、あるタスクが別のタスクへの依存性を持つということである。依存性はコードを書くときにいつでも発生する。つまり、オフセットを計算するために2つの値を加算するコードの1行があれば、すぐ次の行が配列から読み出すためにそのオフセットを用いるだろう。しかしながら、コンパイラはこれらの依存性を追跡し、正しい結果をもたらすコードを確実に生成するので、貴方はこれに気付く必要はそれほどない。バリアの手動挿入は、コンパイラがコンパイル時にどのようにデータが書き出され、読み出されているかを理解できないような方法で物事を行うまで通常現れない。これは同じデータにアクセスする複数のスレッドによって一般的に発生するが、(他のハードウェアの一部がメモリに書き込むときのような)他の奇妙なケースでも発生し得る。いずれにしても、適切なバリアを用いることで、誤ったデータの読み出しとならないように保証され、結果が従属ステップから可視であろうことを確実にするだろう。
コンパイラはマルチスレッドのCPUプログラミングを行うときに自動的に依存性を扱うことができないので、貴方はマルチスレッドのタスク間の依存性を表現し解決する方法を示すのにしばしば多くの時間を費やすことだろう。これらの状況では、どのタスクが他のタスクの結果に依存するかを示す依存性グラフを構築することが一般的である。このグラフの助けによって、どんな順番でタスクを実行するか、そして、先行するタスクが次のタスクの実行開始前に完了するように、どのタイミングで2つのタスク(またはタスクのグループ)の間に同期ポイント(バリア)を置くべきかを決めることが可能になる。IntelのTBBの文書にあるこの理解しやすい例にあるように、ツリー状の図として描かれるこれらのグラフをしばしば見ることだろう。
(画像)
タスク指向のマルチスレッドプログラミングを一切行わなかったとしても、この図は依存性の概念をかなり明確にしてくれる。つまり、まずパンがなければパンにピーナッツバターは塗ることはできないということだ!大まかに、これはタスクの順番を決定するが(ピーナッツバターの前にパン)、現実にこれを行っていたならば明らかであろうことを若干暗示してもいる。つまり、棚からパンを一枚取ってくるまでピーナッツバターを塗り始めることはできない。貴方自身が現実でこれを行っていた場合、これについて考えてすらいなかっただろう。貴方ひとりだけなら、一度に1つの工程を行うだけであろう。しかし、我々は元々マルチスレッディングの文脈で議論していた。これは並列に異なるコアで異なるタスクを実行しようと試みることについて話していることを意味する。適切に待機しなければ、パンの工程と同時にピーナッツバターのタスクを実行することになってしまい、これは明らかに良くない!
(画像)
これらの種類の問題を回避するため、TBBのようなタスクスケジューラは先行するタスク(または、タスクのグループ)が完全に実行し終えるまでタスク(または、タスクのグループ)を強制的に待機させるメカニズムを提供する。以前に言及したように、このメカニズムをバリア、または、同期ポイントと呼ぶことができるだろう。
(画像)
この種のことはモダンなPCのCPUでは実装するのがかなり容易である。これは、高い柔軟性に加え、アトミック演算や同期プリミティブ、OS提供の条件変数などといった強力なツールを自由に利用できるためである。
Back To GPU Land#
さて、我々はバリアが何たるかの基礎を取り扱ったが、これらがGPUとの対話のために設計されたAPIに存在する理由をまだ説明していなかった。結局の所、ドローコールやディスパッチコールの発行は別個のコアで実行するように大量の並列タスクをスケジュールするのと実際には同じではない、っことだよね?つまり、D3D11プログラムのAPI呼び出しシーケンスを見ると、めちゃくちゃ直列に見える。
(画像)
このようなAPIを通してGPUを扱うのに慣れているならば、GPUが一度にひとつずつのコマンドをサブミットされた順序で実行すると考えても仕方がないだろう。そして、これは長らく真実であったかもしれないが、モダンなGPUでは実際にはかなりずっと複雑であるというのが現実である。私が話していることを示すため、私のDeferred TexturingサンプルがAMDの素晴らしいプロファイリングツールであるRadeon GPU Profilerでキャプチャを取るとどう見えるかを見てみよう。
(画像)
このスニペットはフレームの一部だけ、具体的にはシーンのジオメトリのすべてがGバッファにラスタライズされる部分を示している。左側はドローコールを示し、右側の青線はドローコールが実際に実行を開始するときと終了するときを示す。そして、まさかの、ここには全体にたくさんのオーバーラップがある!PIX for Windowsでやった場合でも若干の差異はあれど同じものを確認できる。
(画像)
これはPIXのタイムラインビューのスニペットである。これは、同様に同じドローコールのシーケンスに対する実行時間を示している(先のRGPキャプチャは大幅に劣るRX460で行われたが、今回はGTX1070でキャプチャした)。同じパターンを確認できる。つまり、描画は大まかにサブミッション順に実行し始めるが、至る所でオーバーラップする。いくつかの場合では、描画は先の描画が完了する前に終了するだろう!
GPUについて少しでも知っているならば、これはそれほど驚愕するべきものではない。結局の所、GPUがIHVが言う所の何千何万の”シェーダコア”からほぼ構成され、これらのシェーダコアのすべてが”あきれるほど並列”な問題を解くために一緒に動作する、ということは誰もが知っている。近頃、描画を処理するために行われる処理のほとんど(とディスパッチを処理するために行われる処理のすべて)はこいつらで処理される。これはHLSL/GLSL/MetalSLコードからコンパイルされるシェーダプログラムを実行する。確かに、シェーダコアが単一のドローコールから数千の頂点を並列に処理することと、トライアングルのライスタライズから生じる何千何万のピクセルで同じことをすることは理に適っている。しかし、複数のドローコールやディスパッチを互いに重複させるようにして、実際の高レベルのコマンドまでも並列に実行することは本当に合理的なのだろうか?
その正解は”絶対にYES!“である。実際に、ハードウェア設計者たちは自分らのGPUが描画の間にいくつかのステート変更があったとしてもこれを確実に行えるよう長年に渡り尽力してきた。デスクトップGPUでは、ピクセルシェーダが描画順に完了しなくてもブレンド処理を行うことができるように、ROP(ピクセルシェーダの出力を受け取り、実際にメモリに書き込むことを担当するユニット)を設計さえしている!このようにすることはシェーダコアをアイドル状態にさせないようにするのに役立つ。これは、その結果として、より良いスループットをもたらす。現時点で完全に理解できなくても、これがそうなる理由を解説する今後の投稿でいくつかの例を一通り説明するつもりなので、心配しなくて良い。ただ今は、ドローやディスパッチがオーバーラップできるようになると一般により高いスループットを引き起こすということを額面通りに受け取っておいてほしい。
ドロー/ディスパッチに由来するGPUのスレッドがその他とオーバーラップ可能であるならば、2つのタスクの間にデータ依存性が存在する場合にそれの発生を防止する方法をGPUが必要とすることを意味する。これが発生する際には、CPUと同様に、スレッドのグループがその処理をすべて完了したときを知るためにスレッドバリアとほぼ同じ何かを挿入することは理に適っている。実際には、GPUはこれを、次のディスパッチを始める前に未処理のコンピュートシェーダスレッドすべてが終了するのを待つような、非常に荒い方法で行う傾向にある。GPUは動き出す前にすべてのスレッドが”排出”されるのを待つであろうことから、これは”フラッシュ”とか”アイドル待ち”と呼ばれたりする。だが、そのさらなる詳細は次の記事で述べようと思う。
Caches are Hard#
(訳注:以降、要約・抜粋のみで構成しています。)
CPUでは、タスクの読み出しが先に起こらないと保証するために適宜正しいメモリ/コンパイラバリアを置いている限り、キャッシュ階層(各コアに1つずつあるL1、各コアで共有されるL2、あったりなかったりするL3)のある場合に正しい結果が得られるかを気にしなくてよい。これはx86コアのキャッシュがコヒーレントである(常に最新の状態を保つ)ためである。
GPUでは、キャッシュは常に厳密な階層構造を取るわけではなく、時折互いに同期ずれを起こす。したがって、古くなったデータがキャッシュ内に複数存在するとき、スレッドのオーバーラップが起こらないことを保証するだけではread-after-write依存性を解決するのに不十分であり、キャッシュを無効化またはフラッシュ(変更されたキャッシュラインを次段に書き込むこと)する必要がある。
Squeezing Out More Bandwidth#
GPUは日に日に計算に注力するようになってきているが、未だピクセルのグリッドにトライアングルをラスタライズするためにカリカリに最適化されている。つまり、ROPが毎フレームに大量のメモリを読み書きすることになることを意味する。
GPU設計者はこの帯域幅の使用率がボトルネックにならないようにロスレス圧縮技術をハードウェアに搭載してきた。長年に渡り使われるたくさんの固有技術があり、その厳密な詳細は公になっていない。しかし、AMDとNVIDIAは最新アーキテクチャのDelta Color Compressionの特有の実装についてほんの少しだけ情報を提供している。
ROPは圧縮されたデータの扱い方を理解しているかもしれないが、シェーダがテクスチャユニットを通してランダムアクセスする必要があるときにも同様である必要はない。これはハードウェアやテクスチャの使い方に依存して、テクスチャの内容が従属タスクによって読み込み可能になる前にdecompressionステップが必要であることを意味する。これはGPUや明示的APIでは”バリア”の領分である。
But What About D3D?#
D3D12やVulkanといったAPIを見てみると、これまでに話したことと直接対応するものは実際には存在しない。これらはGPUの固有の詳細であるため、明示的APIとはいえ抽象化を越えて漏れ出ることはないだろう。
D3D12/Vulkanのバリアはより高レベルであり、パイプラインステージのある方からもうひとつの方へのデータの流れを記述することを目的としている。別の言い方をすれば、さまざまなタスクや機能ユニットに関してデータの可視性における変化をドライバに伝えるということである。
D3D11では、ドライバが実行時にリソースの可視性の変化を見つけて適宜バリアを挿入していたので、我々が行う必要がなかった。これは自動的に正しい結果を得られるという点で素晴らしいが、いくつかの理由で良くないことでもある。
- リソースおよび描画/ディスパッチの自動追跡は高価であり、レンダリングコードからフレームあたりに数ミリ秒をひねり出したいときには良くない
- 読み書きを別のスレッドでバインドすると、ドライバは結果を直列化しない限りリソース寿命を判別できないので、並列にコマンドバッファを生成するためには非常にまずい
- コンテキストがすべての描画/ディスパッチで完全な入出力セットを知っている明示的なリソースバインディングモデルに依存しているので、バインドレスリソースアクセスで行われる素晴らしいことを妨げる可能性がある
- ある場合では、シェーダがデータにアクセスする方法の知識不足によって不必要なバリアを発行するかもしれない
D3D12やVulkanで考えると、必要な可視性変化をドライバに提供することでこれらの欠点を取り除くことができる。