コンピュートシェーダー

このシリーズの "公式な" 最終章となるはずの章へようこそ──GPU に関する記事はこれからも書くつもりだが、このシリーズはもう十分長くなっている。ここまではグラフィックスパイプラインに通常含まれる全てのモジュールを巡りながら、ときには細かくときには大まかに各モジュールの説明を行ってきたが、後は DX11 で導入された主要な機能が一つ残るだけとなった: コンピュートシェーダーである。

実行環境

このシリーズを通して注目してきたのはアーキテクチャレベルでの全体的なデータフローであり、シェーダーの実行ではない (シェーダーの実行は他の場所で分かりやすく説明されている)。ここまで見てきたステージにおいて、これは各ステージにおける入力がどのように出力へつながるかに焦点を当てることを意味した: 内部の動作は入出力データの形状に支配されるのが普通だった。しかしコンピュートシェーダーでこれは正しくない──このモジュールはグラフィックスパイプラインの一部としてではなく、それ自身で実行される。そのためコンピュートシェーダーの表面的なインターフェースはずっと小さい。

実はコンピュートシェーダーの入力側では、入力データが収まるバッファはほとんどなにも存在しない。バインドされる定数バッファやリソースといった API ステートを除けば、CS は入力としてスレッドインデックスだけを受け取る。次の点は本当に誤解しやすいので、しっかりと頭に刻んでほしい: CS 環境へのディスパッチにおける不可分な一単位としての「スレッド」は、普通「スレッド」と聞いたときに思い浮かべるであろう OS が提供するスレッドとは全く異なる代物である。CS スレッドはそれぞれが固有の識別子とレジスタを持つが、個別のプログラムカウンター (命令ポインタ) とスタックは持たず、個別にスケジュールされることもない。

CS の「スレッド」は 頂点シェーディングにおける個々の頂点や ピクセルシェーディングにおける個々のピクセルと同じような立ち位置にあり、同じような扱いを受ける。スレッドはいくつか (通常は 16~64 個) がまとめられて「Warp」もしくは「Wavefront」となり、これが同じコードをロックステップで実行する。CS スレッドはスケジュールされず、Warp や Wavefront がスケジュールされる (以降この章では Warp を使う。頭の中で Wavefront に置き換えれば AMD 向けの文章になる)。レイテンシを隠すとき、実行が異なる CS スレッドに切り替わることはなく、実行は必ず異なる Warp (スレッドのまとまり) に切り替わる。Warp に含まれる各スレッドは分岐を個別に実行できず、あるコード片を Warp に含まれるスレッドが一つでも実行するなら、全てのスレッドがそのコード片を (大部分のスレッドで結果が捨てられる場合であっても) 処理しなければならない。つまり CS の「スレッド」は SIMD レーンのようなものであり、普通のプログラミングにおける「スレッド」とは異なる。覚えておこう。

これで「スレッド」と「Warp」のレベルが説明できた。その上には「スレッドグループ」のレベルがある。これが扱うのは──なんだと思う?──スレッドのグループである。スレッドグループのサイズはシェーダーのコンパイル時に指定され、DX11 では一つのスレッドグループに 1~1024 個のスレッドを入れることができる。スレッドグループのサイズは一つの数字として指定するのではなく、スレッドの x, y, z 座標を与える三要素のタプルとして指定する。この番号付けスキームは主に二次元あるいは三次元のリソースを読み込むシェーダーコードで利便性を向上させるために存在するが、加えて走査の最適化も可能になる。巨視的なレベルでは、CS の実行は整数個のスレッドグループがまとめてディスパッチされる。D3D11 のスレッドグループ ID も同じ理由で三次元であり、スレッド ID も同様である。

スレッド ID はシェーダーの好みに応じて好きな形で渡すことができ、これが CS に対する入力の中で全てのスレッドが異なる値を受け取る唯一の値となる。これまで私たちが見てきた他のシェーダーと大きく異なるが、この点はまだ氷山の一角に過ぎない。

スレッドグループ

