仮眠プログラマーのつぶやき

自分がプログラムやっていて、思いついたことをつぶやいていきます。

自作ゲームやツール、ソースなどを公開しております。
①ポンコツ自動車シュライシュラー
DOWNLOAD
②流体力学ソース付き
汚いほうDOWNLOAD
綺麗なほうDOWNLOAD
③ミニスタヲズ
DOWNLOAD
④地下鉄でGO
DOWNLOAD
⑤ババドン
DOWNLOAD
⑥圧縮拳(ツール)
DOWNLOAD
⑦複写拳
DOWNLOAD
⑧布シミュレーション
DOWNLOAD
⑨minecraft巨大電卓地形データ
DOWNLOAD
⑩フリュードランダー
デジゲー博頒布α版
DOWNLOAD
⑪パズドラルート解析GPGPU版
DOWNLOAD
⑫ゲーム「流体de月面着陸」
DOWNLOAD

Unity

UnityでGPGPUその5 100万個のドットをDrawProceduralNowでGPU描画

ローレンツアトラクタを描画してみる

UnityでGPGPUシリーズはその1~その4まで数値計算と描画を切り分けて、数値計算に特化した内容で記事をまとめてきた。ただやはり可視化してみないと何を計算しているのかわからないことも多いので、ここでは最低限の可視化について触れようと思う。
タイトルにあるようDrawProceduralNowを使えば結構楽にGPUインスタンシング(であってる?)ができるのでまとめてみた。

可視化の題材はローレンツアトラクタ
詳しくはググってもらうとして、これはある計算をすることで見た目が3Dのこんな感じの絵がでるようになる。

lorenz

適当なx,y,z座標の初期値を与えると、あとは規則に沿ってこういう軌道を描き、なんかちょっと面白い絵ができる。

これは普通CPUで計算するような題材だが、計算も簡単なのでGPU上で座標計算を行い、可視化までGPU内で完結させてしまおうと思う。ComputeBufferにx,y,z座標を格納し、その座標に1ピクセルのドットを描画する、というのをまずは目指す。

実装

今回必要なコードはたった3つ
・C#コード
・ComputeShaderコード(座標計算用)
・SurfaceShader.shader(可視化用コード)

でこれに加えて
・空のGameObject


まず空のGameObjectを作成し
Assetsフォルダ内にScriptsフォルダを作成
・右クリック→Create→C# script→「Main」
Assetsフォルダ内にShaderフォルダを作成
・右クリック→Create→Shader→Compute Shader→「ComputeShader」
・右クリック→Create→Shader→Standard Surface Shader→「SurfaceShader」

以下のソースをコピーして
unitygamen
こんな感じにできれば完成。


Main.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Main : MonoBehaviour
{
    //計算するコンピュートシェーダー
    [SerializeField]
    ComputeShader cshader;

    // ドットをレンダリングするシェーダー
    [SerializeField]
    Shader renderingShader;
    Material renderingShader_Material;

    ComputeBuffer posBuffer;

    //[SerializeField]
    int particleNum = 1024 * 1024;

    int kernel_Lorenz;
    int kernel_BufInit;

    void Start()
    {
        renderingShader_Material = new Material(renderingShader);

        posBuffer = new ComputeBuffer(particleNum, sizeof(double) * 3);//x,y,z成分で3つ
        kernel_Lorenz = cshader.FindKernel("Lorenz");
        kernel_BufInit = cshader.FindKernel("BufInit");
        cshader.SetBuffer(kernel_Lorenz, "posBuffer", posBuffer);
        cshader.SetBuffer(kernel_BufInit, "posBuffer", posBuffer);
        
        //初期化もGPUで
        cshader.Dispatch(kernel_BufInit, particleNum / 64, 1, 1);

        // GPUバッファをマテリアルに設定
        renderingShader_Material.SetBuffer("posBuffer", posBuffer);
    }

    // Update is called once per frame
    void Update()
    {
        cshader.Dispatch(kernel_Lorenz, particleNum / 64, 1, 1);
    }

    void OnRenderObject()
    {
        // レンダリングを開始
        renderingShader_Material.SetPass(0);
        // n個のオブジェクトをレンダリング
        Graphics.DrawProceduralNow(MeshTopology.Points, particleNum);
    }

    private void OnDestroy()
    {
        //解放
        posBuffer.Release();
    }
}


ComputeShader.compute
#pragma kernel Lorenz
#pragma kernel BufInit

//OpenCLに移植するときはfloat3,double3が使えないので注意
RWStructuredBuffer<double3> posBuffer;
#define DELTA_TIME (0.0001)
#define LOOPNUM (128)


//簡易ランダム
uint wang_hash(uint seed)
{
	seed = (seed ^ 61) ^ (seed >> 16);
	seed *= 9;
	seed = seed ^ (seed >> 4);
	seed *= 0x27d4eb2d;
	seed = seed ^ (seed >> 15);
	return seed;
}

//ランダム位置に初期値を置く
[numthreads(64, 1, 1)]
void BufInit(uint id : SV_DispatchThreadID)
{
	double3 pos;
	uint r = wang_hash(id * 1847483629);
	pos.x = 0.005 * (r % 1024) - 1.5;
	r /= 1024;
	pos.y = 0.005 * (r % 1024) - 3.5;
	r /= 1024;
	pos.z = 0.005 * (r % 1024) + 23.0;
	posBuffer[id] = pos;
}

[numthreads(64,1,1)]
void Lorenz(uint id : SV_DispatchThreadID)
{
	double3 pos = posBuffer[id];
	double3 pos_after;
	for (int i = 0; i < LOOPNUM; i++) 
	{
		pos_after.x = pos.x + DELTA_TIME * (-10.0 * pos.x + 10.0 * pos.y);
		pos_after.y = pos.y + DELTA_TIME * (-pos.x * pos.z + 28.0 * pos.x - pos.y);
		pos_after.z = pos.z + DELTA_TIME * (pos.x * pos.y - 8.0 * pos.z / 3.0);
		pos = pos_after;
	}

	posBuffer[id] = pos_after;
}


