e.blog

主にUnity/UE周りのことについてまとめていきます

ComputeShaderを触ってみる その1 ~スレッド編~

概要

並列化可能な、膨大な数の計算を行う場合はCompute Shaderの出番です。
今回はこの「Compute Shader」を触ってみたのでそのメモです。

Compute Shaderの最小単位

Compute Shaderを利用する場合、まずは.computeファイルを作成します。
そして作成したCompute Shaderに以下を記述すると最小の構成となります。

#pragma kernel CSMain

[numthreads(4, 1, 1)]
void CSMain()
{
    // do nothing.
}

このCompute Shaderはなにもしてくれませんが、構成がどうなっているかを知るには十分です。
まず、#pragma kernel CSMainで「CSMain」関数がカーネル*1であることを伝えます。
当然ですが、シェーダ内には任意の関数を定義することができます。
その他の言語の関数と何ら変わらず、関数呼び出しを通して計算を行うことができます。

その中で、カーネルとして動作する関数を#pragma kernelで指定している、というわけです。

スレッド

さて、Compute Shaderは(当然ですが)GPU上で動作します。
そしてGPUと言えば並列処理が得意ですよね。
そのGPUに並列処理をしてCPUの代わりに計算を行ってもらうのがCompute Shaderです。

そしてその並列処理を最大限活用するのが「スレッド」です。

スレッド数は[numthreads(x,y,z)]で指定します。
ここで指定した数だけスレッドが作られ、それぞれがカーネルのコピーとして動作します。

冒頭の例では[numthreads(4,1,1)]となっているので全部で4スレッドとなります。

スレッドと次元

なぜ(4,1,1)なのに4スレッドなのか、と思われたかもしれません。

これは、多次元配列としてスレッド数を指定しているから、というのが理由になります。
つまり、X軸に4つ、Y軸に1つ、そしてZ軸に1つの配列をイメージしてください。
Y,Zともに1なので、実際はX軸のみ、1次元の配列になりますよね。

なので合計で4スレッドになる、というわけです。

試しに[numthreads(4,4,1)]と指定すると、X,Y軸が4つずつ、Z軸が1つの、4 * 4 = 16スレッドが生成されることになります。

なぜこんなまどろっこしい方法でスレッド数を指定するのか、というと、GPUが得意としているテクスチャへのアクセスなどが行いやすいから、というのが自分の理解です。
どういうことかというと、テクスチャは2次元で表されます。幅と高さですね。

そしてテクセルをそれぞれ二次元配列として表した場合、左上を(0, 0)、その右隣を(1, 0)などと表現することができます。

こうした、二次元配列へのアクセスには、ふたつの添字があると便利ですよね。
そして複数次元でスレッドを表すことで、このあと説明する「スレッドグループ」とあいまって、とても簡単に、各テクセルへアクセスすることが可能となります。

要は、二次元(ないし三次元)の要素を扱うのだから、それに応じたスレッドの定義をしておいたほうがいいよね、くらいの理解でいいと思います。(というか自分はそんな程度の理解ですw)

スレッドグループ

さて、スレッドの数については前述の通りです。
これに加えて、さらに多次元の「スレッドグループ」というものがあります。

最初はなんのこっちゃな感じですが、まずはC#側のコードを見てみましょう。

// Get kernel ID by string. Just example below.
int kernel = shader.FindKernel("CSMain");
shader.Dispatch(kernel, 1, 1, 1);

コンピュートシェーダを起動するC#コードは上記のような感じになります。
shaderには、インスペクタなどから.computeファイルを指定するなどして参照を保持しておきます。

そしてFindKernelメソッドを利用して、カーネルIDを取得します。
実はカーネルは、ひとつのシェーダ内に複数記述することができ、上から出現順にIDが連番で振られていきます。
(なので、FindKernelメソッドを使わず、直接0などを指定しても動作します。サンプルのように、カーネルがひとつしかない場合は直接0を指定したほうが分かりやすいサンプルになるかもしれません)

通常は複数カーネルが存在し、かつ順番も(リファクタリングなどで)変化するかもしれないため、FindKernelメソッドで適切にIDを取得する形を取ります。

カーネルIDが取得できたら、バッファ(ComputeBuffer)や計算に使う値などを適切にセットアップした上で、Dispatchメソッドを使ってコンピュートシェーダを起動します。

Dispatchメソッドの引数は全部で4つ。

第一引数は、起動するカーネルIDを指定します。
残りの3つは、前述した「スレッドグループ数」を指定するものになります。

上の例ではX,Y,Zそれぞれに1を指定しています。
こちらもスレッド数と同じ考え方なので、結果としてグループはひとつだけ指定したことになります。
そして1グループ、4スレッド、つまり合計4スレッドが一度のDispatchメソッドで実行される、というわけです。

Compute Shaderを利用した計算を行う場合、このスレッドとスレッドグループの概念は必須で覚えないとなりません。
というのも、計算が並列で行われるため、どのスレッドが今実行中なのか、その位置を把握しながらコードを書かないとならないためです。