以上の説明からするとスレッドグループは適当に用意した中間階層に思えるかもしれないが、スレッドグループを非常に特別なものとする重要な要素はまだ紹介していない: スレッドグループ共有メモリ (thread group shared memory, TGSM) である。DX11 レベルのハードウェアでコンピュートシェーダーは 32 キロバイトの TGSM へのアクセスを持ち、これは基本的に同じグループに含まれるスレッドの間で通信を行うためのスクラッチパッドとして動作する。TGSM は異なる CS スレッドが通信を行うときの主要 (かつ最も高速) な手段である。

TGSM はハードウェアでどのように実装されるのだろうか? これは非常に簡単で、同じスレッドグループに含まれる全てのスレッド (正確には Warp) が同じシェーダーユニットで実行され、そのシェーダーユニットが少なくとも 32 キロバイト (普通はもっと多い) のローカルメモリを持つというだけである。グループ内のスレッドは同じシェーダーユニットを (つまり ALU なども) 共有するので、共有メモリへのアクセスの仲裁や同期のための複雑な機構は必要ない: 任意のサイクルで一つの Warp だけが命令を発行できるので、どのサイクルを見てもメモリアクセスできるのは一つの Warp だけになる! もちろんこの処理は普通パイプライン化されるが、そうしても基礎的な不変条件は変化しない: シェーダーユニットごとに TGSM は一つだけである。TGSM へのアクセスはパイプラインステージ数個分を必要とするかもしれないが、実際の TGSM からの読み込み (あるいは TGSM への書き込み) は一つのパイプラインステージで実行され、そのサイクルの間のメモリアクセスは必ず同じ Warp で起きる。

しかし、これだけでは実際の共有メモリ通信には十分でない。問題は単純だ: サイクルごとに一セットのアクセスだけが TGSM に向かい、並行アクセスを禁止するための連携が必要ないことは上述の不変条件によって保証される。これによってハードウェアは単純かつ高速になるのでありがたい。しかしシェーダープログラムの視点から見ると、メモリアクセスが起こる順序に関しては何も保証されていない。Warp は多かれ少なかれランダムな順序で実行されるためである。Warp の実行順序はスケジュールの瞬間に実行可能な (メモリアクセスやテクスチャの読み込みの完了を待っていない) スレッドがどれかによって決まる。さらに細かい点として、処理全体がパイプラインされているというまさにその事実によって、TGSM に書き込んだ内容が TGSM からの読み込みで "見える" ようになるまでには数サイクルが必要になる。これは TGSM への書き込み/TGSM からの読み込みが異なるパイプラインステージ (もしくは同じステージの異なるフェーズ) で起こる状況で発生する。よって何らかの同期機構が必要になる。ここで登場するのがバリアである。バリアには様々なタイプがあるが、それらはどれも次の三つの基礎的な要素から構成される:

  1. グループ同期バリア: グループ同期バリアを張ると、現在のグループに含まれる全てのスレッドがそのバリアに到達するまで以降の実行が禁止される。任意の Warp がこの種類のバリアに到達すると、その Warp に実行不可能であることを示すフラグが立てられ、メモリやテクスチャに対するアクセスの完了を待っているかのような扱いを受ける。最後の Warp がそのバリアに到達すると、残りの Warp も実行が再開される。こういった処理は全て Warp スケジューラのレベルで起こる: つまりグループ同期を使うとスケジュールに関する制約が追加される。これはストールを発生させる可能性があるものの、アトミックなメモリトランザクションなどは必要にならない。細かいレベルで少し利用率が落ちることを除けば、グループ同期バリアはコストの低い操作である。
  2. グループメモリバリア: 同じグループ内の全てのスレッドは同じシェーダーユニットで実行されるので、ペンディングされている共有メモリ操作が全て完了することを保証するグループメモリバリアは基本的にパイプラインのフラッシュと同じになる。現在のシェーダーユニット以外のリソースとの同期は必要ないので、このバリアもコストはそれほど高くない。
  3. デバイスメモリバリア: これは同じグループ内の全てのスレッドが全てのメモリアクセスを完了するまでブロックする──直接的なアクセスと間接的な (テクスチャサンプルなどを通した) アクセスの両方を待機する。このシリーズで前に説明したように、GPU のメモリアクセスとテクスチャサンプルは長いレイテンシを持つ──まず 600 サイクル以上、1000 サイクルを超えることも珍しくない──ので、この種のバリアは実行を大きく妨げる。

