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

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

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

GPGPU

UnityのComputeShader関連のエラー Failed to present D3D11 swapchain due to device reset/removed.について

Failed to present D3D11 swapchain due to device reset/removed.

というエラーメッセージについて、個人的にハマったこと、解決したことをまとめようと思います。
結論から言うとこういうことです。

環境:Windows 11、Unity 2021.2.7f1、GPUはGeForce MX150(mem 2GB)
・Windowsで描画担当のGPUが高負荷で数秒応答しなくなった時、ドライバが強制終了する仕様がある
・Unityのプログラムで初期地形生成にGPUのComputeShaderを使っていた。低スペックGPUだと3-5秒くらいかかるので、タイムアウト時間によりOSがドライバをリセット、Unityで上記エラーが発生

さて、これはバグでもなくUnity側の不具合でもなく、WindowsOSの仕様が引き起こしたことなのでバグとは呼ばず「エラー」と言うこととします。
では時系列順に起こったことを書いていきます。

Unity Editorでエラー&強制終了

ある日からUnity Editorで再生ボタンを押すとこのようにエラーマークが出てEditorごと落ちるようになった。
100%落ちるわけではなく20%くらいの確率で普通にうまくプログラムが起動する。
また、別の高スペックなほうのPCでやると一切エラーマークは発生せず、ちゃんと起動する。
e1

Waiting for Unity's code to finish executing

続いてエラーが出るときにEditor Windowの後ろのほうに出ているメッセージになにかヒントがないか調べてみた。
"EnterPlayMode"や"Waiting for Unity's code to finish executing"などで検索するも有益な情報なし。
そもそも今回のエラーと関係あるのか確証もなかった。

e2

Buildしたらどうなるか

Editor上で実行するときだけ起こる問題なのかと思い、今度はBuildでexeを生成してから実行してみた。
すると100%の確率で強制終了。画像のようなエラーマークがでる。
別のPCでやるとちゃんと起動できる。なのでPCのスペックに依存したエラーかなとあたりがつく
e3

クラッシュログを見る

Editorでエラー終了するときにどうもクラッシュログが生成されていることに気づく。
クラッシュログをみるとGPUのメモリ確保(TextureやComputeBuffer)に全て失敗しているようだった。
これでGPU関連のプログラムでなにかまずいことをしているかもと絞り込んでいろいろ試行錯誤開始。
crashlog

エラーが発生しない状況を突き止める

その結果、地形生成部分のGPUの計算負荷(ComputeShaderを使っている)を軽くするとエラーが発生しないことがわかった。具体的には1616×1616マスの地形生成を208×208に減らした。
なので今後は1616×1616のうち208×208の地形生成を最初に行い、数秒後に残りの部分の地形生成を行いエラーが発生するか確かめてみることにした。

GPU Timeoutの原因がexpensive workloads

そうしたところエラー発生。ついに有益なメッセージが得られた。
Failed to present D3D11 swapchain due to device reset/removed.
This error can happen if you draw or dispatch very expensive workloads to the GPU,
which can cause Windows to detect a GPU Timeout and reset the device.
~~~~~~~~~

e4

要約するとGPU負荷がすごすぎてGPUタイムアウト、Windowsがデバイスをリセットした、ということのよう。
これはWindows上でCUDAで遊んだり機械学習してる人なら見慣れている光景かもしれないが、レジストリのTdrDelayを書き換えれば解決するやつ。

TdrDelayを書き換え解決

リンクのやつを参考にTdrDelayを60秒にして再起動をかける。

振り返り

振り返ってみると、ある日からエラーが発生するようになったのはWindows 11にアップデートしたからでした。もともとWindows10で作業していたときは別件ですでに上記のレジストリをいじっていたのでエラーは発生しなかったのだと思われます。これがOSアップデートで戻され、タイムアウト時間が数秒程度になっていたのでしょう。
Unity側からしてみると、プログラム実行中にドライバが突然リセットされるので何が起こったかわかるはずもありません。最初まともなエラーメッセージがでなかったのも無理はありません。

多分、WindowsOSがGPUドライバを強制リセットすることがあるということを知らないとなかなか解決には至らなかったかと思うので今回記事にさせていただきました。

パディングなしでバンクコンフリクトを解消する方法 CUDA 行列転置

シェアードメモリとバンク