特に、バッファなどは一次元配列の形でデータを格納するため、「どの場所に計算結果を格納するか」はスレッド番号などから判断しないとなりません。

とはいえ、最初はどういうことかすぐに理解するのはむずかしいと思います。
MSDNにスレッドグループを説明する画像があったので引用します。

f:id:edo_m18:20170502103117p:plain

スレッドIDを取り出す

上の図を見て「あーなるほどね」となったらここは読み飛ばしてもらってOKです。
最初見たときは、さっと眺めただけではよく分かりませんでした。

が、落ち着いて考えればそこまでむずかしい話ではありません。

まず、図で説明されているのは[numthreads(10,8,3)]で合計240スレッドが指定されたコンピュートシェーダに対して、Dispatch(5,3,2)で合計30グループのスレッドグループからなるスレッド群が起動するところを説明しています。(つまり合計7,200スレッド)

図の最初のテーブルは「スレッドグループ」を表しています。
(5,3,2)なので、X軸に5、Y軸に3、Z軸に2の三次元のテーブルとして表現されています。

そして次のテーブルは、(2,1,0)グループのスレッド詳細をクローズアップしています。
240スレッドが全グループそれぞれで実行されているので、ひとつのグループの詳細を見てみると、またさらに三次元のスレッド群で構成されている、という入れ子構造になっているわけですね。

下のテーブルでは今度は(7,5,0)の位置にあるスレッドについて述べています。

実は、コンピュートシェーダのカーネルへの引数は、通常のシェーダと同様、セマンティクスを用いて必要なデータをシステムから渡してもらうことができます。
そのセマンティクスが、図の下に書かれているSV_GroupThreadIDSV_GroupIDSV_DispatchThreadIDSV_GroupIndexとなります。

今着目しているスレッドが実行される際に、それぞれのセマンティクスを指定した引数にどんな値が渡ってくるか、を示しているのが図の意味なんですね。

SV_GroupThreadIDは、実行しているグループ内でのスレッドIDです。なのでそのまま(7,5,0)なわけですね。
SV_GroupIDは、スレッド群を管理しているスレッドグループのIDなので(2,1,0)となります。

そして若干厄介なのがSV_DispatchThreadIDでしょう。
これは、Dispatchと名前がついている通り、現在実行中のスレッドIDを一意に特定できる情報が格納されます。
つまり、この値を参照すれば、どのスレッドグループのどのスレッドが今実行されているのかが分かる、というわけです。

そして図が説明してくれているのはこのIDの計算方法です。
計算方法を抜粋すると以下のようになっています。

([(2,1,0) * (10,8,3)] + (7,5,0)) = (27,13,0)

つまり、カーネルの引数には(27,13,0)という値が渡ってくるというわけです。(ちなみに引数の型はint3

上の例では3次元なので若干むずかしいですが、簡単のために2 * 2次元のスレッドグループの、4 * 4スレッドを実行した例の図を作成してみました。
以下の図を見てみてください。

f:id:edo_m18:20170509160736p:plain

上の図は、左から順に、「スレッドグループ番号」「スレッドグループごとのスレッドID」「Dispatch Thread ID」の順に、どういう値が割り振られるかを示したものです。
上段にある拡大図は、SV_DispatchThreadIDがどう計算されるかのサンプルを示したものです。

最後の「SV_DispatchThreadID」を見てもらうと分かりますが、うまくX,Y軸の値が連続して並んでいますね。

1行のスレッド数は、「X軸のスレッド数 x X軸のスレッドグループ」となります。
それが、「Y軸のスレッド数 x Y軸のスレッドグループ」の数だけ繰り返されるわけです。

あれ? これを見てなにかに似ていると思わないでしょうか。

そう、テクスチャを二次元配列にしたときの添字と一致しているのが分かるかと思います。
これが、冒頭で書いた「テクスチャなどの多次元配列へのアクセスを考慮したものだ」という話につながります。

スレッド数とスレッドグループ数を多次元で表現すると、こうしたデータへのアクセスがとても容易になるのが分かってもらえたかと思います。

詳細は次回にまわしますが、テクスチャへアクセスするためのコード断片をお見せしましょう。

#pragma kernel CSMain

RWTexture2D<float4> texCopy;
Texture2D<float4> tex;

[numthreads(8,8,1)]
void CSMain(uint2 id : SV_DispatchThreadID)
{
    float4 t = tex[id];
    texCopy[id] = t;
}

今は細かいところは置いておいて、カーネル関数の中身だけに着目してください。
テクスチャへの添字アクセスで、SV_DispatchThreadIDをそのまま使っていることに気づいたでしょうか。

前述のように、SV_DispatchThreadIDは、うまく二次元配列の添字として機能してくれるので、値を加工することなくそのまま使えている、というわけですね。

今回は、ComputeShaderを使う上で大事な「スレッドの概念」についてまとめました。
ComputeShaderを使ってテクスチャを利用して様々な計算が行えるので、次はテクスチャとバッファを利用した計算について書きたいと思います。

*1:カーネルは「核」を意味するので、計算の核となる関数のこと