この三つをいくつか組み合わせて一つのアトミックなユニットとしたバリアが DX11 には何種類か用意されている。その意味論は明らかである。

アンオーダードアクセスビュー

これで CS の入力は片付いて、CS の実行についても少し学んだ。では出力データはどこに入れるのだろうか? この疑問の答えは「アンオーダードアクセスビュー (unordered access view, UAV)」という何とも仰々しくて分かりにくい名前を持つ。UAV はピクセルシェーダーにおけるレンダーターゲットと似ているが、意味論的に重要な違いがいくつかある:

よって "CPU プログラマー" の視点から見ると、UAV は共有メモリマルチプロセッシングシステムにおける通常の RAM に対応する。これより面白いのがアトミック操作に関する話題であり、これは現在の GPU が CPU と大きく異なる設計を持つ領域の一つである。

アトミック操作

現在の CPU において、共有メモリの処理に関する魔法は大部分がメモリ階層 (キャッシュ) で起こる。あるメモリ領域への書き込みでは、書き込もうとするアクティブなコアが対応するキャッシュラインの排他的所有権を最初にアサートしなければならない。通常は MESI やその亜種が使われるが、この詳細をここで話したいわけではない。重要なのは、メモリへの書き込みに排他的所有権の取得が伴うために、二つのコアが同じ場所へ同時に書き込もうとするリスクが完全に排除されることである。こういったモデルでは、アトミック操作は排他的所有権を操作の間保持しておくことで実装される。排他的所有権をずっと保持しておけば、アトミック操作を実行している間に他のコアが同じ場所へ書き込む心配はない。ここでも詳細に踏み込むと (特にページング、割り込み、例外といったものが関係すると) すぐに話が非常に難しくなってしまうが、3000 フィート上空からの景色が分かっていればここでは十分だ。

この種のモデルにおいて、アトミック操作は通常のコアにある ALU とロード/ストアユニットを使って行われ、"興味深い" 仕事はキャッシュで起こる。こうする利点はアトミック操作が (要件が追加されるなど特殊な点が多少あれど) 通常のメモリアクセスになることである。ただ問題がいくつかある: 最も重大なのが、「snooping」と呼ばれるキャッシュ一貫性の標準的な実装において、プロトコルに参加する全てのエージェントが互いに対話できることが要求されることで、これはスケーラビリティ的に深刻な問題となる。この制限をどうにかする手法もある (主要な手法としていわゆる directory-based coherency プロトコルを使うものがある) ものの、メモリアクセスが複雑になり、レイテンシも大きくなってしまう。もう一つの問題はロックとメモリトランザクションが必ずキャッシュラインのレベルで起こることである。もし頻繁に更新されるものの特に関係のない二つの変数が同じキャッシュラインを共有していると、複数のコアの間で "ピンポン" が発生し、一貫性を保証するためのトランザクション (およびそこからの実行速度の低下) が大量に引き起こされることになる。この問題は「false sharing」と呼ばれる。ソフトウェアでは問題を起こしている関係のない二つのフィールドを異なるキャッシュラインに載せるようにすることで false sharing を回避できる。しかし GPU ではキャッシュラインのサイズも実行中のメモリレイアウトもアプリケーションからは分からず、制御することもできない。そのためこの問題はより深刻になる。

