忍者ブログ

Memeplexes

プログラミング、3DCGとその他いろいろについて

C#でGPGPUチュートリアル その3 演算シェーダー

ベクトルを2倍にする

 前回と前々回は、
GPUにあるデータをCPUに
持ってくるというのがテーマでした。

GPGPUのチュートリアルでありながら、
やっているのはデータの移動だけで
GPUに計算をさせていません。

今回こそ計算させてみましょう。

今回行う計算は、あるバッファの中身を2倍にするというものにします。

doubleTheContentsOfGpuBuffer.jpg

各要素にそれぞれ、同じ計算(2倍する)をするので、
並列処理っぽい、CPUよりもGPUが得意とする計算なはずです。


演算シェーダー

GPGPUな計算に使うシェーダーを
演算シェーダーといいます。
演算シェーダーはDirectX11で初めて導入された機能です。

SlimDXで演算シェーダーを使うには、
DeviceContextクラスのComputeShaderプロパティを通すことになります。
public ComputeShaderWrapper ComputeShader { get; }
SlimDX.Direct3D11.ComputeShaderWrapperクラスは
演算シェーダーの機能をまとめたクラスです。
演算シェーダーの実行に必要な設定用メソッドが用意されています。
(ただし、演算シェーダーの実行そのものにはこのクラスは関与しません。
実行にはDeviceContextクラスのメソッドを使います。後述)


計算結果を格納するバッファ

今回演算シェーダーの計算結果を格納するバッファを指定する方法は、
ComputeShaderWrapper.SetUnorderedAccessView()メソッドを使うというものです。
public void SetUnorderedAccessView(UnorderedAccessView unorderedAccessView, int slot);
unorderedAccessViewは演算シェーダーにセットするUnorderedAccessViewです。
slotはデバイスに存在するUnorderedAccessView配列のインデックスです。つまり配列のどこにセットするのかということですね。

バッファを指定するメソッドと言っておきながらこれの引数は
(バッファではなく)「なんとかかんとかView」です。
どういう事かというと、この「なんとかかんとかView」は
バッファをコンストラクタ引数に取るクラスで、
バッファの使われ方のようなものを表すのです。
バッファはデータの塊、Viewは機能の塊といったところでしょうか。

しかしなぜViewとバッファを別物として考えるのでしょうか?
DirectX9世代では少なくともViewというようなものはなかったはずです。
バッファに相当するものだけで何とかちゃんとやりくりできていたのです。
その疑問の答えは、
「現在のDirectXではリソースは一般的な形で存在し、色々な使い方ができるため、
その都度Viewで使い方を指定してやる必要がある」、
ということだと思います。(鵜呑みにしないでください)

たとえば、float2をテクスチャ座標としてSample()で色を獲得する一般的なテクスチャ
(DirectX9でピクセルシェーダー内で使っていたようなテクスチャ、Texture2Dです)
より厳密にuint2をインデックスとして色を獲得できるテクスチャ
(演算シェーダーの登場によってこういうテクスチャが現れました。RWTexture2Dです)
を考えてみましょう。
ある一つのテクスチャをある局面ではfloat2のテクスチャ、
別の局面ではuint2のテクスチャとして使いたくなった場合、
バッファとViewが同じものだった場合インターフェースがやや面倒なことになるかもしれません。
クラスの凝集度が下がりそうです。

一方、BufferとViewを別のものにしていた場合、
1つのバッファに複数のViewを割り当てる」ことで上記のような場合も柔軟に対応できるでしょう。
そういったわけで、データそのものを意味するBufferと、
そのデータに対する操作を表すViewに分離しているようです。
(概念的にはobjectを様々なインターフェースにキャストすることに似ているかもしれません。
こっちはあまり褒められたものではありませんが)

なお、3DCGのカメラのViewとはなんの関係もありません。

ですから、「なんとかかんとかViewを作る」と言った場合それが意味するのは
「このバッファはなんとかかんとかな役割を果たす」ということです。
ここではSlimDX.Direct3D11.UnorderedAccessViewですが、これは
「このバッファは一度に複数のスレッドからのランダムアクセスな読み書きが出来る」という意味です。
public class UnorderedAccessView : ResourceView