GPUを使った行列転置でほぼ頻出話題のバンクコンフリクト(Bank Conflict)とパディング(Padding)。前提知識としてCUDA等のGPUプログラミングでシェアードメモリ(共有メモリ)を明示的に扱う際に、Bankを意識しないと遅くなっちゃいますよという話があります。
bank

シェアードメモリは4byte刻みで32個のBankに分かれています。
例えばthread 0がSMEM[0]、thread 1がSMEM[32]にアクセスするようなプログラムだと(SMEMは4byte型とします)、どちらも同じBank0にアクセスすることになるのでリプレイ(replay)が発生して遅くなります。

行列転置バンクコンフリクトありver

つまり普通にシェアードメモリを使って行列転置をしようとすると
jurai

こんな感じにShared Memoryから値を読み込む際にバンクコンフリクトが起こってしまっています。
ちなみにここではわかりやすくBankは0~3までとしています。

行列転置パディングありver

そこでパディングをいれることで参照Bankがずれるのでこれを解消できます。そこそこ有名なテクニックです。
Kepler GPUアーキテクチャとプログラム最適化
プログラムを高速化する話Ⅱ 〜GPGPU編〜

sample0

概念図
juraip

これでShared Memoryに書き込むときも読み込むときも、Bank競合が起きてないことが分かります。
でも貴重なShared Memoryに未使用領域があるのはちょっと気持ち悪くないですか。

行列転置パディングなしver

上の概念図がすでに大きなヒントでした。
sinki
これでも読み書きともバンクコンフリクトは起こってないですね。
メモリアクセスの仕方としては
code0

こんな感じにx方向のズレ幅をyごとにずらしてやればいいです。

一応コードの全貌を載せておきます。

#include<stdio.h>
#include<cuda.h>
#include<cuda_runtime.h>

// --matrix size --
const int nimax=8192,njmax=8192;
// -- the number of threads per block --
const int BS=32;
__device__ float idata[nimax][njmax],odata[nimax][njmax];

__global__ void transposeNaive(){
  int ni,nj;
  ni=blockIdx.y*blockDim.y+threadIdx.y;
  nj=blockIdx.x*blockDim.x+threadIdx.x;
  odata[nj][ni]=idata[ni][nj];
  return;
}

__global__ void transposeShared(){
  int ni,nj;
  __shared__ float tile[BS][BS];
  ni=blockIdx.y*blockDim.y+threadIdx.y;
  nj=blockIdx.x*blockDim.x+threadIdx.x;
  tile[threadIdx.y][threadIdx.x]=idata[ni][nj];
  __syncthreads();
  ni=blockIdx.x*blockDim.x+threadIdx.y;
  nj=blockIdx.y*blockDim.y+threadIdx.x;
  odata[ni][nj]=tile[threadIdx.x][threadIdx.y];
  return;
}

__global__ void transposeSharedNobankconf(){
  int ni,nj;
  __shared__ float tile[BS][BS+1];
  ni=blockIdx.y*blockDim.y+threadIdx.y;
  nj=blockIdx.x*blockDim.x+threadIdx.x;
  tile[threadIdx.y][threadIdx.x]=idata[ni][nj];
  __syncthreads();
  ni=blockIdx.x*blockDim.x+threadIdx.y;
  nj=blockIdx.y*blockDim.y+threadIdx.x;
  odata[ni][nj]=tile[threadIdx.x][threadIdx.y];
  return;
}


__global__ void transposeSharedNobankconf_nopading(){
  int ni,nj;
  __shared__ float tile[BS][BS];
  ni=blockIdx.y*blockDim.y+threadIdx.y;
  nj=blockIdx.x*blockDim.x+threadIdx.x;
  tile[threadIdx.y][(threadIdx.x+threadIdx.y)%BS]=idata[ni][nj];
  __syncthreads();
  ni=blockIdx.x*blockDim.x+threadIdx.y;
  nj=blockIdx.y*blockDim.y+threadIdx.x;
  odata[ni][nj]=tile[threadIdx.x][(threadIdx.x+threadIdx.y)%BS];
  return;
}


void transpose(float hidata[][njmax],float hodata[][njmax]){
  int ni,nj;
  for(ni=0;ni<nimax;ni++){
    for(nj=0;nj<njmax;nj++){
      hodata[nj][ni]=hidata[ni][nj];
    }
  }
  return;
}