現在の GPU はメモリ階層を異なる形で構成することで false sharing の問題を回避する。アトミック操作をシェーダーユニットの中で処理する (そして「誰がどのメモリを所有するのか」という問題を発生させる) のではなく、一番近くの共有キャッシュと直接対話するアトミックユニットを用意するという方法である。GPU に共有キャッシュは一つしか存在しないので、こうすれば一貫性の問題は生じない。キャッシュラインはキャッシュに存在する (そしてそれが現在の値である) か、存在しない (そしてメモリにあるコピーが現在の値である) かのどちらかとなる。アトミック操作は二つの部分から構成される。一つはそれぞれのメモリ位置を (キャッシュに存在しなければ) キャッシュに持ってくる処理であり、もう一つは要求された read-modify-write 操作をアトミックユニットが持つ専用の整数 ALU を使ってキャッシュの内容に対して直接行う処理である。アトミックユニットが特定のメモリ位置に対して処理を行っている間、その位置への他のアクセスは全てストールする。アトミックユニットは複数存在するので、それらが同じ瞬間に同じ場所へアクセスしないようにする必要がある。これを実現する一つの簡単な方法として、それぞれのアトミックユニットが特定のアドレスの集合を "所有する" ようにするものがある (この振り分けは静的である──CPU におけるキャッシュラインの所有権のように動的ではない)。これは担当するアトミックユニットのインデックスをアクセスするメモリアドレスのハッシュとして計算することで行われる (ただ現在の GPU がこれを行っているかどうか私は確かなことを知らない。公式ドキュメントにはアトミックユニットの動作の詳細がほとんど載っていなかった)。

与えられたメモリアドレスに対してアトミック操作を行うとき、シェーダーユニットはどのアトミックユニットがその操作を担当するかを計算し、そのユニットが新しいコマンドを受け入れられるようになるまで待ち、それから操作を送信する (アトミック操作の結果が必要ならさらに完了まで待つ) 必要がある。アトミックユニットが処理できるコマンドは一度に一つだけである可能性もあるが、未処理のリクエストを入れる小さな FIFO を持つ可能性もある。また当然、どんな場合でもシェーダーユニットが処理を進められるようにアトミック操作の進行をそれなりに公平にするためのアロケーションやキューに関する細かなテクニックもたくさんある。しかしここでも、詳細については話さない。

最後の注意点として、未処理のアトミック操作はもちろん「デバイスメモリのアクセス」に分類される。メモリ/テクスチャの読み込みや UAV への書き込みと同様、シェーダーユニットは未処理のアトミック操作を覚えておき、デバイスメモリバリアに到達したときはその完了を待たなければならない。

構造化バッファと追記/消費バッファ

何か見落としてない限り、この二種類のバッファが CS に関係する機能として最後の話題となる。そして、まぁなんというか、ハードウェアの視点から見ると、これらについて話すことは特にない。本当だ。構造化バッファはドライバの内部にあるシェーダーコンパイラに対するヒント──具体的には、バッファが固定長のストライドを持った要素から構成され、それぞれの要素がまとめてアクセスされる可能性が高いというヒント──のようなものだが、最終的には通常のメモリアクセスにコンパイルされる。ドライバがバッファ用メモリの位置とレイアウトを決めるときには構造化バッファであることが考慮されるが、今まで考えてきたモデルに全く新しい機能が追加されるわけではない。

追記/消費バッファも同様で、既存のアトミック命令を使って実装される。ただ細かいことを言うと、追記/消費バッファのポインタはリソースの位置を直接指すのではなくはなくリソース外部の副次的データを指しており、リソースには特別なアトミック操作を使ってアクセスするようになっている。また構造化バッファと同様に、バッファが追記/消費バッファであるという宣言があることで、ドライバはバッファの格納場所をより適切に選択できるようになる。

最後に

そして...これで以上となる。次章のプレビューはない。このシリーズは終わったのだ :) とはいえ私にはやるべきことがある。全体の構成を整えたいし、少し書き直したい場所もある──このシリーズのブログ記事は書きっぱなしで推敲もされていないので、もう一度見直して一つの文書としてまとめたいと思っている。それまでの間は他のことをここに書くつもりだ。これまでに受け取ったフィードバックも反映させようと思っている──他に疑問や訂正、コメントがあれば、今のうちに教えてほしい! 体裁の整った最終的なバージョンがいつになるかをここで宣言したくはないが、今年が終わるよりずっと前に終わらせるように努力する。それまでは、さようならだ。読んでくれてありがとう!

広告