SurfaceShader.shader
Shader "Custom/SurfaceShader" {
	SubShader{
		ZWrite Off
		Blend One One//加算合成

		Pass {
			CGPROGRAM
			// シェーダーモデルは5.0を指定
			#pragma target 5.0

			#pragma vertex vert
			//#pragma geometry geom
			#pragma fragment frag
			#include "UnityCG.cginc"
	
	StructuredBuffer<double3> posBuffer;

	// 頂点シェーダからの出力
	struct VSOut {
		float4 pos : SV_POSITION;
	};

	// 頂点シェーダ
	VSOut vert(uint id : SV_VertexID)
	{
		// idを元に、ドットの情報を取得
VSOut output; output.pos = mul(UNITY_MATRIX_VP, float4(float3(posBuffer[id]), 1)); return output; } // ピクセルシェーダー fixed4 frag(VSOut i) : COLOR { return float4(0.0154,0.05,0.3,1); } ENDCG } } }

参考
[Unity]コンピュートシェーダ(GPGPU)で1万個のパーティクルを動かす

結果

実行するとこんな感じにきれいな絵が出来上がる。
Webp.net-gifmaker

このgif絵はCameraをわざわざ回転させてるので実際の実行画面とはちょっと違うが。
Camera回転版はGithub


解説

今回の新しい点はSurfaceShader.shaderでComputeBufferを受け取っているというところ。その紐づけはC#側で行う。
renderingShader_Material = new Material(renderingShader);
...中略...
// GPUバッファをマテリアルに設定
renderingShader_Material.SetBuffer("posBuffer", posBuffer);
次にSurfaceShader.shader側の頂点シェーダーをみると
	StructuredBuffer<double3> posBuffer;

	// 頂点シェーダ
	VSOut vert(uint id : SV_VertexID)
	{
		// idを元に、ドットの情報を取得
VSOut output; output.pos = mul(UNITY_MATRIX_VP, float4(float3(posBuffer[id]), 1)); return output; }

posBufferはdouble型なのでfloatにキャストしている。生の座標値だとまだ描画に使えないのでUNITY_MATRIX_VPでうまい具合に描画用の座標に変換している(正直よくわかってない・・・)。
ここでidには例によって0から始まる通し番号が入っている。これを使って各ドットの座標が格納されているposBufferにアクセスしている。

そして
C#側
    void OnRenderObject()
    {
        // レンダリングを開始
        renderingShader_Material.SetPass(0);
        // n個のオブジェクトをレンダリング
        Graphics.DrawProceduralNow(MeshTopology.Points, particleNum);
    }
ここのparticleNumで指定している数だけドットが表示されるというわけだ。

MeshTopology.Lines

lorenzline

MeshTopology.PointsをMeshTopology.Linesとかに変えてみると、こんな感じに違った雰囲気の絵が作れるのでいろいろ遊んでみると面白い。他にもQuadsなどもあり本格的なポリゴン描画にも使えそうである。

補足:GPUで擬似乱数の生成

実は今回、パーティクル座標の初期値をGPUで生成している。しかし大量の初期座標を重複なく作るのは意外と大変。Unity C#ではRandom.Random()などあるが、Compute Shaderではもちろん使えない。
そこでスレッドidから擬似ランダムな値を生成するコードを書いている。
//簡易ランダム
uint wang_hash(uint seed)
{
	seed = (seed ^ 61) ^ (seed >> 16);
	seed *= 9;
	seed = seed ^ (seed >> 4);
	seed *= 0x27d4eb2d;
	seed = seed ^ (seed >> 15);
	return seed;
}

//ランダム位置に初期値を置く
[numthreads(64, 1, 1)]
void BufInit(uint id : SV_DispatchThreadID)
{
	double3 pos;
	uint r = wang_hash(id * 1847483629);
	pos.x = 0.005 * (r % 1024) - 1.5;
	r /= 1024;
	pos.y = 0.005 * (r % 1024) - 3.5;
	r /= 1024;
	pos.z = 0.005 * (r % 1024) + 23.0;
	posBuffer[id] = pos;
}

この部分。まずスレッドidに1847483629という適当な数字をかけ、それをwang_hashという関数にかけている。たったこれだけ。
参考にしたのはQuick And Easy GPU Random Numbers In D3D11

この記事にもあるようwang_hashは乱数生成としては簡便な割には優秀らしい。実際TESTU01という乱数検定ソフトでも、「スレッドidに1847483629をかけてwang_hashに入れる」方式は最低限のテストをパスできた。



wikipediaから抜粋すると
『TESTU01には(10個のテストから成る)"Small Crush", (96個のテストからなる)"Crush"そして(106個のテストよりなる)"Big Crush"など数種のテスト・スイートが含まれる。』


もちろんXOR SHIFTにはかなわないが、XOR SHIFTは愚直に実装するとまったくの逐次計算になるのでGPUにはあまり向かない。一応、調べてみるとGPUに実装している人もちらほらいるのでできなくはないようだが、関数一つ書けば終わる程度のものとは比較にならないほど大変そうだ。


※本ブログで紹介しているソースコードはすべてCC0 パブリックドメインです。
ご自由にお使いください



UnityでGPGPUその4 ComputeShaderでAtomic演算

ComputeShaderでAtomic演算

GPUプログラミング(CUDAとかOpenCL)で、GPU上にある配列の総和を求めたいということは良くある。
前回shared memoryを使った配列の総和を計算したがこれは良い面も悪い面もある。良い面としては高速なこと、悪い面はややプログラムが煩雑になること。1グループあたり32KBまでしかshared memoryを共有できないため1024要素程度までなら良いが1000万要素もの配列となると入りきらない。

多段式にshared memoryを使ったReductionのコードを組めばそれが最速なのだろうがかなり面倒だ。

そこで、やや速度では劣るが簡便な方法としてAtomic演算を使う。Atomic演算を使うと、変数(アドレス)に対する同時アクセスをスレッド間で排他制御できる。

CUDAでもOpenCLでもAtomic演算はできるし、裏を返せばハードウェア的に、NVIDIA、AMDのGPUでもAtomic演算ができる機能は備わっているはず。どうせ使える機能ならUnityでも使いたい。

実装

例のごとく空のオブジェクトとHost.csとComputeShader.computeを生成して
C#(Host.cs)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public class Host : MonoBehaviour
{
    public ComputeShader shader;
    void Start()
    {
        uint N = 1024;//256の倍数で
        uint[] host_B = { 0 };//初期値
        ComputeBuffer AtomicBUF = new ComputeBuffer(host_B.Length, sizeof(uint));
        int k = shader.FindKernel("sharedmem_samp1");
        AtomicBUF.SetData(host_B);

        //時間測定開始
        int time = Gettime();

        //引数をセット
        shader.SetBuffer(k, "atmicBUF", AtomicBUF);

        //初回カーネル起動
        Debug.Log("初回カーネル起動前" + (Gettime() - time));
        shader.Dispatch(k, (int)N/256, 1, 1);
        Debug.Log("初回カーネルDispatch後  " + (Gettime() - time));
        AtomicBUF.GetData(host_B);
        Debug.Log("初回カーネルGetData直後  " + (Gettime() - time));

        // こっちが本命。GPUで計算
        host_B[0] = 0;
        AtomicBUF.SetData(host_B);
        shader.Dispatch(k, (int)N/256, 1, 1);
        Debug.Log("本命カーネルDispatch直後 " + (Gettime() - time));

        // device to host
        AtomicBUF.GetData(host_B);

        //結果
        Debug.Log("本命カーネル確定終了    " + (Gettime() - time));
        Debug.Log("\nAtomic_Addの結果   "+host_B[0]);

        //解放
        AtomicBUF.Release();
    }

    // Update is called once per frame
    void Update()
    {
    }

    //現在の時刻をms単位で取得
    int Gettime()
    {
        return DateTime.Now.Millisecond + DateTime.Now.Second * 1000
         + DateTime.Now.Minute * 60 * 1000 + DateTime.Now.Hour * 60 * 60 * 1000;
    }
}

ComputeShader(ComputeShader.compute)
#pragma kernel sharedmem_samp1
RWStructuredBuffer<uint> atmicBUF;//1要素

[numthreads(256, 1, 1)]
void sharedmem_samp1(uint id : SV_DispatchThreadID) {
	InterlockedAdd(atmicBUF[0], id);
}

atomicadd

https://github.com/toropippi/Uinty_GPGPU_SharedMem_AtomicAdd/tree/master/gpgpu_sample4

0~N-1までの総和を求めることができた。
(計算時間を測定するのがくせなので初回カーネルとか書いてあるが今回はあまり重要ではない)

計算速度について

基本的にはAtomic演算はShared memoryを使った方式よりかなり遅いと思っていたほうが良い。グローバルメモリに対してGPU全コアから排他制御でアクセスしているのでランダムアクセスみたいな遅さになる。
ハイブリッドな方法として、shared memoryで1グループ内で総和を求めた後、その値をAtomic演算で総和していくのがプログラムを書く上でも簡単で良いかなと私は考えている。

他の演算

今回のは32bit整数型のAtomic演算のaddを使ったサンプルになる。ほかにも引き算やビット演算などAtomic演算はいくつもある。以下を参照のこと

InterlockedXor

なお、この中に32bit浮動小数点数に対するAtomic演算は含まれておらず、いわゆるFloat型の総和を求めたいときはAtomic演算でなくShared memoryを使うしかない。なぜかはわからないが、CUDAのみFloat型に対してもAtomic演算を行なう機能を持っており、Compute ShaderやOpenCLにはその機能がない。(非正規化数周りとかを異なるGPUベンダー間で統一できないからか?)
「OpenCL float atomic add」などで調べるとCompareExchange演算を使って無理やりfloatの加算を実現してるコードなども出てくるが、やや効率は落ちるようだ。


※本ブログで紹介しているソースコードはすべてCC0 パブリックドメインです。
ご自由にお使いください



UnityでGPGPUその3 ComputeShaderの共有メモリ(shared memory)

group shared memoryを使う

今回は256要素の配列の総和を求める計算をする。リダクション(Reduction)ともいう。
こちらがその簡素なコード
C#(Host.cs)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Host : MonoBehaviour
{
    public ComputeShader shader;
    void Start()
    {
        float[] host_A = new float[256];
        for(int i = 0; i < 256; i++)
        {
            host_A[i] = 1f * i;
        }
        float[] host_B = { 0f };//この数字はなんでもいい。Bは結果がはいる側

        ComputeBuffer A = new ComputeBuffer(host_A.Length, sizeof(float));
        ComputeBuffer B = new ComputeBuffer(host_B.Length, sizeof(float));

        int k = shader.FindKernel("sharedmem_samp0");

        // host to device
        A.SetData(host_A);

        //引数をセット
        shader.SetBuffer(k, "A", A);
        shader.SetBuffer(k, "B", B);

        // GPUで計算
        shader.Dispatch(k, 1, 1, 1);//ここでは1*1*1並列を指定。ComputeShader側で256並列を指定している

        // device to host
        B.GetData(host_B);
        Debug.Log(host_B[0]);

        //解放
        A.Release();
        B.Release();
    }

    // Update is called once per frame
    void Update()
    {
    }
}

ComputeShader

#pragma kernel sharedmem_samp0
RWStructuredBuffer<float> A;//256要素
RWStructuredBuffer<float> B;//1要素

groupshared float block[256];
[numthreads(256, 1, 1)]
void sharedmem_samp0(int id : SV_DispatchThreadID, int grid : SV_GroupID, int gi : SV_GroupIndex) {
	float a = A[id];
	block[gi] = a;
	//共有メモリに書き込まれた
	GroupMemoryBarrierWithGroupSync();
	for (int i = 128; i > 0; i /= 2) {
		if (gi < i) {//128,64,32,16,8,4,2,1
			block[gi] += block[gi + i];
		}
		GroupMemoryBarrierWithGroupSync();//ここは256スレッドすべてで実行
	}

	//あとは0番目のデータを抽出するだけ
	if (gi==0)
		B[0] = block[0];
} 
https://github.com/toropippi/Uinty_GPGPU_SharedMem_AtomicAdd/tree/master/gpgpu_sample3

前の記事のように
Assetsフォルダ内にScriptsフォルダを作成して以下の2つを作成
・右クリック→Create→C# script→「Host」
・右クリック→Create→Shader→Compute Shader→「ComputeShader」
空のobjectにC#をアタッチし、そのオブジェクトの「shader」に「ComputeShader」をアタッチ
そして実行

結果

samp3結果0

解説するとまずC#側でhost_Aという配列変数に0,1,2,3,4・・・255の数値を格納しGPUに転送。GPU側で全要素を並列に足し算しC#側に結果を戻し表示している、という感じ。
検算してみると
0,1,2,3・・・なのでN*(N-1)/2なので256*255/2=32640。
あってる。

解説

今回初見の命令は全部ComputeShader側コードで
・groupshared float block[256];
・int grid : SV_GroupID, int gi : SV_GroupIndex
・GroupMemoryBarrierWithGroupSync();

の3つ。

groupshared float block[256];

これはカーネルで共有メモリを使いたいときに宣言する。float型×256要素を確保している。共有メモリとはうまく使うことでいろいろ高速化できる優れもの。で以下の特徴がある
・グループ内(この場合256スレッド)でしか内容が共有されない
・他グループからはアクセス不能
・そのカーネル実行中にのみアクセスできる
・カーネル終了後はアクセスできない
・カーネル終了後は自動で消えるので解放処理はいらない
・実体はL1キャッシュに存在する
・1グループで使える最大サイズは32KB(DirectX 11の場合)
・L1なのでグローバルメモリよりはるか高速に(10倍-100倍)アクセスできる

int grid : SV_GroupID, int gi : SV_GroupIndex

次にこれ。
void sharedmem_samp0(int id : SV_DispatchThreadID, int grid : SV_GroupID, int gi : SV_GroupIndex) {
この行の後ろ2つのやつだ。
SV_DispatchThreadIDは前もでてきたようにスレッドidであり、上のコードにならってみるとカーネルが256並列なので0~255の値が各スレッドで割り当てられている

SV_GroupIDはグループidで、カーネル256並列を1グループ*256スレッドという形で実行しているのでグループは一つしかないため、このグループidには0が割り当てられることになる。
この場合の1と256は
C#の
shader.Dispatch(k, 1, 1, 1)
とComputeShaderの
[numthreads(256, 1, 1)]
に対応している。
C#ではグループ数の指定、ComputeShaderカーネルでは1グループあたりのスレッド数の指定をしていたというわけだ。
SV_GroupIndexはグループ内のスレッドindexで0~255の値が割り当てられることになる。

GroupMemoryBarrierWithGroupSync();

最後にこれ。
これはグループ内で全スレッドの同期をとる命令。GPUはいくら並列演算器とはいえ、全部のコアが全く同じプログラムの行を実行しているわけではない。256並列なら0番目スレッドと255番目のスレッドではタイミングがズレているかもしれない。メモリにアクセスする際に順番がバラバラだと困ったことになるので、足並みをそろえてほしいときに使う。
注意としては1グループ=256スレッドの場合、256すべてのスレッドでGroupMemoryBarrierWithGroupSync();が実行されないと次の行に進まないということ。これは多分OpenCLやCUDAでも同じ考えだろう。

総和アルゴリズムについて

今回のやつを一応図解しておく。わかりやすくするために0~7の総和で考える。
reduction


最終的に共有メモリの0番目の要素に結果が集まる形である。

とりあえずnumthreadsには64の倍数を指定しておくとなぜ良いのか

前回の記事で
・numthreadsには1~1024までの数字しか指定できない
・とりあえずnumthreadsには64の倍数を指定しておくと良い
と書いているが、特に2番目、なぜそうしたほうが良いのかという理由はまだ説明できていない。その理解のためにはグループ、スレッドの抽象概念の理解とハードウェアのほうの理解両方が必要であり、いよいよ避けて通れなくなってきた。

これを理解するために早速あるプログラムの実験をしてみよう
C#(Host.cs)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public class Host : MonoBehaviour
{
    public ComputeShader shader;
    ComputeBuffer A;
    int cnt;
    int time0;
    int time1;
    uint[] host_A;
    int[] k;
    int N;
    int[] timelist;
    int knum;
    int THREADNUM;
    void Start()
    {
        N = 11;
        THREADNUM = 256 * 1024;
        timelist = new int[N];
        host_A = new uint[THREADNUM];
        A = new ComputeBuffer(host_A.Length, sizeof(uint));
        k = new int[9];
        k[0] = shader.FindKernel("sharedmem_samp1");
        k[1] = shader.FindKernel("sharedmem_samp2");
        k[2] = shader.FindKernel("sharedmem_samp4");
        k[3] = shader.FindKernel("sharedmem_samp8");
        k[4] = shader.FindKernel("sharedmem_samp16");
        k[5] = shader.FindKernel("sharedmem_samp32");
        k[6] = shader.FindKernel("sharedmem_samp64");
        k[7] = shader.FindKernel("sharedmem_samp128");
        k[8] = shader.FindKernel("sharedmem_samp256");
        //引数をセット
        for (int i = 0; i < 9; i++)
        {
            shader.SetBuffer(k[i], "A", A);
        }
        cnt = 0;
        knum = 0;
    }
    

    void Update()
    {
        if (cnt < N) {
            gpuvoid();
        }
        
        if (cnt == N) {
            Array.Sort(timelist);//中央値を選択したい
            Debug.Log("グループスレッド数="+(1<<knum)+"\n計算時間        "+timelist[N/2]+"ms");
            
            knum++;
            if (knum == 9)
            {
                A.Release();
            }
            else
            {
                cnt = -1;
            }
        }

        cnt++;

    }

    void gpuvoid()
    {
        time0 = Gettime();
        // GPUで計算
        shader.Dispatch(k[knum], THREADNUM >> knum, 1, 1);
        // device to host
        A.GetData(host_A);
        time1 = Gettime() - time0;
        timelist[cnt] = time1;
    }

    //現在の時刻をms単位で取得
    int Gettime()
    {
        return DateTime.Now.Millisecond + DateTime.Now.Second * 1000
         + DateTime.Now.Minute * 60 * 1000 + DateTime.Now.Hour * 60 * 60 * 1000;
    }
}



ComputeShader
#pragma kernel sharedmem_samp1
#pragma kernel sharedmem_samp2
#pragma kernel sharedmem_samp4
#pragma kernel sharedmem_samp8
#pragma kernel sharedmem_samp16
#pragma kernel sharedmem_samp32
#pragma kernel sharedmem_samp64
#pragma kernel sharedmem_samp128
#pragma kernel sharedmem_samp256
RWStructuredBuffer<uint> A;
#define LPNM 2048


uint SinRandom(uint id) {
	uint x = id;
	float y;

	for (int i = 0; i < LPNM; i++) {
		y = sin(1.234f*(float)(x % 12345));
		y = 2.3456f / (y+1.0001f);
		x = (uint)(100000000.0f*y) + id;
	}
	return x;
}


[numthreads(256, 1, 1)]
void sharedmem_samp256(uint id : SV_DispatchThreadID) {
	A[id] = SinRandom(id);
}

[numthreads(128, 1, 1)]
void sharedmem_samp128(uint id : SV_DispatchThreadID) {
	A[id] = SinRandom(id);
}

[numthreads(64, 1, 1)]
void sharedmem_samp64(uint id : SV_DispatchThreadID) {
	A[id] = SinRandom(id);
}

[numthreads(32, 1, 1)]
void sharedmem_samp32(uint id : SV_DispatchThreadID) {
	A[id] = SinRandom(id);
}

[numthreads(16, 1, 1)]
void sharedmem_samp16(uint id : SV_DispatchThreadID) {
	A[id] = SinRandom(id);
}

[numthreads(8, 1, 1)]
void sharedmem_samp8(uint id : SV_DispatchThreadID) {
	A[id] = SinRandom(id);
}

[numthreads(4, 1, 1)]
void sharedmem_samp4(uint id : SV_DispatchThreadID) {
	A[id] = SinRandom(id);
}

[numthreads(2, 1, 1)]
void sharedmem_samp2(uint id : SV_DispatchThreadID) {
	A[id] = SinRandom(id);
}

[numthreads(1, 1, 1)]
void sharedmem_samp1(uint id : SV_DispatchThreadID) {
	A[id] = SinRandom(id);
}


https://github.com/toropippi/UintyGPGPU/tree/master/gpgpu_sample3a

これはGPU上で適当なランダムな値を生成してグローバルメモリに書き込むというサンプルだが、注目するべきはループの回数。2048ループと非常に多く、かつループ内には除算も入っており、これは完全に演算律速となる問題だ。これを256*1024並列でGPU上で実行する。その際全体の並列数は変えずに、グループスレッド数だけを変えて実行してみる。

AMD(RX 480)のグラボのとき
amdjikan


NVIDIA(MX150(Pascal))のグラボのとき
nvidia


見やすくグラフにしてみる
amd

nv


見やすくするために実行時間の逆数を棒グラフにしている。フレームレートを見ていると考えれば良い。横軸はグループスレッド数だ。

グループスレッド数を1→2→4と倍々に増やしていくと、それに応じて速度が倍々に増えているのがわかる。そしてAMDでは64、NVIDIAでは32で頭打ちになる。
これらの結果からAMDのグラボではグループスレッド数を64、128、256にしたとき最大パフォーマンスが、NVIDIA(Pascal)のグラボではグループスレッド数を32、64、128、256にしたとき最大パフォーマンスが得られると経験的にわかった。
ComputeShaderは一応両方のベンダーのグラボで実行できるわけだから、多くの状況を想定するならグループスレッド数を最低でも64にしておくほうが安全ということになる。


ではなぜNVIDIAで32、AMDで64が頭打ちになるかというと、NVIDIAでいうところの「Warp」、AMDでいうところの「Wavefront」が関係している。あまり詳しいわけではないがざっくりいうとこんな感じ

Warp
NVIDIAのGPUでは32スレッド分を1まとまりの単位として実行する。その単位がWarp
・Maxwell,Pascalアーキテクチャでは32スレッドを32CUDA Coreが1cycleで実行する
・Fermi,Kepler,Volta,Turingアーキテクチャでは32スレッドを16CUDA Coreが2cycleで実行する
・16 or 32のスレッドが同じタイミングで同じ行のプログラムを実行する

Wavefront
AMDのGPUでは64スレッド分を1まとまりの単位として実行する。その単位がWavefront
・64スレッドを16PEが4cycleで実行する(PE=CUDA coreみたいなもん)
・16スレッドが同じタイミングで同じ行のプログラムを実行する


だからグループスレッドに1を指定してしまうと、AMDのGPUなら1cycle目で16PEのうち15PEが空回りし2~4cycle目で16PE全部が空回りする。そしてNVIDIAのPascalだと32CUDA Core中31CUDA Coreが何もしないで空回りすることになってしまう。
こんな感じにとんでもない無駄を生む可能性があるため、この概念を少しでも理解し、グループスレッド数を指定できるようになりたい。
ここまでわかればとても奇数や素数を指定する気にはならないだろう。2のべき乗でと考えると選択肢は64,128,256,512,1024に限られる。512,1024までいくと古いグラボやShaderModelによって実行できない場合があるので64,128,256の3つが現実的になるのかなと思う。
なお192とかは確かに64の倍数だけどなんか気持ち悪い・・・

補足

WindowsだとGPUのタスクが重すぎて2秒とか反応しないとOSが検知してドライバー強制終了させるのがデフォルトのようだ。
そんなことをされたらデバッグが捗らないのでここを参照してレジストリをいじっておく
https://support.borndigital.co.jp/hc/ja/articles/360000574634


※本ブログで紹介しているソースコードはすべてCC0 パブリックドメインです。
ご自由にお使いください



UnityでGPGPUその2 並列計算と時間測定、コアレスアクセスなど


UnityでGPGPUその4 ComputeShaderでAtomic演算

UnityでGPGPUその2 並列計算と時間測定、コアレスアクセスなど

ベクトルの内積のGPU実装

naiseki

今回ベクトルの内積の計算をGPUで行う。要素数は65536*4=262144。ベクトルの各要素は1/1,1/2,1/3,1/4,1/5・・・であり、この場合内積の結果がpi26halfに収束することが分かっている。ベクトル長を長くするほど正確なπが求まる。(が、実際はfloat精度の桁数の問題ですぐ頭打ちになる)

ということで各要素の掛け算のところを並列に行うことを考える。普通に考えると1コアで262144回の計算を2コアなら131072回、4コアなら65536回と並列度をあげればあげるほど1コアあたりの計算量が減って速く計算ができるはずだ。

まぁその通りなのだが、ある程度並列度を上げるとメモリ律速になって思うように速くならないことがある。というかそんなことだらけだ。
なので今回の記事ではそこのところも少し突っ込んでまとめていこうと思う。

GPUの並列数を指定する個所は2つある。ComputeShader側の
[numthreads(x, y, z)]
とC#側の
shader.Dispatch(k, X, Y, Z);
だ。
この場合x*X*y*Y*z*Z並列で処理が行われる。

・x,y,zのほうで1グループあたりのスレッド数を指定して並列化
・X,Y,Zのほうでグループ数を指定して並列化

ができる。

じゃあ2つの使い分けはというと、これは説明しだすとかなり長くなるので次の記事で。最低限覚えておくべき知識は、「numthreadsには1~1024までの数字しか指定できない」「とりあえずnumthreadsには64の倍数を指定しておくと良い」というところ。

この知識をふまえ早速GPGPUでバリバリ高速化・・・したいところだがそこは抑えて、まずは「1並列」で処理するコードを書いて答えを確認してみる。

1並列

前回の記事のHoge0を
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public class Hoge0 : MonoBehaviour
{
    public ComputeShader shader;

    void Start()
    {
        int time0 = Gettime();
        int N = 262144;//=2^18
        float[] host_vec1 = new float[N];
        float[] host_vec2 = new float[N];
        float[] host_ans = new float[1];

        //初期値をセット
        for (int i=0;i< N; i++)
        {
            host_vec1[i] = 1.0f / (i + 1.0f);// 1/1 , 1/2 , 1/3 , 1/4・・・・
            host_vec2[i] = 1.0f / (i + 1.0f);// 1/1 , 1/2 , 1/3 , 1/4・・・・
        }

        ComputeBuffer vec1 = new ComputeBuffer(host_vec1.Length, sizeof(float));
        ComputeBuffer vec2 = new ComputeBuffer(host_vec2.Length, sizeof(float));
        ComputeBuffer ans = new ComputeBuffer(1, sizeof(float));

        int k = shader.FindKernel("CSMain");

        // host to device
        int time1 = Gettime() - time0;
        vec1.SetData(host_vec1);
        vec2.SetData(host_vec2);
        int time2 = Gettime() - time0;

        //引数をセット
        shader.SetBuffer(k, "vec1", vec1);
        shader.SetBuffer(k, "vec2", vec2);
        shader.SetBuffer(k, "ans", ans);

        // GPUで計算
        int time3 = Gettime() - time0;
        shader.Dispatch(k, 1, 1, 1);
        int time4 = Gettime() - time0;

        // device to host
        ans.GetData(host_ans);
        int time5 = Gettime() - time0;

        //計測時間表示
        Debug.Log("CPU→GPU転送前\t" + time1);
        Debug.Log("CPU→GPU転送後\t" + time2);
        Debug.Log("Dispatch前\t" + time3);
        Debug.Log("Dispatch後\t" + time4);
        Debug.Log("GPU→CPU転送後\t" + time5);

        //結果表示
        float calc_pi = Mathf.Sqrt(host_ans[0] * 6.0f);
        Debug.Log("π =" + calc_pi.ToString("f10"));

        //解放
        vec1.Release();
        vec2.Release();
        ans.Release();
    }

    // Update is called once per frame
    void Update()
    {

    }

    //現在の時刻をms単位で取得
    int Gettime()
    {
        return DateTime.Now.Millisecond + DateTime.Now.Second * 1000
         + DateTime.Now.Minute * 60 * 1000 + DateTime.Now.Hour * 60 * 60 * 1000;
    }
}



とし
HogeCS0を
#pragma kernel CSMain
//Read and Write
RWStructuredBuffer<float> vec1;
RWStructuredBuffer<float> vec2;
RWStructuredBuffer<float> ans;

[numthreads(1, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
	float s = 0.0;
	for (int i = 0; i < 262144; i++) {//=2^18
		s += vec1[i] * vec2[i];
	}
	ans[0] = s;
}

として実行。


kekka1

πが3.141くらいまで求まった。成功だ!

GPUの計算時間計測の落とし穴

GPUの計算時間についてはどうか。一番最初の時刻を0msとしており、命令が実行される時点での時刻(ms)を右に表示してある。
上のはった画像を見るに、CPU→GPUのデータ転送は1ms。GPUの計算はなんと0msで行えているようだ。そしてGPU→CPUの転送に75msかかっている・・・ーーと思うがこれは間違い!

実際はGPUの計算に75msかかっている!Dispatch命令はGPUに向かってタスクを投げるだけであって、GPUの計算が終了するまで待つ命令ではない!!
【イメージ】
tasktime



だからどれだけGPUのタスクが重かろうと、
        // GPUで計算
        int time3 = Gettime() - time0;
        shader.Dispatch(k, 1, 1, 1);
        int time4 = Gettime() - time0;
このtime3とtime4の差はほとんどない。
個人的な感覚では0.3μsくらいだ。
そして
        // device to host
        ans.GetData(host_ans);
ここでGPUの計算が終わるのを待ち、そのあとGPU→CPUの転送が行われるというわけ。

ちなみにGPUの計算が終わるまで待つという命令(OpenCLで言うclFlush)はないのかというと、私が探した限りでは見つけられなかった。あればGPUの計算時間を直接計算できるのだが。今はGetDataで代用するしかないようだ。

データ転送時間の計算

計測時間についてさらに考察を深めてみよう。時間がない人は「ちゃんとした並列計算」まで飛ばしても構わない。

CPU→GPU、GPU→CPUのデータ転送はPCI Expressを経由している。
mem
2019年1月現在PCI Express Gen3.0がもっとも普及しておりGPUとの接続では基本PCI Express Gen3.0 x16となっているだろう。これは片方向16GB/sの帯域速度がでる。今回のプログラムではCPU→GPUに262144要素×4byte×2のデータ転送を行っているが、これはたったの2MBである。この転送にかかる時間は理論上0.122msとなり、実際に計測された1msでは遅いくらいだ(オーバーヘッドが含まるからおかしくはない)。

clock単位の計算時間計測

次にGPU処理部分の時間について考える。今回1並列で愚直に計算しており、せっかくRX480には2304個の演算器があるのに2303個は空回りしているという状態。ただ丁度よい機会なので1演算器あたりの性能をここで計算してみよう。

[numthreads(1, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
	float s = 0.0;
	for (int i = 0; i < 262144; i++) {//=2^18
		s += vec1[i] * vec2[i];
	}
	ans[0] = s;
}
さっきのCSMainだが、コードをみると計算の大部分はループ内の
		s += vec1[i] * vec2[i];
である。75msで262144ループなので1ループ当たり286nsかかっている。
大雑把にGPUのコアクロックを1Ghzとすると1clock=1nsなので、この1行に286clockもかかっていることになる!これは遅い!?
実はFP32演算器は1clockでa+b , a-b , a*b , a*b+cの計算を行うことができる。とするとこの行の計算自体は1clockで終わるはずで、vec1[i],vec2[i]のメモリアクセスがどうも原因であることに気づく。つまりメモリアクセスに約280clockかかっていて、その間FP32演算器が「待ちぼうけ」を喰らっているわけだ。なんという無駄か!

ちゃんとした並列計算

1グループあたりのスレッド数を増やして並列度を上げる

ではまずComputeShaderの
[numthreads(1, 1, 1)]

[numthreads(32, 1, 1)]
に置き換える。

コアレスアクセス

そして高速化において「連続したアドレスにアクセスすること」が鍵になる。上記の実験にて演算が1clockに対しメモリアクセスが280clockかかっていたことからも、メモリアクセスの高速化がいかに大事かわかる。

今まで話に出てきたメモリは「グローバルメモリ」といって、これに効率よくアクセスするには「コアレスアクセス」が必要だ。(グローバルメモリ=実体はGDDR5とかGDDR6とかHBM2)

[numthreads(32, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
	float s = 0.0;
	for (int i = 0; i < 8192; i++) {
		s += vec1[id.x + i*32] * vec2[id.x + i * 32];
	}
	
	ans[id.x] = s;
}
これがコアレスアクセスしている32並列のComputeShaderコードになる。1スレッドあたり8192回ループを行っているが、ベクトル長が262144要素で32並列なのでこうなる。
ここでvec1,vec2の添え字がid.x+i*32になっているのに注目。
iが0のとき
corelessacs
これがコアレスアクセス。一度に連続したデータをとってくる。

iが1のとき
corelessacs2
これもコアレスアクセス。
コアレスアクセスでは、一度のメモリアクセスに必要な時間(≒レイテンシ)は変わらないが、一度にとってこれるデータ量が増えるためTotalで見ると高速になる。この矢印がてんでバラバラな領域に向かっているとパフォーマンスが落ちる。同じ領域内なら矢印がバラバラでもパフォーマンスは落ちない ←ここ重要!

これができているかできていないかで、同じ並列数でも実行速度が全然違ってくる。記事の下のほうで実験してるが4-5倍ほど平気で変わってくる。

Hoge0(C#)も修正
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public class Hoge0 : MonoBehaviour
{
    public ComputeShader shader;

    void Start()
    {
        int time0 = Gettime();
        int p = 32;//並列数
        int N = 65536* 4;
        float[] host_vec1 = new float[N];
        float[] host_vec2 = new float[N];
        float[] host_ans = new float[p];

        //初期値をセット
        for (int i=0;i< N; i++)
        {
            host_vec1[i] = 1.0f / (i + 1.0f);// 1/1 , 1/2 , 1/3 , 1/4・・・・
            host_vec2[i] = 1.0f / (i + 1.0f);// 1/1 , 1/2 , 1/3 , 1/4・・・・
        }

        ComputeBuffer vec1 = new ComputeBuffer(host_vec1.Length, sizeof(float));
        ComputeBuffer vec2 = new ComputeBuffer(host_vec2.Length, sizeof(float));
        ComputeBuffer ans  = new ComputeBuffer(host_ans.Length , sizeof(float));

        int k = shader.FindKernel("CSMain");

        // host to device
        int time1 = Gettime() - time0;
        vec1.SetData(host_vec1);
        vec2.SetData(host_vec2);
        int time2 = Gettime() - time0;

        //引数をセット
        shader.SetBuffer(k, "vec1", vec1);
        shader.SetBuffer(k, "vec2", vec2);
        shader.SetBuffer(k, "ans", ans);

        // GPUで計算
        int time3 = Gettime() - time0;
        shader.Dispatch(k, 1, 1, 1);
        int time4 = Gettime() - time0;

        // device to host
        ans.GetData(host_ans);
        int time5 = Gettime() - time0;

        //計測時間表示
        Debug.Log("CPU→GPU転送前\t" + time1);
        Debug.Log("CPU→GPU転送後\t" + time2);
        Debug.Log("Dispatch前  \t" + time3);
        Debug.Log("Dispatch後  \t" + time4);
        Debug.Log("GPU→CPU転送後\t" + time5);

        //結果表示
        float calc_pi = 0.0f;
        for (int i=0;i< p; i++)//最後の最後の結果はCPUで加算
        {
            calc_pi += host_ans[i];
        }
        calc_pi = Mathf.Sqrt(calc_pi * 6.0f);
        Debug.Log("π =" + calc_pi.ToString("f10"));

        //解放
        vec1.Release();
        vec2.Release();
        ans.Release();
    }

    // Update is called once per frame
    void Update()
    {

    }

    //現在の時刻をms単位で取得
    int Gettime()
    {
        return DateTime.Now.Millisecond + DateTime.Now.Second * 1000
            + DateTime.Now.Minute * 60 * 1000 + DateTime.Now.Hour * 60 * 60 * 1000;
    }
}

そして計算時間は
res2
13-6=7ms。結構縮んだ。
75ms→7msで10倍高速化。
もちろんこの7msの中にGPU→CPU転送時間も含まれているから実際はもう少し短い。
内積の最後の足し算だが、今まではGPU側のans[0]に結果を全部まとめていたが、今回はGPU側でans[0]~ans[31]に各スレッドの結果を代入し、CPU側でans[0]~ans[31]を合計している。
GPU側で最後の32要素の合計をすることもできるがやや難易度が上がるので次の機会とする。

グループ数を増やして並列度を上げる

ではC#側のDispatch関数で
        shader.Dispatch(k, 128, 1, 1);
こうして
128*32=4096並列で実行するとしよう。そうするとans[0]~ans[4095]まで必要になるがそこも修正して計算時間を計ってみると・・・
(コードは省略)
kekka4
7-6=1ms。は、速い!75ms→7ms→1msと少なくとも75倍高速化できており、時間解像度の問題でもはや正確に測定できてるのか疑うレベル。

今回の高速化ではコアレスアクセスのところはいじってないため、単に立ち上げるスレッド数が32→4096に増えたことによるものだろう。細かくは私もわからないが、多くのスレッドからメモリアクセス要求があったほうが、その帯域を使い切れるといったイメージだ。きっとさっきの32並列のやつだけではその帯域を全然使い切れてなかったということなのではなかろうか。

4096並列のときの計算時間再測定

さて今度は計算時間をちゃんと測定するため、もっと計算の規模を大きくしなければいけなさそうだ。Nを65536*4から512倍の65536*2048にする。

Hoge0(C#)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public class Hoge0 : MonoBehaviour
{
    public ComputeShader shader;

    void Start()
    {
        int time0 = Gettime();
        int bp = 32;//ComputeShaderで指定している並列数
        int gp = 128;//Dispatchで指定している並列数
        int N = 65536*2048;
        float[] host_vec1 = new float[N];
        float[] host_vec2 = new float[N];
        float[] host_ans = new float[bp*gp];

        //初期値をセット
        for (int i=0;i< N; i++)
        {
            host_vec1[i] = 1.0f / (i + 1.0f);// 1/1 , 1/2 , 1/3 , 1/4・・・・
            host_vec2[i] = 1.0f / (i + 1.0f);// 1/1 , 1/2 , 1/3 , 1/4・・・・
        }

        ComputeBuffer vec1 = new ComputeBuffer(host_vec1.Length, sizeof(float));
        ComputeBuffer vec2 = new ComputeBuffer(host_vec2.Length, sizeof(float));
        ComputeBuffer ans  = new ComputeBuffer(host_ans.Length , sizeof(float));

        int k = shader.FindKernel("CSMain");

        // host to device
        int time1 = Gettime() - time0;
        vec1.SetData(host_vec1);
        vec2.SetData(host_vec2);
        int time2 = Gettime() - time0;

        //引数をセット
        shader.SetBuffer(k, "vec1", vec1);
        shader.SetBuffer(k, "vec2", vec2);
        shader.SetBuffer(k, "ans", ans);

        // GPUで計算
        int time3 = Gettime() - time0;
        shader.Dispatch(k, gp, 1, 1);
        int time4 = Gettime() - time0;

        // device to host
        ans.GetData(host_ans);
        int time5 = Gettime() - time0;

        //計測時間表示
        Debug.Log("CPU→GPU転送前\t" + time1);
        Debug.Log("CPU→GPU転送後\t" + time2);
        Debug.Log("Dispatch前  \t" + time3);
        Debug.Log("Dispatch後  \t" + time4);
        Debug.Log("GPU→CPU転送後\t" + time5);

        //結果表示
        float calc_pi = 0.0f;
        for (int i=0;i< bp * gp; i++)//最後の最後の結果はCPUで加算
        {
            calc_pi += host_ans[i];
        }
        calc_pi = Mathf.Sqrt(calc_pi * 6.0f);
        Debug.Log("π =" + calc_pi.ToString("f10"));

        //解放
        vec1.Release();
        vec2.Release();
        ans.Release();
    }

    // Update is called once per frame
    void Update()
    {

    }

    //現在の時刻をms単位で取得
    int Gettime()
    {
        return DateTime.Now.Millisecond + DateTime.Now.Second * 1000
            + DateTime.Now.Minute * 60 * 1000 + DateTime.Now.Hour * 60 * 60 * 1000;
    }
}


HogeCS0(ComputeShader)

#pragma kernel CSMain
//Read and Write
RWStructuredBuffer<float> vec1;
RWStructuredBuffer<float> vec2;
RWStructuredBuffer<float> ans;

[numthreads(32, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
	float s = 0.0;
	for (int i = 0; i < 32768; i++) {//65536*2048/32/128=32768
		s += vec1[id.x + i * 4096] * vec2[id.x + i * 4096];
	}
	ans[id.x] = s;
}

これで実行すると
kekka5
2794-2503=261ms
512倍にしているので実際は261/512=0.51msだったことがわかる。

まとめると
1並列:75ms
32並列:7ms
4096並列:0.51ms

となり、最初の1並列プログラムと比較すると149倍高速になったことになる。

ソースはこちら
https://github.com/toropippi/Uinty_GPGPU_SharedMem_AtomicAdd/tree/master/gpgpu_sample2

πが全然収束してないがこれはfloat精度なので仕方ない。double型で計算し直したところ3.141592までは正確に計算できた。

ランダムアクセスは遅い

最後に、コアレスアクセスでない方法を試す。先ほどの4096並列の512倍規模にしていたやつのコードを流用する。
[numthreads(32, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
	float s = 0.0;
	for (int i = 0; i < 32768; i++) {//65536*2048/32/128=32768
		s += vec1[id.x* 32768 + i] * vec2[id.x* 32768 + i];
	}
	ans[id.x] = s;
}
今度は添え字がid.x*32768 + iになっている。

corelessacs3

iが0のとき、こうなる。iが1のときも同様、離れている個所へのメモリアクセスとなり、これはランダムアクセスといって効率が悪い。
この方式でやると・・・
kekka6
3713-2544=1169ms
やはり遅くなっている!
261msから1169msなので4.48倍遅い。

まとめ

ベクトルの内積をやるといって、結局メモリの話ばかりしてたような気がする。ただ実際、GPUの計算はグローバルメモリのアクセスがボトルネックになってくるので、パフォーマンスに直結するので大事なことだと思っている。

※本ブログで紹介しているソースコードはすべてCC0 パブリックドメインです。
ご自由にお使いください




UnityでGPGPUその3 ComputeShaderの共有メモリ(shared memory)

UnityでGPGPUその1 C=A+B

UnityでGPGPU

UnityでGPGPU(Compute Shader)を扱ったブログや書籍が増えてきたものの依然GPUプログラミングの敷居は高い。GPUの計算結果をシェーダー(レンダリング)で使う場合、シェーダーの学習コストも高く、数値計算側とは別の壁になっている。
「数値計算」と「レンダリング」を切り分けて、数値計算部分のことについて簡単に書いた記事は少ないなと思っていた矢先、中国語で数値計算だけやっている記事があった。
https://blog.csdn.net/weixin_38884324/article/details/79284373
これこれ、こういうのが日本語でもきっと必要だ!


GPUプログラミングにおいてのHello Worldは、1+1の計算をすることだと思っている。
今回行うことはGPU上に4要素の配列変数A,B,Cを確保し、CPU側からAとBに1を代入、GPU上でC=A+Bを4並列で行い結果をCPU側にとってくるというもの。
eererytuyrrt


実装(最小構成)

UnityでGPGPUを行うのに必要なのはたった3つ。
・C#コード
・ComputeShaderコード
・空のGameObject



では最初から
まずは適当に2Dでも3DでもどっちでもいいのでUnityプロジェクトを生成
no title


空のGameObjcetを作成する
1


Assetsフォルダ内にScriptsフォルダを作成して以下の2つを作成
・右クリック→Create→C# script→「Hoge0」
・右クリック→Create→Shader→Compute Shader→「HogeCS0」

以下のソースをコピー

C#
using System.Collections;
using System.Collections.Generic;
using UnityEngine;


public class Hoge0 : MonoBehaviour
{
    public ComputeShader shader;

    void Start()
    {
        float[] host_A = { 1f, 1f, 1f, 1f };
        float[] host_B = { 1f, 1f, 1f, 1f };
        float[] host_C = { 0f, 0f, 0f, 0f };

        ComputeBuffer A = new ComputeBuffer(host_A.Length, sizeof(float));
        ComputeBuffer B = new ComputeBuffer(host_B.Length, sizeof(float));
        ComputeBuffer C = new ComputeBuffer(host_C.Length, sizeof(float));

        int k = shader.FindKernel("CSMain");

        // host to device
        A.SetData(host_A);
        B.SetData(host_B);

        //引数をセット
        shader.SetBuffer(k, "A", A);
        shader.SetBuffer(k, "B", B);
        shader.SetBuffer(k, "C", C);

        // GPUで計算
        shader.Dispatch(k, 1, 1, 1);

        // device to host
        C.GetData(host_C);

        Debug.Log("GPU上で計算した結果");
        for (int i = 0; i < host_C.Length; i++)
        {
            Debug.Log(host_A[i] + ", " + host_B[i] + ", " + host_C[i]);
        }

        //解放
        A.Release();
        B.Release();
        C.Release();
    }


    // Update is called once per frame
    void Update()
    {

    }


}

ComputeShader
#pragma kernel CSMain
//Read and Write
RWStructuredBuffer<float> A;
RWStructuredBuffer<float> B;
RWStructuredBuffer<float> C;

[numthreads(4, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
	C[id.x] = A[id.x]+ B[id.x];
}

空のゲームオブジェクトにHoge0をアタッチ
2

ゲームオブジェクトをクリックしInspectorのとこのShaderにHogeCS0をセット
3

結果


4

解説

コード自体は中国語のサイトのやつをそれなりにパクっている。だが重要なことはすべてこの中につまっている。
ComputeBuffer A = new ComputeBuffer(host_A.Length, sizeof(float));
ここでGPU側のメモリを確保している。


int k = shader.FindKernel("CSMain");
ここでは、ComputeShader側のCSMainという関数とC#側のkを紐づけていると思えばよい。
今後このkを使ってC#側から命令発行したりする。

A.SetData(host_A);
host_AはCPU側で確保したメモリ。ここでCPU→GPUへとデータ転送している。


shader.SetBuffer(k, "A", A);
ComputeShaderのCSMain関数で使うAは、さっきC#側で生成したAとまだ紐づいていないため、これで紐づける。1回紐づければ後はAの中身を変えようが解かれない。

shader.Dispatch(k, 1, 1, 1);
ここでやっとGPUに計算させることができる。kはCSMainと紐づいているのでCSMainを1回実行することになる。
今度はComputeShader側のコードみてみると
[numthreads(4, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
	C[id.x] = A[id.x]+ B[id.x];
}
numthreads(4, 1, 1)は4並列で計算するという意味。
id.xはスレッドidを意味している。
4並列なので0番目のスレッドではid.xが0、1番目のスレッドではid.xが1、2番目のスレッドではid.xが2、3番目のスレッドではid.xが3
このid.xの使い方が並列計算で最初慣れないところではあるが、これが1億並列になったとしてもこのid.xを使って並列計算するためこの概念は重要である。

C#コードに戻って
C.GetData(host_C);
ここでGPU→CPUの転送が行われている。これによってはじめてCPU側で結果を確認することができる。ComputeBuffer「C」はGPU上に確保されているのでC#側からは見れないためだ。

A.Release();
GPU上に確保したメモリは最後解放しないとUnityに怒られる。ちなみに忘れてもプログラムを終了すれば自動的に解放される。


以上のことだけで、多くの数値計算コードをGPUに移植できるようになる。

実行環境

OS:Windows 10
CPU:core i7 3820
メモリ:32GB
GPU:Radeon RX 480 (GDDR5 8G)
Unity:2018.3.9 Personal



※本ブログで紹介しているソースコードはすべてCC0 パブリックドメインです。
ご自由にお使いください
ソースはこちらで公開
https://github.com/toropippi/Uinty_GPGPU_SharedMem_AtomicAdd/tree/master/gpgpu_sample1

参考文献

https://blog.csdn.net/weixin_38884324/article/details/79284373
http://neareal.com/2601/



UnityでGPGPUその2 並列計算と時間測定、コアレスアクセスなど


プロフィール

toropippi

記事検索
アクセスカウンター

    QRコード
    QRコード
    • ライブドアブログ