void check_mat(float C[][njmax],float CC[][njmax]){
  int ni,nj,flg;
  flg=0;
  for(ni=0;ni<nimax;ni++){
    for(nj=0;nj<njmax;nj++){
      if(fabs(C[ni][nj]-CC[ni][nj])>1.0e-8){
        flg=1;
        printf("%d %d %lf %lf\n",ni,nj,C[ni][nj],CC[ni][nj]);
      }
    }
  }
  if(flg==1){
    printf("Calculation error.\n");
  }else{
    printf("OK.\n");
  }
  return;
}




float hidata[nimax][njmax],hodata[nimax][njmax],htdata[nimax][njmax];

int main(){
  int ni,nj;
  dim3 grid,blck;
  grid.x=nimax/BS; grid.y=njmax/BS;
  blck.x=BS; blck.y=BS;
  // -- set initial value --
  srand(248309);
  
  
  for(ni=0;ni<nimax;ni++)
  {
    for(nj=0;nj<njmax;nj++)
    {
      hidata[ni][nj]=(float)rand()*1.1;
    }
  }
  
  
  cudaMemcpyToSymbol(idata,hidata,nimax*njmax*sizeof(float));
  transpose(hidata,htdata);
  // -- 1. transpose by simple kernel --
  transposeNaive<<<grid,blck>>>();
  transposeNaive<<<grid,blck>>>();
  cudaMemcpyFromSymbol(hodata,odata,nimax*njmax*sizeof(float));
  check_mat(htdata,hodata);
  
  // -- 2. transpose with shared memory --
  transposeShared<<<grid,blck>>>();
  transposeShared<<<grid,blck>>>();
  cudaMemcpyFromSymbol(hodata,odata,nimax*njmax*sizeof(float));
  check_mat(htdata,hodata);
  
  // -- 3. transpose with shared memory and without bank conflict --
  transposeSharedNobankconf<<<grid,blck>>>();
  transposeSharedNobankconf<<<grid,blck>>>();
  cudaMemcpyFromSymbol(hodata,odata,nimax*njmax*sizeof(float));
  check_mat(htdata,hodata);

  // -- 4. transpose with shared memory and without bank conflict and nopading --
  transposeSharedNobankconf_nopading<<<grid,blck>>>();
  transposeSharedNobankconf_nopading<<<grid,blck>>>();
  cudaMemcpyFromSymbol(hodata,odata,nimax*njmax*sizeof(float));
  check_mat(htdata,hodata);
  

  cudaDeviceReset();
  
  return 0;
}


8192×8192要素の行列の転置をするコードとなっています。

速度比較


normal

1.transposeNaive:シェアードメモリすら使ってない。グローバルメモリへコアレスアクセスできてないので一番遅い
2.transposeShared:シェアードメモリ内でバンクコンフリクト発生
3.transposeNobankconf:パディング入れてるやつ
4.transposeNobankconf_nopading:パディング入れてないやつ


まずグローバルメモリにコアレスアクセスできてない1番、これは論外です。次に2,3のバンクコンフリクトのあるなし、これだけで2倍違うことがわかります。
今回考案した4番は、速度的に3番に劣ってないことがわかります。

で、これは何の役に立つんですか?

さきほど書いたよう、パディング入りは無駄なメモリ領域を確保しています。なのでOccupancyが100%になる前にシェアードメモリ容量が律速になるような問題では以下のようなことが発生します。

1Blockあたり16.25KBのシェアードメモリを使うプログラムの場合
nvvp2

1Blockあたり16.0KBのシェアードメモリを使うプログラムの場合
nvvp

このようにOccupancyが上がることが期待できます。

なお、上記の行列転置のコードのカーネルはすべてOccupancy100%です。(Shared Memoryを使い切る前にOccupancy100%になる)

CUDA Unified Memoryを扱う方法 (CとPyCUDAのコード)

最小限のコードであまり落ちてなかったので自分のメモ用としても

まずはCUDA版(nvccでコンパイルするやつ)
#include <stdio.h>
__global__ void VecAdd(float* A, float* B, float* C) {
	int id = 256 * blockIdx.x + threadIdx.x;
	C[id]=A[id]+B[id];
}

int main() {
	int N = 1024;
	// Allocate 3 arrays on GPU
	float *d_A, * d_B, * d_C;
	cudaMallocManaged(&d_A, N * sizeof(float));
	cudaMallocManaged(&d_B, N * sizeof(float));
	cudaMallocManaged(&d_C, N * sizeof(float));
	// CPU init
	for(int i=0;i<N;i++){
		d_A[i]=d_B[i]=1.0*i;
	}

	//gpu kernel
	VecAdd <<<N/256, 256>>> (d_A, d_B, d_C);
	cudaDeviceSynchronize();//wait

	for(int i = 0;i<N;i++){
		printf("%f ",d_C[i]);
	}
	
	cudaFree(d_A);
	cudaFree(d_B);
	cudaFree(d_C);
	return 0;
}

つぎにPyCUDA版(2020.1)
from pycuda.autoinit import context
import pycuda.driver as cuda
from pycuda.compiler import SourceModule
import numpy as np

mod = SourceModule("""
__global__ void doublify(float *a)
{
    a[threadIdx.x] *= 2;
}
""")
doublify = mod.get_function("doublify")

a = cuda.managed_empty(shape=12, dtype=np.float32, mem_flags=cuda.mem_attach_flags.GLOBAL)
a[:] = np.linspace(0, 11, len(a)) # Fill array on host
doublify(a, grid=(1,1), block=(len(a),1,1))
context.synchronize() # Wait for kernel completion before host access
print(a)
del a #これでガベージコレクションに削除してもらうことで、GPUメモリも解放される

ほんの19行でかけた aにCPUからもGPUからもアクセスできている。

UnityでDispatchIndirectを使う

DispatchIndirectの使い方

UnityでGPGPUその1でDispatch命令の使い方は解説したが、次にDispatchIndirectを使おうと思ったがなんかハマったのでメモ。
これはDispatch命令の引数3つ(x,y,z)をCPUから指定するのではなくGPUのバッファに入れておいてそのGPUバッファを指定する命令。
例えば動的にパーティクルの数が変動していて、GPU側ではパーティクル数を記憶しているのにCPU側では記憶してないときに、わざわざGPU→CPUにデータ転送してDispatch命令をしなくても良くなる。

これも例によっていろいろ調べてなんか中国語のサイトに巡り合い解決した。

https://zhuanlan.zhihu.com/p/72747058


Unityでの実装

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


public class Hoge0 : MonoBehaviour
{
    public ComputeShader shader;

