ピクセルシェーダーにおけるAtomic加算(2)

先のポストで紹介したピクセルシェーダー内のAtomic演算に関してもう少し考えてみたいと思います。

Atomic操作が実行される場所

下図は、近年のデスクトップ向けGPUを抽象的に書いたものです。基本的には演算クラスタとVRAMがクロスバーで接続されている形になっています。
演算クラスタとVRAM側双方にキャッシュ機構がついています。DirectXComputeやCUDA、OpenCLなどは、スレッドの生成時に、スレッドブロックを指定できますが、既存のグラフィックスパイプラインのシェーダーステージでは指定出来ません。したがって、すべてのシェーダーステージの演算がすべての演算クラスタで実行される可能性があると考えるのが一番自然です。そのためシェーダー内で共有するバッファーは基本的にはL2サイドに存在しなければなりません。

もしL2サイドにあるメモリに対するAtomic演算を演算クラスタ側で行うと、発生するInterlockやクロスバーのオーバーヘッドは巨大なものになるのは容易に想像がつきます。そのためGPUはglobalメモリ領域のAtomic演算自体をL2側で処理すると思われます。演算クラスタからAtomic演算のリクエストをパケットのような形で送出し、ROP側で演算を行い結果を返すようなイメージです。そうすればInterlockのオーバーヘッドは最小限で済みます。
この処理の実行時間のイメージとしては、テクスチャのサンプリングでL1キャッシュをミスして、L2キャッシュにヒットした時にに近いのではないかと思います。ただし同じアドレスへのAtomic処理が他の演算クラスタからも行われれば、Interlockによる遅延が発生するため、このイメージより遅くなると思います。

複数のアドレスに対するAtomic操作

Atomic操作を高速に処理するために、許されるならAtomic操作するアドレスを複数用意して分散することで、Interlockによる遅延を低減できると考えられます。
以下は先のポストで作成したサンプルコードの、ピクセルシェーダーのスレッドごとにAtomic操作を行うアドレスを変えた場合のアドレスの種類の数と処理時間です。SV_POSITIONを用いて適当に操作するAtomicのアドレスを変更しました。

1つ 2つ 4つ 8つ
5.3ms 41ms 21ms 19ms

テストは私の環境(GeForce GTX680)で行いました。操作するAtomicのアドレスが1つの場合が、発生すると思われるInterlock時間は一番長くなると考えられるのですが、実際はこれが一番早く処理が終わります。2つから8つにかけては、Interlockで生じる待ち時間に伴うと思われる処理時間の差が生じるようです。なぜこんな処理時間になるのかは、このままでは不可解です。そこでもう少しテストコードを書いてみます。

uint pos = (uint)(input.Pos.x + input.Pos.y * 1280.0);
uint oldIdx;

#if FAST_CODE
  if (pos%2 == 0)
    rwBuffer0.InterlockedAdd(0, 1, oldIdx);
  else
    rwBuffer0.InterlockedAdd(4, 1, oldIdx);
#endif

#if INTER_CODE
  if (pos%2 == 0)
    rwBuffer0.InterlockedAdd(pos%2*4, 1, oldIdx);
  else
    rwBuffer0.InterlockedAdd(pos%2*4, 1, oldIdx);
#endif

#if SLOW_CODE
  rwBuffer0.InterlockedAdd(pos%2*4, 1, oldIdx);
#endif

このコードは、どれも2つのアドレスに対するAtomic操作を行うコードです。defineに答えを書いてしまいましたが、この3種類のコードは処理時間が異なります。処理時間は以下のようになりました。

SLOW_CODE INTER_CODE FAST_CODE
GTX680 41ms 12ms 9ms
GTX560TI 520ms 82ms 69ms

思ったよりも大きな差になりました。ついでにFermi世代のGPUでも試してみました。値は大きく異なりますが、傾向は同じです。なぜこのようなことが起こるのでしょうか。

GPUのプログラムカウンタ

GPUのプログラムカウンタは、ある程度のスレッドの塊に対してひとつしかありません。このスレッドの塊はwarpやwavefrontという呼び方で呼ばれています。このスレッドの塊の中で実行する命令に分岐が発生した場合は、スレッドごとに実行するかどうかをbitによるマスクのようなもので制御しながら、すべての実行パスを実行します。たとえば上記のコードにあるIF文による分岐の場合は、最初にスレッドごとに条件のテストを行い、生成された実行マスクを適用しながら、PASSした場合の命令をデコードして実行します。次に実行マスクを反対に適用し、FAILした場合の命令をデコードして実行します。したがってすべてのスレッドは、マスクに従い命令を実行する/しないに分かれ、実行するスレッドは処理をまったく同時に実行します。プログラムカウンタはひとつしか無いからです。GPUはこの「全く同時に実行する性質」をうまく利用しているケースが多々あるようです。

上記のサンプルコードの、SLOW_CODEでは、異なるアドレスに対するAtomic操作が同時にリクエストされます。しかしFAST_CODEとINTER_CODEでは、同時に実行するAtomic操作はひとつのアドレスに対してのみです。
ここで具体的な例を考えます。32本のスレッドが同時にInterlockedAdd操作を同じアドレスに対して行うとします。この場合L2側に依頼すると思われるAtomic操作リクエストは1つで済むはずです。なぜなら、例えばインクリメントの値がすべて1の場合なら、32のインクリメントを1つだけ行うようにL2側にリクエストし、行われたAtomicの結果(差分)を各々のスレッドに分配すれば正しく処理を行うことが出来ます。おそらくGPUはこのトリックを利用していると思われます。ではSLOW_CODEの場合ですが、処理を行うアドレスは2つのみなので、こちらもリクエストを2つ行えば正しく処理をすることが出来るはずですが、そのようにはなっていないようです。残念ながら処理するアドレスが何種類あるかを確認してAtomic操作をマージすることを、限られた実装面積と処理時間の中でで行うのは簡単なことではありません。

スレッドの塊 – warp

先ほどSLOW_CODEは処理が遅いとなりましたが、実際にはスレッドの塊であるwarpごとにアドレスが変わるように処理をすれば、同時に実行されるスレッド内で処理するアドレスが同じになるため、遅くなることは無いはずです。どのようなピクセルの塊でスレッドが呼び出されているかはよく分かりませんが、値を調整して処理時間を観察すればすぐに分かります。今回のサンプルコードで用いているRenderTargetでは4×8 pixelごとにピクセルシェーダーのwarpが生成されている様なので、アドレスの計算をそれに準じるものに変更して各コードを実行してみます。

uint pos = (uint)input.Pos.x/4 + (uint)input.Pos.y/8 * 1280/4;
rwBuffer0.InterlockedAdd(pos%2*4, 1, oldIdx);
SLOW_CODE INTER_CODE FAST_CODE
GTX680 7.7ms 8.4ms 6.8ms

実行結果はおおむね予想通りになりましたが、依然FAST_CODEが一番早いという結果になりました。おそらくですが、アドレスが直値で記述されている場合はそのほかにも最適化が行われているようです。もしくは変数posの計算が最適化されたのかもしれません。

まとめ

DirectX11やOpenGLのARB_shader_atomic_counters拡張によって、シェーダー内でAtomic操作が可能になり、非常に複雑な処理がGPU内で完結できるようになりました。しかしGPUのAtomic操作はCPUで行われているものとは、処理そのものが異なるようです。これを理解して使用しないと、折角のAtomic操作がパフォーマンスの足を引っ張ることになってしまいます。

コメントを残す

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

WordPress.com ロゴ

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

Twitter 画像

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

Facebook の写真

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

Google+ フォト

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

%s と連携中