配列の足し算
すこしだけ"からくり"の説明をしておきますか。
ご存知のとおり、ビデオカードはマザーボード上のPCIコネクタにがっちゃんこします。PCIを境にしてCPU側(CPUとメインメモリ)を"ホスト"、ビデオカード側(GPUとビデオメモリ)を"デバイス"と呼び、プログラムはホスト側のmainを起点として、ホスト側関数がデバイス側関数を呼び出すことで処理を行います(デバイス側からはホスト側の関数を呼び出すことはできません)。初期状態ではデバイス側にはコードもデータもありません。デバイス側コードはホスト側に埋め込まれていて、あらかじめCUDAランタイムがデバイスに転送し、呼び出されるのを待っています。デバイス側コードの実行は、
- 処理対象となる入力データをホストからデバイスに転送し
- デバイス側コードを呼び出し
- デバイス側コードの処理完了を待って
- デバイス側に得られた出力データ(結果)をホストに転送する
というダンドリになります。1と4では大量のデータがPCI-bus越しに行き来しますから、何度も繰り返すと転送時間がパフォーマンスを落とします。実装次第では"ちっとも速くならない"とがっかりすることになるのでご注意を。
それではVisual StudioでのCUDAプログラミングを始めましょう。CUDA ToolkitをインストールするとVisual Studioにいくつかのプラグインが埋め込まれ、ソリューションにプロジェクトを追加する際にプロジェクト・テンプレート:CUDA Runtimeが加わっています。これがCUDAを使ったコンソール・アプリケーションのひな型となります。ビルドしてできあがるのは2つの配列の要素どうしの和:c[i] = a[i] + b[i]をCUDAで求めるもので、ひとまずCUDAがちゃんと動くことの確認に使えます。
このテンプレートで生成されたひな型プロジェクトには「kernel.cu」というソースコードが1つだけ、ぽつんと作られます。拡張子.cuのついたコードはホスト側とデバイス側の両方のコード(どちらもC++)が混在したもので、CUDA Toolkitに含まれるNVIDIA製のコンパイラ:nvccでコンパイルされます。nvccは.cuをホスト側とデバイス側とに(内部的に)分離し、デバイス側をnvcc自身、ホスト側をVisual C++でコンパイルします。双方が吐いたバイナリは一本の.objにマージされます。リンク時にCUDAランタイムをリンクしてデバイス側バイナリの埋め込まれた.exeのできあがりです。
ひな型コード:kernel.cuをざっくりと眺めてみましょう。
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include <stdio.h>
cudaError_t addWithCuda(int *c, const int *a, const int *b, unsigned int size);
__global__ void addKernel(int *c, const int *a, const int *b)
{
int i = threadIdx.x;
c[i] = a[i] + b[i];
}
__global__のついた関数(addKernel)は"ホストから呼び出すことのできるデバイス側関数"です(戻り値を返すことができずvoidでなくてはなりません)。配列のたし算なんだから複数の要素を足し合せなきゃいけないのに、このコードは要素1つ分の加算になってます。CUDAではこの関数を実行するスレッドを要素の数だけ起こして一気に処理します。複数のスレッドはWarpと呼ばれるスレッド群に小分けにされ、WarpがCUDAコア群に投げ込まれて同時に実行されます(SIMT:Single Instruction Multiple Threadと呼んでいます)。これを繰り返し、全Warpの実行が完了すればオシマイ。コード内にあるthreadIdx.xが各スレッドに振られた通し番号です。つまり第0スレッドがc[0] = a[0] + b[0]、第1スレッドが c[1] = a[1] + b[1]……することで配列の全要素にたし算を行うというわけ。
CUDAでは数千以上のスレッドを一気に実行することで高パフォーマンスを発揮します。CUDAコア数は着火するスレッド数より少ないのですが、CUDAコアにスレッドを割り当てるスケジューリングはGPU側で面倒をみてくれます。
int main()
{
const int arraySize = 5;
const int a[arraySize] = { 1, 2, 3, 4, 5 };
const int b[arraySize] = { 10, 20, 30, 40, 50 };
int c[arraySize] = { 0 };
// Add vectors in parallel.
cudaError_t cudaStatus = addWithCuda(c, a, b, arraySize);
if (cudaStatus != cudaSuccess) {
fprintf(stderr, "addWithCuda failed!");
return 1;
}
printf("{1,2,3,4,5} + {10,20,30,40,50} = {%d,%d,%d,%d,%d}\n",
c[0], c[1], c[2], c[3], c[4]);
// cudaDeviceReset must be called before exiting in order for profiling and
// tracing tools such as Nsight and Visual Profiler to show complete traces.
cudaStatus = cudaDeviceReset();
if (cudaStatus != cudaSuccess) {
fprintf(stderr, "cudaDeviceReset failed!");
return 1;
}
return 0;
}
mainはどうということのないコード。入力となる配列a,bおよび結果の受け皿となる配列cを用意してaddWithCudaを呼び、結果を出力するだけ。
// Helper function for using CUDA to add vectors in parallel.
cudaError_t addWithCuda(int *c, const int *a, const int *b, unsigned int size)
{
int *dev_a = 0;
int *dev_b = 0;
int *dev_c = 0;
cudaError_t cudaStatus;
// Choose which GPU to run on, change this on a multi-GPU system.
cudaStatus = cudaSetDevice(0);
// エラーチェック: 失敗したら Errorへ
if (cudaStatus != cudaSuccess) {
fprintf(stderr, "cudaSetDevice failed! Do you have a CUDA-capable GPU installed?");
goto Error;
}
// 3つの配列領域(2入力/1出力)をGPU側に確保
cudaStatus = cudaMalloc((void**)&dev_c, size * sizeof(int));
// エラーチェック(省略)
cudaStatus = cudaMalloc((void**)&dev_a, size * sizeof(int));
// エラーチェック(省略)
cudaStatus = cudaMalloc((void**)&dev_b, size * sizeof(int));
// エラーチェック(省略)
// 2つの入力データ(配列)をホストからデバイスへ転送
cudaStatus = cudaMemcpy(dev_a, a, size * sizeof(int), cudaMemcpyHostToDevice);
// エラーチェック(省略)
cudaStatus = cudaMemcpy(dev_b, b, size * sizeof(int), cudaMemcpyHostToDevice);
// エラーチェック(省略)
// カーネル(デバイス側コード)を呼び出す(1要素につき1スレッド)
addKernel<<<1, size>>>(dev_c, dev_a, dev_b);
cudaStatus = cudaGetLastError();
// エラーチェック(省略)
// カーネルの終了を待つ
cudaStatus = cudaDeviceSynchronize();
// エラーチェック(省略)
// 結果(配列)をデバイスからホストへ転送
cudaStatus = cudaMemcpy(c, dev_c, size * sizeof(int), cudaMemcpyDeviceToHost);
// エラーチェック(省略)
Error:
cudaFree(dev_c);
cudaFree(dev_a);
cudaFree(dev_b);
return cudaStatus;
}
関数addWithCudaが実際にデバイス側コード:addKernelを呼び出します。cudaMallocでデバイス側に領域(2つの入力と1つの出力)を確保し、cudaMemcpyでホスト側にある2つの配列をデバイス側にコピーします。しかるのち、addKernel<<<1, size>>>(dev_c, dev_a, dev_b);でデバイス側にある__global__なaddKernelを呼び出します。<<<と>>>に囲まれた2つの値はそれぞれブロック数/ブロックあたりのスレッド数で、ここでは"ブロックあたりsize個のスレッドを1ブロック分"ってことです。デバイス側関数の呼び出しは非同期で、デバイス側で処理が完了するのを待たずに返ってきます。完了するまで待つのが cudaDeviceSynchronize。これを抜ければ結果が得られていますから、cudaMemoryでデバイス側の(結果が詰まった)領域をホスト側にコピーし、cudaFreeでデバイス側領域を解放してオシマイ。
addKernel呼び出し後のcudaDeviceSynchronize()は、実は不要です。それに続くcudaMemcpyが直前のデバイス側の処理が終わるのを待っててくれますから。

