GPGPUについて
GPGPUとはGPUを使って大規模な計算を並列化しようという試みです。
これのおかげでAIが飛躍的に進歩したみたいです。
CUDAというNvidiaが出してるフレームワークが一番有名かもしれません。
ゲーム業界ではDirectXを使ったコンピュートシェーダーが有名ですね。
今回はGPGPUをC#で行っていきます。
CUDAのめんどくささについて語りたい
GPGPUの主流であるCUDAなのですが、ハードウェアに最適化されててすごく高速みたいなのですが、Nvidia製のGPUを積んでるPCでしか使えないんですよね。
あとCUDAを使うにあたり、いろいろなドライバーをインストールしなくてはいけません。
この作業がとてもめんどくさいのです。
なので自分はこのめんどくささが軽減されるコンピュートシェーダーやDirectMLが好きです。
正直ある程度の速度が出せれば十分ですしね。
ComputeShapを使ってGPGPUを簡略化しよう
今回C#でGPGPUを実装するにあたり、考えらるのはコンピュートシェーダーを使うことです。
しかしこのコンピュートシェーダー、シェーダーとあるようにhlslを記述し、シェーダーを書かなくてはならず、C#のみの実装はできません。
そこで登場したのがComputeSharp
になります。
これはC#でおなじみのソースジェネレーターをうまく生かし、C#コードからシェーダーをコンパイルしてGPGPUを実現しようというものです。
シェーダーをコンパイルする機能なので、コンピュートシェーダーではない、ピクセルシェーダーなんかも作れるのが、ほかの言語であるようなGPGPUライブラリとは一線を画しているところなのではないでしょうか。
あとやっぱりC#ってほかの言語よりも可読性高いので、ライブラリ特有の機能でなんじゃこれ?みたいなコードにならないのはいいですよね。
GPU計算
今回GPU計算を行う例として、ランダムに生成した2048×2048の行列を二つ作り、その行列を掛け合わせるという、行列積をやってみたいと思います。
ComputeShader
をNugetして作成ます。
GPUで処理する部分は以下のようになります。
[ThreadGroupSize(DefaultThreadGroupSizes.XY)]//スレッドグループのサイズを指定します。行列なのでX*Yとしました。DefaultThredGroupSizesで何も考えなくてもいいのはありがたい
[GeneratedComputeShaderDescriptor]//ソースジェネレーターでシェーダーを作りますよのアトリビュート
// ソースジェネレーターを作るのでpartial。IComputeShaderを実装します。
public readonly partial struct MatrixMultiplicationKernel : IComputeShader
{
// フィールド読み込みのみの場合はReadOnly,書き込むものについてはReadWriteとすること。
// この他ReadOnlyTexture2D<T>とかあってシェーダーみたいでおもろいです。
public readonly ReadOnlyBuffer<float> A;
public readonly ReadOnlyBuffer<float> B;
public readonly ReadWriteBuffer<float> C;
public readonly int N;
// コンストラクタ。本体C#のメソッド側から呼ばれます。
public MatrixMultiplicationKernel(ReadOnlyBuffer<float> a, ReadOnlyBuffer<float> b, ReadWriteBuffer<float> c, int n)
{
// 本体C#側から受け取った情報をフィールドに入れる
A = a;
B = b;
C = c;
N = n;
}
// この処理が実行されます。
public void Execute()
{
// ThreadIdsからスレッドIDが取得できるので、並列で処理するときはこれを使う
int row = ThreadIds.X;
int col = ThreadIds.Y;
float sum = 0;
for (int k = 0; k < N; k++)
{
// 担当した部分だけの行列の値を取得している。
sum += A[row * N + k] * B[k * N + col];
}
// 担当行列の部分に入れ込む
C[row * N + col] = sum;
}
}
void Execute()
がIComputeShader
の実装に必要な関数となっていてこの中の処理を行います。
もちろん普通のシェーダーのように関数を外出ししてもEcecute()
の中で呼ばれてれば問題ないですね。
こういうシェーダーをC#っぽくかけるのはなかなか革命なんじゃないかと思います。
今回はfloat[N,N]のマトリックスではなく、float[N*N]で表現しています。
呼び出し
上記で作成したGPGPU処理を呼び出すには以下を行います。
using ComputeSharp;
using ComputeSharpTest;
using System.Diagnostics;
// 行列の縦横の長さこれは2048×2048という行列という意味
int N = 2048;
// ランダムで作成された2048×2048の行列matrixAを作成
float[] matrixA = SetMatrix(N);
//Console.WriteLine("matrixA :");//どんな行列か見るように作ったが、サイズがでかいと馬鹿みたいになるのでコメントアウト
//for(int i = 0; i < N; i++)
//{
// for(int j = 0; j < N; j++)
// {
// Console.Write($"{matrixA[i * N + j]:F2}");
// }
// Console.WriteLine();
//}
// ランダムで作成された2048×2048の行列matrixBを作成
float[] matrixB = SetMatrix(N);
//Console.WriteLine("matrixB :");
//for (int i = 0; i < N; i++)
//{
// for (int j = 0; j < N; j++)
// {
// Console.Write($"{matrixB[i * N + j]:F2},");
// }
// Console.WriteLine();
//}
// 空の2048×2048の行列matrixBを作成(結果取得用)
float[] matrixC = new float[N * N];
// 時間を測ります
Stopwatch sw = new Stopwatch();
Console.WriteLine($"計測開始");
sw.Start();
// C#のfloat[]をGPUで使えるようにbufferに変換します。GPU用にメモリを移し替えてる的な
// メモリアロケーターでここでしか使わないので`using`を使うこと使わないとメモリリーク
using (var bufferA = GraphicsDevice.GetDefault().AllocateReadOnlyBuffer(matrixA))
using(var bufferB = GraphicsDevice.GetDefault().AllocateReadOnlyBuffer(matrixB))
using(var bufferC = GraphicsDevice.GetDefault().AllocateReadWriteBuffer<float>(matrixC.Length))
{
// これで上記で作ったコンピュートシェーダーを呼び出す(x,y,shader)x*y回MatrixMultiplicationKernelを実行
// コンストラクタには上記シェーダーに書いた引数を入れ込む
GraphicsDevice.GetDefault().For(N, N, new MatrixMultiplicationKernel(bufferA, bufferB, bufferC, N));
// 参照渡しのため、与えたbufferCに値が入れられる。それをmatrixCにコピーする
bufferC.CopyTo(matrixC);
}
sw.Stop();
// コピーされたmatrixCを確認。馬鹿みたいに数が多いのでコメントアウト
//Console.WriteLine("Result Matrix C:");
//for (int i = 0; i < N; i++)
//{
// for (int j = 0; j < N; j++)
// {
// Console.Write($"{matrixC[i * N + j]} ");
// }
// Console.WriteLine();
//}
// 処理時間を表示
Console.WriteLine($"経過時間: {sw.Elapsed}");
Console.WriteLine($"経過時間(ms): {sw.ElapsedMilliseconds}");
// ランダムな数値の[size*size]の行列を作る(matrixA,matrixB)を作るのに使っている
float[] SetMatrix(int size)
{
var matrix = new float[size * size];
Random rando = new Random();
for(int i = 0;i < size;i++)
{
for(int j = 0;j < size;j++)
{
matrix[i * N + j] = (float) (rando.NextDouble() * 10);
}
}
return matrix;
}
ランダムな行列作成もComputeSharpでやろうかと思いましたが、ChatGPTによるとCPUでやった方が早いとのことなのでCPU側で作成してます。
だいたいこれで157msとかでした。
3×3や100×100のときも140ms前後だったので、計算処理よりもCPUからGPUにメモリを渡すところがオーバーヘッドみたいですね。
いかにもなGPU処理っぽい結果がでました。
まとめ
ComputeSharp
を使えばCUDAを使わずともGPGPUができるようになります。
めちゃくちゃお手軽で、C#にもhlslにも寄り添った書き味で面白いです。
今後はフーリエ変換だったり、AIとかで使われる計算も試してみたいですね。