なぜバッファをUnorderedAccessViewとして扱う必要があるのでしょう?
それは今回のようなベクトルの各要素にアクセスするような並列計算をする場合、
配列の"[]"演算子を使うのが手っ取り早いからです。
UnorderedAccessViewでないということは、
C#で喩えるならT[]ではなくIEnumerable<T>を使うようなものです
(ただしUnorderedAccessViewの場合は書き込みの話です。
読み込みはUnorderedAccessViewでなくてもランダムアクセスっぽく[]でアクセスできるようです)。
IEnumerable<T>はそのままでは並列的な計算に向きませんよね。
Parallel.Forの中でアクセスするのはIEnumerable<T>の要素ではなくT[]の要素にしたいものです。

別の言い方をするなら、ピクセルシェーダーのように書込み先の位置が決まっているのではなく、
[]演算子で好きな位置に書き込めるということです。
ピクセルシェーダーでは計算した結果を格納する先は決まっていて、
プログラマが出来ることといったら書きこむデータを計算することだけでした。
UnorderedAccessViewなら計算した結果を自由な位置に書き込めます。

コンストラクタは4つ用意されていますが
今回使うのは一番簡単なこのコンストラクタです。
public UnorderedAccessView(Device device, Resource resource);
deviceはこのViewを持つデバイスです。
resourceはこのビューがその役割を指定するリソース(この場合はバッファ)です。

なお、~ViewクラスはSlimDXに4つ存在するようです。
~Viewクラス 説明
DepthStencilView 深度ステンシルテストでテクスチャリソースとして使います。
RenderTargetView レンダーターゲットなテクスチャーリソースとして使います。画面に映る描画先のことですね。
ShaderResourceView シェーダーリソースとして使います。たとえば定数バッファ、テクスチャバッファ、テクスチャ、サンプラーなどです。演算シェーダーでも計算に必要なデータを格納したバッファをこれに指定することがあります。
UnorderedAccessView ピクセルシェーダーか演算シェーダーで、複数のスレッドからのランダムアクセスっぽい読み書きを受け付ける、ということを意味します。


さて、生成したバッファをUnorderedAccessとして使う場合、
Bufferのコンストラクタで指定しなければならないオプションがあります。
コンストラクタに入れるBufferDescriptionのBindFlagsプロパティを、
以下のようにBindFlags.UnorderedAccessにしなくてはいけないのです。

BindFlags = BindFlags.UnorderedAccess

これは、生成するバッファを引数にUnorderedAccessViewを生成できるということです。

ちなみに、BindFlagsには他にも以下のようなオプションがあります。
BindFlagsというのはバッファの使われ方を意味する、
SlimDX.Direct3D11.BindFlags型のプロパティです。

BindFlagsの値 数値 説明
None 0 特に何も指定しません。
VertexBuffer 1 生成されるリソースが、Input Assemblerステージで、頂点バッファとして使えることを意味します。
IndexBuffer 2 生成されるリソースが、Input Assemblerステージで、インデックスバッファとして使えることを意味します。
ConstantBuffer 4 生成されるリソースが、シェーダーステージで、定数バッファとして使えることを意味します。
ShaderResource 8 生成されるリソースが、シェーダーステージで、バッファやテクスチャとして使えるとうことを意味します。
StreamOutput 16 生成されるリソースが、Stream Outputステージで、出力バッファとして使用できるということを意味します。Stream Outputステージとは頂点シェーダーとピクセルシェーダーステージの間に位置するステージの一つです。どうやらジオメトリーシェーダーとセットになっているらしく、新たな頂点データをメモリに書き出すことができます。
RenderTarget 32 生成されるリソースが、Output Mergerステージで、レンダーターゲットとして使えることを意味します。つまり普通のDirectXアプリケーションで画面に表示される画像のことですね。
DepthStencil 64 生成されるリソースが、Output Mergerステージで、深度ステンシルとして使えることを意味します。
UnorderedAccess 128 生成されるリソースが、ランダムアクセス風に、複数のスレッドから同時に読み書きできることを意味します。

数値が0,1,2,4…となっていることからわかるように、
これは複数のフラグを"|"で一緒に指定することができます。


演算シェーダーのセット

演算シェーダーで行う具体的な計算は、
C#ではなくHLSLのファイルに書きます。

C#側で行うのは、HLSLファイルをロードしてコンパイルしてセットすることです。