    void Start()
    {
        int[] host_A = { 2, 2, 2 };//これがグループサイズx,y,zに対応
        int[] host_B = new int[16];

        ComputeBuffer A = new ComputeBuffer(host_A.Length, sizeof(int), ComputeBufferType.IndirectArguments);
        ComputeBuffer B = new ComputeBuffer(host_B.Length, sizeof(int));

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

        A.SetData(host_A);
        B.SetData(host_B);

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

        // GPUで計算
        //shader.Dispatch(k, 2, 2, 2);と同じ
        shader.DispatchIndirect(k, A, 0);

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

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


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

CpmputeShader
#pragma kernel CSMain

RWStructuredBuffer<int> B;

[numthreads(1, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
	int idx = id.x + id.y * 2 + id.z * 4;
	B[idx] = idx + 100;
}




2*2*2スレッドが起動しているので答えは100~107の数字が格納されているはず。

aaa


GithubにもUP

これにより、並列数をCPUからでなくGPUから指定することができ、GPUの計算結果によって並列数を動的に変えたい場合にGPU→CPUのブロックをなくすことができる。
注意だが
A = new ComputeBuffer(3, 4, ComputeBufferType.IndirectArguments);

の部分を忘れないように。忘れてもとりあえずプログラム自体は動いてしまうのでバグに気づかない。

ちなみにUnityのリファレンスにもあるようhttps://docs.unity3d.com/ja/2018.4/ScriptReference/ComputeShader.DispatchIndirect.html
第三引数のargsOffsetにあたるところにはbyteで指定しないといけない。だからAがint型でGPUメモリに乗っていればここは4,8,12など4の倍数で指定しないといけない。

UnityでGPGPU応用編 バイトニックソートを高速化

バイトニックソート(Bitonic Sort)の概要

バイトニックソート(Bitonic Sort)は主にGPU等の並列計算器でソートを実装しようとするときに使われるソートである。
計算量のオーダーはO(n log^2 n)であり、クイックソートのO(n log n)には負けるものの並列化による高速化が勝るという感じなのでいろんなところに使われている。

対象読者

キーワード「GPU」「バイトニックソート」で検索してこの記事にたどり着いただろう方が対象。
この記事では
・バイトニックソートでなぜソートできるか
・どうやったら高速化できるか
という点について重点的に書いている。

高速化については
OpenCLでバイトニックソートを実装している海外サイト
パクリ参考にした。
これのComputeShader移植+αかつ日本語資料として、一応価値はあるかと思うのでまとめてみた。

時間がない方は完成版コードの
BitonicSort_ComputeShader

をご参照を。

ソートはソートでも、ただ配列をソートしているのではなく、配列のもとのindexを保持したままソートしている実装なので可読性より実用性重視になっている。

アルゴリズム

wikinoyatuとりあえず英語のwikipediaの画像がわかりやすいので転載してみた。
16要素のソートの場合のアルゴリズムを図示したもののようだ。
左から見ていく。
最初はソートされてない数字が縦に並んでいて、縦の矢印のところで比較、swapが行なわれるという意味らしい。
矢印の方向によって大きい値が矢印の先に移動するといった具合でやると、最終的に一番画面右の時点で上から小さい順に数字が並んでいる状態になる。

なぜソートできるのか

4要素の時

あまり数学的な説明はできないが、自分の思考過程をまとめてみた。
sikou0
まず4要素のソートで考える。(2だと自明すぎるので)
最終的にこのような形にするためには、という考え方をすると後ろから見ていくとよさそうだ。

sikou1

この時点で、上2つは1と3が、下2つは4と6が入ってないと絶対いけない。

つまりこの図の操作(矢印の数でいうと4回)後には、上に小さい方が値2つ、下に大きい方の値2つが振り分けられてないといけないことがわかった。

sikou2

この操作だけでは、1,4,3,6と入力したときに「1,4」「3,6」となり振り分けが不十分だ。

sikou3
やっぱり最初の操作をしないと「1,3」「4,6」の振り分けがうまくいかないようだ。

要素数がもっと大きいとき

それではもっと要素数が大きくなった場合に、この「振り分け」が常にきちんとできているのか気になる。
例えば

sikou4
このように2n個の数字を入力すると、大きい方n個、小さい方n個に分けられて出力されないといけない。
と、ここで入力側は真ん中を境に2方向にソートされていたことを思い出す。

sikou5



Webp.net-gifmaker

このように入力がソートされていれば、16入力のうち小さいものと大きいものを8個ずつに分けることができる。

例えば小さい方の8個というのは、赤の数値を小さい順にみて集めていき、赤と青のお互いの上下がひっくりかえったところで今度は青の数値を集めていけば、それが必ず小さい8個の集合になる。

256入力になったときの図
gifmaker2

この例を見ても確かにうまくふるい分けられている。

Bitonic sequence

では今の現状を整理する。
sikou6

真ん中でソートされているn+nの入力ならば、出力側で上下分離できていることが分かった。
しかし先ほどのgifの出力の「へ」の字みたいなのは必ずしも真ん中でソートされているわけではない。それが次の入力になっているが、はたして大丈夫なのか。

実際にやってみた。

gifmaker3

このようにいびつな形でも上下分離はできるようだ。
今度はルート記号のような形がでてきた。こんなんでも実は上下分離できる。
gifmaker4

実はこの形のことをbitonic sequenceと呼ぶ。
https://en.wikipedia.org/wiki/Bitonic_sorter
wikiitibu

単調増加→単調減少となっている数列で、かつそれが循環シフトしていてもよい。
上の画像のルート記号を縦にしたような形は、よくみると赤の一番上の位置と青の一番下の位置が繋がっている。循環シフトすれば「ヘ」の字のように単調増加→単調減少の形になるはずだ。

結局のところ

バイトニックソートの内部構造を改めてみてみる。wikinoyatu


結局のところバイトニックソートは、bitonic sequenceを入力すると上下分離されたbitonic sequenceが出力され、それが次の入力になって・・・という形が続いているだけである。
なのである範囲内で集合の上下関係を確定させることが可能で、その結果最後はソートされた数列になっているというわけ。

改めて見るとなんだか美しい図に見えてくるなぁ

(余談1:美しいのには理由がありました。FFTバタフライ演算図と流れが酷似しています。)
(余談2:上のgifを作るのためにHSPでプログラムを書いて絵を出力しました。案外1-2時間程度で簡便に作れたので手短にかけるHSPは楽で良いです。)

バイトニックソートのGPU実装

GPU実装。まずどこを並列化できるかを探す。
gpuimpliment0
このようにStep分けすると、各Step内はほぼ同じような処理をしているためここを並列化できそうだ。
Step7を抽出してみると

gpuimpliment1

このように1threadが2点の比較をするように組むとして、あとは自threadがどの2点にアクセスするかの計算コードを書けば良い。

Unityで実装

Unityを起動し
空のGameObjectを作成
下記のMain.csをアタッチしBitonicSort.computeを紐づけ
saisyo


C#(Main.cs)
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;

//keyを配列index付きでソートする
public class Main : MonoBehaviour
{
    struct mystruct
    {
        public float key;//ソートしたい値
        public uint index;//一緒にソートされるindex
    }

    public ComputeShader shader;
    ComputeBuffer gpu_data;
    int kernel_ParallelBitonic;
    mystruct[] host_data;
    const int THREADNUM_X = 64;

    //配列の要素数
    int N = 1 << 19;//下限 1<<7,上限 1<<27

    void kernelfindStart()
    {
        kernel_ParallelBitonic = shader.FindKernel("ParallelBitonic");
    }

    void host_Init()
    {
        for (uint i = 0; i < N; i++)
        {
            host_data[i].key = UnityEngine.Random.Range(0.0f, 1.0f);
            host_data[i].index = i;
        }
    }

    void Start()
    {
        host_data = new mystruct[N];//ソートしたいデータ
        gpu_data = new ComputeBuffer(host_data.Length, Marshal.SizeOf(host_data[0]));

        //カーネル初期設定
        kernelfindStart();

        //CPU側で初期値代入
        host_Init();

        // host to device
        gpu_data.SetData(host_data);

        //ソート実装部分
        BitonicSort(gpu_data);
        gpu_data.GetData(host_data);

        //結果表示
        resultDebug();
    }

    void BitonicSort(ComputeBuffer gpu_data)
    {
        int n = gpu_data.count;
        //引数をセット
        shader.SetBuffer(kernel_ParallelBitonic, "data", gpu_data);

        int nlog = (int)(Mathf.Log(n, 2));
        int lninc, inc;

        for (int i = 0; i < nlog; i++)
        {
            inc = 1 << i;
            for (int j = 0; j < i + 1; j++)
            {
                shader.SetInt("inc", inc);
                shader.SetInt("dir", 2 << i);
                shader.Dispatch(kernel_ParallelBitonic, n / 2 / THREADNUM_X, 1, 1);
                inc /= 2;
            }
        }
    }

    void resultDebug()
    {
        // device to host
        gpu_data.GetData(host_data);

        Debug.Log("GPU上でソートした結果");
        for (int i = 0; i < Mathf.Min(1024, gpu_data.count); i++)
        {
            Debug.Log("index=" + host_data[i].index + " key=" + host_data[i].key);
        }
    }

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


ComputeShader(BitonicSort.compute)
#pragma kernel ParallelBitonic

//構造体と比較を自前で定義する必要あり。
struct Mystruct
{
	float key;
	uint index;
};
#define data_t Mystruct
#define COMPARISON(a,b) ( a.key > b.key )
//ここまで

RWStructuredBuffer<data_t> data;
int inc;
int dir;

[numthreads(64, 1, 1)]
void ParallelBitonic(uint threadid : SV_DispatchThreadID)
{
	int t = threadid; // thread index
	int low = t & (inc - 1); // low order bits (below INC)
	int i = (t << 1) - low; // insert 0 at position INC
	bool reverse = ((dir & i) == 0); // asc/desc order

	// Load
	data_t x0 = data[i];
	data_t x1 = data[inc + i];

	// Sort
	bool swap = reverse ^ COMPARISON(x0, x1);
	data_t auxa = x0;
	data_t auxb = x1;
	if (swap) { x0 = auxb; x1 = auxa; }

	// Store
	data[i] = x0;
	data[inc + i] = x1;
}

結果

kekka

Key(float型)が大きい順にソートされている。

ソートする前のindexの情報も含んだ構造体でソートしているので、indexも一緒にソートできている。

この構造体はmystructで定義しているので一応自由に変えられる。
C#のmystructを変えたらComputeShader側のmystructも変える必要あり。

また、使用の注意点としてはソースコードにもあるよう、2のべき乗の要素数でしかソートできない。もし端数なら、-∞や+∞で数値を埋めておいてソート結果に影響しないようにしておく必要がある。

速度計測

単位はms
要素数/カーネル名B2
2560
5120
10240
20480
40960
81920
163840
327680
655361
1310721
2621442
5242884
104857610
209715219
419430439
838860883
16777216179
33554432382
67108864818
1342177281744
元の記事を参考にしているのでこのB2というのはカーネル名。
(1threadあたり2点読み込んでいるためと思われる。)

高速化Tips

これでも十分速いけど高速化はロマン!

コアレスアクセス

UnityでGPGPUその2
でさんざん書いてきたよう、コアレスアクセスは速い。上のコードではすでにコアレスアクセスはできているので特段変える必要はない。
corelessacs


改めて図示するとこんな感じに、同じwarp(wavefront)が実行するカーネルはなるべく連続したメモリアドレスにアクセスするようにする。

Shared Memoryを使う

今までのコードは、1threadがグローバルメモリの2か所を読み取って比較し、適宜swapした後またメモリに書き戻す、という作業をしていた。このグローバルメモリはいくらコアレスアクセスができてもなお遅いので可能な限りグローバルメモリを介さず計算したいと考える。
そこでShared Memoryを使えないかと考える。1Gruopあたり16threadだとするとその16threadはshared memoryを共有できる。

バイトニックソートのアルゴリズム図をもっと遠くからみた図
shared0

各Stepで結果をグローバルメモリにいちいち書き戻さなくても、shared memoryに書き込んで使いまわしていくことで、グローバルメモリにアクセスするのはカーネルの最初と最後だけにすることができる。

shared1

この青のところが全部shared memoryで高速化できるところだ!
ここでは図示しやすいように1Group=16threadで書いているが、実際は1Group=64threadや1Group=128threadで実装している。

これをやることで
要素数/カーネル名B2B2C2
25600
51200
102400
204800
409600
819200
1638400
3276800
6553610
13107211
26214421
52428843
1048576108
20971521911
41943043924
83886088351
16777216179109
33554432382236
67108864818508
13421772817441095

1.5-6倍速くなった!


2Step同時計算

上図のピンク領域はShared Memoryにおさまりきらないので、従来のグローバルメモリに読み書きする方式でやっている。今度はそこを高速化しようという話。
従来はthread0~thread7がそれぞれ2点ずつ読み込んで、という処理だったが

gpuimpliment2

今度はthread0~thread3がそれぞれ4点のメモリを読み込んで2Step分まとめて計算しようという発想だ。

従来の方法だと
Step7を計算するのに必要なメモリアクセス回数が、Load16回+Store16回、そしてStep8を計算するのにLoad16回+Store16回で合計64回の読み書きが発生していた。
しかし2Step同時計算をすることでLoad16回+Store16回で合計32回の読み書きで済む。

こうしてグローバルメモリのアクセス回数を減らしていくことで高速化をしていく。
要素数/カーネル名B2B2C2B2B4C2
256000
512000
1024000
2048000
4096000
8192000
16384000
32768000
65536100
131072110
262144211
524288432
10485761086
209715219119
4194304392419
8388608835138
1677721617910977
33554432382236163
67108864818508345
13421772817441095735

最初から2倍以上速くなった!

n Step同時計算

ではStepを3つや4つまとめて計算すればもっと良いのではという発想が出るのは自然なことだ。
しかし4つまとめて、とやると1threadあたりの作業量が16点になり、5つで32点・・・と指数的に増えていってしまう。さすがにレジスタの容量も限られているので4Stepまとめくらいが限度だろう。
OpenCLのサイトの実装も4Stepまでであった。
要素数/カーネル名B2B2C2B2B4C2B2B4B8C2B2B4B8B16C2
25600000
51200000
102400000
204800000
409600000
819200000
1638400000
3276800000
6553610000
13107211000
26214421111
52428843222
1048576108665
209715219119118
41943043924191616
83886088351383333
16777216179109776863
33554432382236163139127
67108864818508345292271
13421772817441095735614556

効果はかなりあるようだ。


ここまでの高速化Tipsを全て踏まえ、さらにShared memory内の計算にも2Step同時計算を適応したコードを適応すると
要素数/カーネル名B2B2B4B8B16C2B2B4B8B16C2C4
256000
512000
1024000
2048000
4096000
8192000
16384000
32768000
65536100
131072100
262144211
524288422
10485761055
20971521987
4194304391614
8388608833329
167772161796360
33554432382127127
67108864818271264
1342177281744556552

これはあまり速くならなかったが気持ち少し速くなっている!

ともあれ最初と比べ3.16倍になった。高速化は大成功!!

実行メモリバンド幅測定

このベンチマークをとったのはRTX2080Ti。このGPUのメモリのバンド幅は616GB/s。
B2B4B8B16C2C4カーネルで要素数最大の134217728でやった場合のメモリアクセス量は82回*2回×
134217728*8byte=176.1GB
これを1秒あたりに直して319GB/sの実行メモリバンド幅とわかる。

約51.8%の性能が出せている。個人的には十分だと思う。

行列転置で全範囲Sared memory化

ここからは自分オリジナルの案になるが、結論から言うと速くならなかった。
発想としてはFFTのブロック化の案(参考pdf)を使うことで、メモリアクセスの局所化をはかる。

sikou7

これを

sikou8
こうする。
なお、書いてある数字はメモリの中身ではなくメモリアドレスだと思ってほしい。
このアルゴリズム図はメモリレイアウトが変わっただけで、今までと全く同じことをしている。
違うのは各Stepを計算するとき、メモリアクセスが4領域内にまとめられているということ。
画像でいうと最初の処理で(0,4,8,12)、(1,5,9,13)、(2,6,10,14)、(3,7,11,15)にまとめてアクセスしており、後半処理では(0,1,2,3)、(4,5,6,7)、(8,9,10,11)、(12,13,14,15)にまとめてアクセスしている。この「まとまっている」がコアレスアクセスに相当する。
最初の処理では(0,4,8,12)と飛び飛びだからコアレスアクセスできてないと思うかもしれないが、事前に行列転置をしておくことで可能になる。
GPUを使った行列転置は記事にしたのでこれを参考に(パディングなしでバンクコンフリクトを解消する方法 CUDA 行列転置)
最初0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15の順だったのを0,4,8,12,1,5,9,13,2,6,10,14,3,7,11,15に効率的に配置できる。同じように
0,4,8,12,1,5,9,13,2,6,10,14,3,7,11,15から0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15へも効率的に配置できる(これは画像の通り)。
この行列転置はShared memoryを使うことで読み書きともにコアレスアクセスにできる。

これで何が嬉しいかと言うと、今までShared memoryに入りきらなかったStepの計算もすべて
Shared memoryを使いつつコアレスアクセスにできることである。
イメージとしてはこうなる。

shared2

Shared memoryを使った計算では8Stepまとめて計算できるので、今までの4Step+4Stepの処理が1つにまとまる。しかしその分行列転置の処理が入り込むので結局メモリアクセス量はたいして変わらずかむしろ増えたようで、Totalの速度ではむしろ1.2倍くらい遅くなってしまった。


なお
Shared memoryの容量的にまだ余裕があったので10Stepまとめて計算するコードに変えてみたが意外と速くならなかった。なかなか高速化は難しい・・・。

Shared memoryだけ使わないと

環境によってはShared memoryが使えないという状況もあると思い追加で速度測定

B2B4B8B16というやつ

要素数/カーネル名B2B2C2B2B4B8B16C2C4B2B4B8B16
2560000
5120000
10240000
20480000
40960000
81920000
163840000
327680000
655361000
1310721100
2621442111
5242884321
104857610854
2097152191179
419430439241418
838860883512936
167772161791096073
33554432382236127154
67108864818508264322
13421772817441095552670

1.2倍ほど遅くなったがこれでも十分すぎるほど速いのではないか。

参考文献



【Qiita】Compute Shaderでのソートアルゴリズムの処理時間比較

【Qiita】ComputeShaderで近傍探索の高速化

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



プロフィール

toropippi

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

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