CUDA

[CUDA]カスタムatomicMinで最小値とそのときのindexとを同時に取得する

値や構造体の配列を入力として、それぞれの入力値に対して何らかの計算式などで何かしらの値を求め、その値の最小値(または最大値)と、そのときの配列のindexとをセットで欲しいときがあります。
例えば、
std::vector values = { 3, 5, 2, 7, 1, 9 };
が入力値で、この入力値の最小値とそのときのindexが欲しいとき、
min_value = 1;
index = 4;

です。

これをCUDA上でやりたいときがあります。もちろん、入力配列の最小値を求めるだけであればCPUでやったほうが普通は速いため、実際には、入力値や入力構造体値からCUDAカーネル上で何かしらの計算を行い、有効な最小値の候補が見つかったスレッドに対して最小値かどうかを判定する、という使い方になるかと思いますが、単純化のためここでは単に入力値が最小値候補だと仮定して話を進めます。

CUDAでは複数スレッドが同一メモリに同時に書き込むことを防ぐためのatomic関数が用意されており、最小値を求めるためのatomicMinも用意されているのですが、複数の値を同時にthread safeに書き換えることは出来ず、16bit, 32bit, あるいは64bitの単一の変数を書き換えることしかできません。
複数行に渡ってmutex的なことを行ったり、複数の変数を持つ構造体に対してthread safeに値を書き換えることは基本的には出来ません(…たぶん)。

ですが、最小値(または最大値)とそのときのindexがどちらも32bit以下の場合には、共用体(union)を使って無理やり64bit変数として解釈することで、2つの値をthread safeに書き換えられるというstack overflowの記事を見つけました。

cuda – How can I implement a custom atomic function involving several variables? – Stack Overflow

いままで共用体なんて使ったことがなく、というか使いどころを知らなかったのですが、まさかこんなところで使えるなんて驚きです。天才的な発想ではないかと思いました。
リンク先のコードをC++とCUDA thrustを使い、その他若干のrefactoringを行ったコードがこちらです。

共用体ValueAndIndexの前半32bitに最小値を、後半32bitにindexを格納するようにし、atomicMinを用いるときはunsigned long long int値として解釈するというテクニックです。
かなり天才的な発想ではないかと個人的には思うのと、stack overflowでは模範解答的に扱われているのですが、atomic関数について勉強すると、実は上記の方法はもう少し改良の余地があることがわかります。

ちなみに上記コードを走らせると、
GPU Original: (min_value, index) = (0, 11101), 0.191msec.
CPU: (min_value, index) = (0, 11101), 0.036msec.

というような結果になり、当たり前ですがこの程度であればCPU計算のほうが速いです。

さて、上記で最も大事なカーネル関数を注意深く見てみましょう。

これ、最低でもwhile文が2回まわってしまいます。他のスレッドからのmin_value_and_indexへの操作がなく、1回目のwhile文内でatomicCASにおいて
min_value_and_index->ulong == current.ulong
だったとしても
current.ulongには古い値が代入されます(atnomicCASの仕様です)。つまり、min_value_and_indexにはvalue_and_indexが正しく代入されているのにcurrentには代入前の古い値が代入されるため、新しい値が代入されても
value_and_index.GetValue() < current.GetValue()
を満たしてしまい、2回目のwhile文が走ります。
2回目のwhile文内では
min_value_and_index->ulong != current.ulong
なので、atomicCASによって値が変わることは無く、一方でatomicCASによって
current.ulong == value_and_index.ulong
となるため、これでようやく
value_and_index.GetValue() == current.GetValue()
となりwhile文を抜けます。

atomic関数についてはNVIDIAが2013年のGPU Technology Conferenceでatomic関数に特化した講演(?)をしているらしく、貴重なスライドを見ることが出来ます。

Understanding and Using Atomic Memory Operations
Lars Nyland & Stephen Jones, NVIDIA GTC 2013

このスライドで解説されているLock-Free Data Updatesの考え方で、先ほどのカーネルを改良すると以下のようになります。

Programming Guide :: CUDA Toolkit Documentation
8.12. Atomic Functions

に記載されている、double値でのatomicAdd演算のサンプルコードと同じ変数名を用いました。上記のようにすることで、中間変数assumedが1つ増えてしまいますが、atomicCASは最低1回で済みます。
ただし、オリジナルのものと比較して速度が改善されるかというとたいして変わりません。むしろ改良したもののほうが時々遅くなることもあります。このあたりは実行時のGPUの状態(?)次第でまちまちです。
試しに実行してみると以下のようになりました。

GPU Original: (min_value, index) = (0, 16912), 0.193msec.
GPU Modified: (min_value, index) = (0, 34187), 0.183msec.
CPU: (min_value, index) = (0, 15386), 0.038msec.

ん、何やら別の問題が発生しているようです…。indexの値が異なっています…!
実は最初に紹介したstack overflowのサンプルコードでは入力要素数が5000しかなかったために気付かれにくかったのですが、min_valueを持つ要素が複数あるとき、CPUでは必ず最小のindex値が選ばれますが、GPUではどのthreadが最速で終わるかはその時々で異なるため、どのindexが選ばれるかは全くわかりません
上記の場合、50000個の入力値に対して入力値が最小値である0となっていたものが複数あり、indexを書き出してみると
The indices of the min_value are… 15386, 16912, 17974, 28312, 34187,
となっていました。つまり、この5つのindexのどれであっても最小値は0なので、どのindexも正しいです。
最小値が複数ある場合に、どの場所の要素を取ってきても構わないのであればこれで問題ありませんが、CPUでの処理とGPUでの処理とで結果が異なることがあるのは気持ち悪いです。
ので、上記改良したカーネルにさらに少しだけ手を加えて、CPUとGPUとで必ず結果が同じになるようにしましょう。こうすれば良いです。

これで、常に最小のindex値を得られるようになりました。7行目の不等号を反対向きにすれば最大のindex値を得ることも出来ます。
4行目の不等号を入れ替えればatomicMaxにもなります。
実行結果は例えば以下のようになります。

GPU Original: (min_value, index) = (0, 43739), 0.195msec.
GPU Modified: (min_value, index) = (0, 33730), 0.268msec.
GPU Modified New: (min_value, index) = (0, 7930), 0.239msec.
CPU: (min_value, index) = (0, 7930), 0.036msec.
The indices of the min_value are… 7930, 18153, 33730, 43739, 46528, 48255,

CPUでの結果と必ず一致すると安心出来ますね。
もちろん、オリジナルのカーネルでもwhileの条件式を書き足せば、CPUと同じ結果にすることが出来ます。

以上、atmoic関数に関する非常にマニアックなネタでした。

最後に、ソースコード全体を載せておきますね。

コメントを残す

メールアドレスが公開されることはありません。