HLSLファイルをロードしてコンパイルするには、
ShaderBytecode.CompileFromFile()メソッドを使います。
このメソッドには6種類オーバーロードがあるのですが、
今回使うのは2番目に簡単なこのメソッドです。
(といっても引数が結構多いです。
思うにこれはC#の名前付き引数機能を使えば簡単になりそうですね)

public static ShaderBytecode CompileFromFile(
    string fileName, 
    string entryPoint, 
    string profile, 
    ShaderFlags shaderFlags, 
    EffectFlags effectFlags
    );
これはDirectX SDK付属のHLSLコンパイルツール、fxc.exeに似ています。

fileNameはシェーダー(今回の場合は演算シェーダー)を記述したHLSLファイルの名前。
entryPointはHLSLファイルの中の、シェーダーとして使う関数の名前。
profileはシェーダーのターゲットです。演算シェーダーなら"cs_4_0"とか"cs_5_0"などというふうな文字列です。頂点シェーダーなら"vs_5_0"、ピクセルシェーダーなら"ps_5_0"という感じになるでしょう。
shaderFlagsはシェーダーコンパイルオプションです。今回はとくにどうでもいいのでNoneを選びます。
effectFlagsはエフェクトコンパイルオプションです。今回は特にどうでもいいのでNoneを選びます。

こうしてコンパイルされると、戻り値としてShaderBytecodeが帰ってきます。
このShaderBytecodeを使って
SlimDX.Direct3D11.ComputeShaderクラスのインスタンスを生成します。

public class ComputeShader : DeviceChild

public ComputeShader(Device device, ShaderBytecode shaderBytecode);

deviceは演算シェーダーを作るデバイス。
shaderBytecodeは演算シェーダーのバイトコードです。

このコンストラクタを使ってできたComputeShaderを
実行出来るようセットする必要があります。
それにはComputeShaderWrapper.Set()メソッドを使います。

public void Set(ComputeShader shader);
shaderはデバイスにセットする演算シェーダーです。

演算シェーダーの実行

演算シェーダーに関するセッティングが全て終わったら、
いよいよ演算シェーダーを実行できます。
実行にはComputeShaderWrapper.Dispatch()メソッドを使います。

public void Dispatch(
    int threadGroupCountX, 
    int threadGroupCountY, 
    int threadGroupCountZ
    );

さてここには3つの引数がありますがなんでしょうか?
それを説明するにはまず演算シェーダーのスレッドの簡単な説明が必要です。

演算シェーダーは多数のスレッドを同時に実行できます。

gpgpuThreads3D.png

そしてそれらのスレッドはグループ分けされているのです。

gpgpuThreadsGrouped.png
Dispatch()メソッドの引数はこのグループの数を指定するのです。
(全スレッドの数ではありません。念のため)


numthreads属性 (HLSL側)

では1グループあたりのスレッドの数はどうやって指定するのでしょうか?
それにはHLSL側のnumthreads属性を使います。

numthreads(X, Y, Z)

X, Y, Zは1グループあたりのスレッドサイズ(?いい表現が思いつきませんでした)です。
1グループあたりのスレッドの数はX * Y * Z個になります。
これは全スレッドの数を指定するのではないことに注意してください。
あくまでも1グループにいくつスレッドがあるかを指定するものです。
全スレッドの数はC#側のDispatchの引数とこのX, Y, Zをかけ合わせたもので決まります。

なお、X, Y, Zには範囲に制限があります。
MSDNの表をそのまま持ってきます:

演算シェーダーバージョン Zの最大値 スレッドの最大個数(X * Y * Z)
cs_4_x 1 768
cs_5_0 64 1024


SV_DispatchThreadID (HLSL側)

さて、このように演算シェーダーでは
多数のスレッドで計算することができます。
こういうことをしていると、
「今演算シェーダーの関数がどのスレッドで実行されているのか」
ということが知りたくなることが良くあります
たとえば今回行う「ベクトルのすべての要素を2倍にする」というようなものだと、
要素一つ一つにそれぞれスレッドを1つ割り当てることになりますが、
どの要素を操作するのかというキーとしてスレッドのIDを知りたくなるのです。

それにはSV_DispatchThreadIDセマンティックを使います。
こいつを演算シェーダーのuint3型の引数に付けてやると、それに
現在実行しているスレッドのIDが入っているのです。


RWStructuredBuffer<T> (HLSL側)

今回HLSLでの計算結果の読み書きはHLSLの
RWStructureBuffer<T>の変数に対して行います。
この型は、読み書きが出来る上(RWとはRead/Writeのことです)、
C#のジェネリクスのついたコレクションのように、
Tの中身を色々な構造体に指定することができます。
例えば今回の場合はRWStructureBuffer<int>というような具合です。

この型には(C#noジェネリクスのついたコレクションのように)
[]演算子も付いています。
T Operator[]( in uint indexPosition );

これは、引数にインデックスをとって、その位置の要素を返します。


コード

今回のコードは2つのファイルからなります。
C#のコードと、HLSLのコードです。

HLSLのコードは
Visual C#のプロパティ「Copy to Output Directory」を
「Copy if newer」にしましょう。

Program.cs
using System.Collections.Generic;
using System.Linq;

using SlimDX;
using SlimDX.Direct3D11;
using SlimDX.D3DCompiler;

class Program
{
    static void Main(string[] args)
    {
        Device device = new Device(DriverType.Hardware);
        Buffer onlyGpuBuffer = createStructuredBuffer(device, Enumerable.Range(0, 10).ToArray());

        initComputeShader(device, onlyGpuBuffer);
        device.ImmediateContext.Dispatch(10, 1, 1);
        writeBuffer(device, onlyGpuBuffer);
    }

    static void initComputeShader(Device device, Buffer onlyGpuBuffer)
    {
        device.ImmediateContext.ComputeShader.SetUnorderedAccessView(new UnorderedAccessView(device, onlyGpuBuffer), 0);

        ShaderBytecode shaderBytecode = ShaderBytecode.CompileFromFile(
            "MyShader.fx",
            "MyComputeShader",
            "cs_4_0",
            ShaderFlags.None,
            EffectFlags.None
            );
        device.ImmediateContext.ComputeShader.Set(
            new ComputeShader(device, shaderBytecode)
            );
    }

    static void writeBuffer(Device device, Buffer buffer)
    {
        Buffer cpuAccessibleBuffer = createCpuAccessibleBuffer(device, Enumerable.Range(0, 10).ToArray());
        device.ImmediateContext.CopyResource(buffer, cpuAccessibleBuffer);
        int[] readBack = readBackFromGpu(cpuAccessibleBuffer);

        foreach (var number in readBack)
        {
            System.Console.WriteLine(number);
        }
    }

    static Buffer createStructuredBuffer(Device device, int[] initialData)
    {
        DataStream initialDataStream = new DataStream(initialData, true, true);
        return new Buffer(
            device,
            initialDataStream,
            new BufferDescription
            {
                SizeInBytes = (int)initialDataStream.Length,
                BindFlags = BindFlags.UnorderedAccess,
                OptionFlags = ResourceOptionFlags.StructuredBuffer,
                StructureByteStride = sizeof(int)
            }
            );
    }

    static Buffer createCpuAccessibleBuffer(Device device, int[] initialData)
    {
        DataStream initialDataStream = new DataStream(initialData, true, true);
        return new Buffer(
            device,
            initialDataStream,
            new BufferDescription
            {
                SizeInBytes = (int)initialDataStream.Length,
                CpuAccessFlags = CpuAccessFlags.Read,
                Usage = ResourceUsage.Staging
            }
            );
    }

    static int[] readBackFromGpu(Buffer from)
    {
        DataBox data = from.Device.ImmediateContext.MapSubresource(
            from,
            MapMode.Read,
            MapFlags.None
            );
        return getArrayInt32(data.Data);
    }

    static int[] getArrayInt32(DataStream stream)
    {
        int[] buffer = new int[stream.Length / sizeof(int)];
        stream.ReadRange(buffer, 0, buffer.Length);
        return buffer;
    }
}


MyShader.fx
RWStructuredBuffer<int> MyBuffer;

[numthreads(1, 1, 1)]
void MyComputeShader( uint3 threadID : SV_DispatchThreadID )
{
    MyBuffer[threadID.x] *= 2;
}



実行結果
0
2
4
6
8
10
12
14
16
18

2倍になりました!

このプログラムがしていることは
GPU中に「0, 1, 2, 3, 4, 5, 6, 7, 8, 9」というStructuredBufferをつくり、
演算シェーダーでそれぞれ2倍し「0, 2, 4, 6, 8, 10, 12, 14, 16, 18」にした、ということです。


10個のスレッドで、演算シェーダーの関数
MyComputeShader()を実行しているのです。
(10個のスレッドグループを作り、
1つのスレッドグループに1つのスレッドがあるので全部で合計10個のスレッドです)

MyComputeShaderの引数threadIDの中には、
(0, 0, 0)
(1, 0, 0)
(2, 0, 0)
(3, 0, 0)
(4, 0, 0)
(5, 0, 0)
(6, 0, 0)
(7, 0, 0)
(8, 0, 0)
(9, 0, 0)
のいずれかが入っているはずです。

そしてのXの値をインデックスにして、
バッファにアクセスし数を二倍にしています。
つまりバッファの中の全てのintは2倍されるのです。


拍手[1回]

PR