SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

DeveloperZine(デベロッパージン)- エンジニアの意思決定を支える技術情報メディア ProductZine

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

特集記事

CUDA:「超並列コンピューティング」はじめました


配列の足し算

 すこしだけ"からくり"の説明をしておきますか。

 ご存知のとおり、ビデオカードはマザーボード上のPCIコネクタにがっちゃんこします。PCIを境にしてCPU側(CPUとメインメモリ)を"ホスト"、ビデオカード側(GPUとビデオメモリ)を"デバイス"と呼び、プログラムはホスト側のmainを起点として、ホスト側関数がデバイス側関数を呼び出すことで処理を行います(デバイス側からはホスト側の関数を呼び出すことはできません)。初期状態ではデバイス側にはコードもデータもありません。デバイス側コードはホスト側に埋め込まれていて、あらかじめCUDAランタイムがデバイスに転送し、呼び出されるのを待っています。デバイス側コードの実行は、

  1. 処理対象となる入力データをホストからデバイスに転送し
  2. デバイス側コードを呼び出し
  3. デバイス側コードの処理完了を待って
  4. デバイス側に得られた出力データ(結果)をホストに転送する

というダンドリになります。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をざっくりと眺めてみましょう。

list01 : kernel.cu(1/3)
#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側で面倒をみてくれます。

list02 : kernel.cu(2/3)
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を呼び、結果を出力するだけ。

list03 : kernel.cu(3/3)
// 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が直前のデバイス側の処理が終わるのを待っててくれますから。

次のページ
行列のかけ算

この記事は参考になりましたか?

特集記事連載記事一覧

もっと読む

この記事の著者

επιστημη(エピステーメー)

C++に首まで浸かったプログラマ。Microsoft MVP, Visual C++ (2004.01~2018.06) "だった"りわんくま同盟でたまにセッションスピーカやったり中国茶淹れてにわか茶...

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

CodeZine(コードジン)
https://codezine.jp/article/detail/8429 2023/09/25 15:03

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー