新年の休みもとうに終わり、皆さんどうお過ごしだろうか。
ちなみに主は年末読む予定であった本や論文を全く消化できずに今に至ってしまった。
年末年始は時間があったので、ひたすら読んでいたはずなのになぜだ。。_| ̄|○
読むのが遅いのがいけないのか?翻訳するのが遅いのか??それともそれをまとめるのに時間がかかっているのか。
おそらく全て当てはまるが、時間がかかるものは仕方がないので、少しずつ慣れていくしかないなー。。(-_-;)
余談だが、年末にブログ執筆環境を再構築した。
今まではwebエディタのClasseurを利用していたが、やはり純粋なWebアプリケーションなのでレンダリングやアップデートに問題があった。そこで、今までも利用していたAtomを少しカスタマイズして試してみたが、どうにも動作が重くかつ、vim-modeがいい感じにならずに残念に思っていたところ、vscodeのことを思い出した。どちらもエレクトロンベースだが、vscodeはAtomより全然軽く、markdownプラグインも豊富にあるので、すぐにmarkdown+mathjax環境を構築することができた。いやはや、世の中は便利になったものだ(^ω^)
せっかくなので、下に執筆環境のスクリーンショットを自慢げに貼ってみようと思う。(markdownは頻繁に見る必要はないので、普段はプレビューは別のタブで開いている)
もしかして、こういうことしてるから時間がかかるのかな??/(^o^)\
それでは、本題に戻ろう。
今回はGPU支える技術の第5章だ。4章と同じく楽しみにしていた章なのでじっくり読んでいきたい。
なお今回も長い章なので、前半と後半に分けてまとめと感想を書いていく。
第5章 GPUプログラミングの基本[前半]
GPUの超並列プロセッサでプログラムを実行するには、超並列で実行できようなプログラムの記述に適したプログラミング言語が求められる。また、これらはGPU側メモリへのデータの送信や計算後のデータ読み出し、同期処理などの計算以外の処理も必要とする。
上記のようなGPUプログラミング言語はいくつか存在する。ハードウェアに近いレベルの命令記述(PTX, SPIL-V)やよく知られているCUDAとOpenCL、C言語をディレクティブで拡張できるOpenACCやOpenMP等だ。
GPUの互換性の考え方
ハードウェアを開発する場合、従来の命令への互換性を保ったまま、新しい命令を追加する「上位互換」という考え方が一般的に用いれられる。
ハードウェアの互換性、機械語命令レベルの互換性
CPUの世界ではIntelとAMDのCPUは命令互換なので同じ機械命令を用いたプログラムであれば、どちらの上でも同様に動作する(もちろん各ベンダ固有の命令もある)。もちろん、IBM POWERやARMプロセッサの上ではこれらのプログラムは動作しないが、中にはAndroidのような仮想マシン技術を利用して、異なるハードウェアを用いた互換性を実現しているものもある。
一方でGPUは各社でまちまちであり、世代が変わっただけでプログラムの互換性が亡くなってしまう。
NVIDIAの抽象化アセンブラPTX
NVIDIAはその実際の機械語命令を公開する代わりに、PTXという命令セットを公開している。これはGPUドライバによって機械語に変換される。PTXはこれまでの全ての命令を保存し、新しい機能の追加に関しては上位互換で拡張を行う。しかし、GPUプログラミングに於いて直接PTXが利用されることは殆どなく、CUDAが用いられる。一方でAMDは機械語命令を公開している。
GPU言語レベルの互換性
CUDAはNVDIA GPUを前提に作られているため、他のGPUでは利用できない。従って、Khronous Gruupを中心にOpenCL言語が開発された。OpenCLはNVIDIA GPUでもサポートされており、業界標準のプログラミング言語となっている(現在の最新バージョンは2.2)。
CUDA
1章で説明したように、NVIDIAがG80 GPUと同時に公開したC言語の拡張。ちなみに「Compute Unified Device Architecture」の略。
C言語拡張
CPTとGPUのメモリが別れていることは既に説明したが、CUDAではこの指定を修飾子で行うことができる。これらは、__global__
, __host__
, __device__
の3種類がある。
-
global
デバイスで実行されるが、ホストCPUから直接呼び出される関数 -
device
デバイスで実行され、呼び出しもデバイスから行われる。 -
host
ホストCPUで実行される。呼び出しはホストからのみ可能。
メモリ構造
GPUのメモリは以下の5つの種類に分けられて利用される。
メモリ名 | 説明 | 搭載メモリ |
---|---|---|
グローバルメモリ | 全てのスレッドで共有される | デバイスメモリ |
コンスタントメモリ | 全てのスレッドで共有される定数を格納する | デバイスメモリ |
テクスチャメモリ | テクスチャパターンを記憶する | 数デバイスメモリ |
シェアードメモリ | スレッドブロック単位で割り当てられる | 高速SRAM |
プライベートローカルメモリ | スレッドブロック単位で割り当てられ、他のスレッドからはアクセスできない | デバイスメモリ |
出展: 「GPUを支える技術」,p176,Hisa Ando,2017
メモリ領域の修飾子により、そのデータがどのメモリに配置されるかが決まる。例えば、__device__
で指定されたらデバイスメモリとなる。__global__
を付ける、または指定がない場合はグローバルメモリに置かれる。__device__
__constant__
とするとデバイスメモリの定数領域に置かれ、__device__
、__shared__
とすると、シェアードメモリに置かれる。ここに置かれたデータは異なるSM間では利用できない。また、各スレッドが必要とするautomatic変数は通常はGPU内のレジスタにおかれ、その容量が大きい場合等はローカルメモリに置かれる。
変数
C言語と同様に、char
, short
, int
, long
, longlong
, float
, double
がある。また、それぞれ符号なしの uchar
, ushort
, uint
, ulong
, ulonglong
がサポートされている。
int3
などどするとベクトル変数となる。これは3つの連続した4バイト整数のことである。なお、インデックスは1〜4、長さは16バイト以下に制限され、2, 4, 8, 16バイトの境界でアラインされる。uint3の特殊化でdim3という指定も存在する。
上記の他に、スレッドが自信の位置を知るための組み込み変数として、 gridDim
, blockIdx
, blockDim
, threadIdx
等が定義されている。
データ転送
計算に必要なデータをGPUに転送したり、受け取ったりするために、ホストプログラムは以下のような処理ステップで記述されます。
- ホスト側にカーネルへのデータの入出力となる領域の確保:
malloc()
- デバイス側に入力用のデバイスメモリの確保:
cudaMalloc()
- デバイス側に出力用のデバイスメモリの確保:
cudaMalloc()
- ホスト側からデバイス側に入力データをコピー:
cudaMemcpy()
- カーネルを起動して計算を実行
- 計算結果をデバイス側からホスト側にコピー
- 使い終わったメモリを開放:
free()
&cudaFree()
具体的には次のようなソースコードで表される。
// Device code __global__ void VecAdd(float* A, float* B, float* C, int N) { int i = blockDim.x * blockIdx.x + threadIdx.x; if (i < N) C[i] = A[i] + B[i]; } // Host code int main() { int N = ...; size_t size = N * sizeof(float); // Allocate input vectors h_A and h_B in host memory float* h_A = (float*)malloc(size); float* h_B = (float*)malloc(size); // Initialize input vectors ... // Allocate vectors in device memory float* d_A; cudaMalloc(&d_A, size); float* d_B; cudaMalloc(&d_B, size); float* d_C; cudaMalloc(&d_C, size); // Copy vectors from host memory to device memory cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice); cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice); // Invoke kernel int threadsPerBlock = 256; int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock; VecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N); // Copy result from device memory to host memory // h_C contains the result in host memory cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost); // Free device memory cudaFree(d_A); cudaFree(d_B); cudaFree(d_C); // Free host memory ... }
出展: http://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#device-memory
行列積を計算するCUDAプログラム
N行N列の3つの行列A, B, Cがある時、その行列積は以下のCUDAコードで計算できる。以下の場合、1つのカーネルはC[i][j]
という1つのデータだけを計算する。このサンプルコードはN × Nのスレッドを含んだスレッドブロックを1つ起動させ、それらの行列要素の計算を行う。
__global__ void MatMul(float A[N][N], float B[N][N], float C[N][N]) { int i = blockDim.x * blockIdx.x + threadIdx.x; int j = blockDim.y * blockIdx.y + threadIdx.y; for(int k=0; k<N; k++) C[i][j] += A[i][k] + B[k][j]; } int main() { ... // N * N * 1スレッドの1つのブロックを起動 int numBlocks = 1; dim3 threadsPerBlock (N, N); MatMul<<<numBlocks, threadsPerBlock>>>(A, B, C); ... }
出展: 「GPUを支える技術」,p183,Hisa Ando,2017
4章で説明したように、1つのスレッドブロック内のスレッドは全て同じSMで実行されるため、上記のコードは1つのSMのみしか利用することができない。また、1つのスレッドブロックには最大1024個のスレッドしかNには入れられないという制限もある。従って、本書では行列演算をブロック化して複数スレッドブロックで動かすことで効率化している。ブログには掲載しないが、気になる人は実際に本を参照してほしい。
CUDA数学ライブラリ
CUDAは標準の数学ライブラリの他にIntrinsic Functionsというライブラリを持っている。これは、IEEE準拠の標準ライブラリと比べると少し誤差が大きいが、高速に演算を行うことができる。NVIDIAはその他にも以下のようなライブラリを揃えている。
- cuBLAS: 行列、ベクトル演算
- cuSPARSE: 疎行列演算
- cuFFT: FFTを行う
- cuDNN: ディープラーニング
GPUのコンピュート能力
NVIDIAはGPUの持つSMの性能をコンピュート能力という指標で表している。これは各数値によって、SMを構成するプロパティが分かるというものである。各GPUのコンピュート能力を知りたい方はこちらを参照されたい。
CUDAプログラムの実行制御
CUDAにはデータ転送と計算を並行して行えるようにするstream
という危機構も提供されている。このため複数のカーネルを同時に実行する事ができるGPUの場合、そのオーバーラップにより全体的な計算時間を短縮する事ができる。
また、CUDAのプログラムはインオーダ形式で実行されるが、異なるワープ(32スレッド)間では実行の順番は決まっていない。これらはタイミングによって結果が変わってしまう計算(メモリアクセスを利用する関数など)を制御するのに重要であるため別途用意されている。
以下の図のようにA, B, Cという3つのワープが同じ命令を実行しており、それぞれがRead, Writeで同一のアドレスアクセスを必要としている場合、その実行順序によって結果が変わってしまうことが予測される。従って、CUDAでは threadfence_block()
という関数を使って、同一スレッドブロック内の全スレッドに於いて、この関数の実行(フェンス)以前に発行された全てのReadとWriteが終了していることを保証することができる。
出展: 「GPUを支える技術」,p188,Hisa Ando,2017
threadfence_block()
と似たような機能を持つ関数として、__threadfence()
や__threadfence_system()
がある。
CUDAの関数実行を同期させるsyncthreads関数
GPU上では1つのワープ(32スレッド)が並行で実行されるが、それらが32を超えた場合、同時には終了しない。また、その実行時間はスレッドにおけるメモリアクセス時間(L2キャッシュ、DDRアクセス、リプレイ回数など)によってよって大きく異なってくる。
このため、CUDAには同じスレッドブロックに含まれる全てのワープの実行が終わるのを待ち合わせる関数__syncthreads()
が用意されている。また、似た他の命令として__syncthreads_count(int predicates)
、__syncthreads_and()
、__syncthreads_or()
がある。
ストリーム実行を動悸させるcudaDeviceSynchronize関数
cudaDeviceSynchronize()
関数は、ストリーム内の全てのコマンドが終了するのを待ち合わせる。また、cudaStreamSynchronize()
は1つのストリームのみを対象として、同様に動作する。
ユニファイドメモリ
4章で詳しく説明した、ユニファイドメモリの具体的な利用方法が記載してある。まず、そのマネージド領域を獲得するためにcudamallocManaged()
関数はまたは、__managed__ float A[100]
のように指示子を宣言部分に付加する。これらはホストとデバイスの両方からアクセス可能なポインタとなり、明示的なデータコピーを記述する必要がない。これは非常に便利であるが、そのメモリ転送はデータが必要となった時点で行われるので、計算と転送をオーバラップさせることはできない。従って、ストリームなどを用いたカーネル実行と並列に転送を実行するプログラムのほうが高速になる可能性が高い。
複数GPUシステム制御
計算能力向上のため、1つのCPUに複数のGPUを接続する構成が一般的になってきている。CUDAは1台のホストでGPUを最大8台まで制御でき、以下のようなコードで記述される。
size_t size = 1024 * sizeof(float); cudaSetDevice(0); // Set device 0 as current float* p0; cudaMalloc(&p0, size); // Allocate memory on device 0 MyKernel<<<1000, 128>>>(p0); // Launch kernel on device 0 cudaSetDevice(1); // Set device 1 as current float* p1; cudaMalloc(&p1, size); // Allocate memory on device 1 MyKernel<<<1000, 128>>>(p1); // Launch kernel on device 1
出展: http://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#multi-device-system
CUDAはある程度は高級化されているけれど、依然としてアプリケーションに対して最適な実装を行うには同期処理、ストリーム、デバイス選択などを細かくコントロールする必要があるように見える。(゜.゜)
CUDAはGPUが広く用いられていることもあって結構進んでいる方だと思うけど、
アルゴリズムと計算構造の分離や自動化はまだまだ今後の課題のようだ。
OpenCL
OpenCLはKhronos Groupが中心となって作成した業界標準のGPUプログラミング環境である。その仕様にはAMD, Apple, ARM, IBM, Intel, Qualcom, NVIDIAなどから多数のエンジニアが参加しているため、各社のGPUで動作するという大きなメリットがある。OpenCLはCUDAと同様にCPU、GPU、DSP等のヘテロジニアスな構成をサポートしている。
OpenCLはCUDAを参考にして作られたため、メモリモデルの思想などが似通っているが、その呼び名は異なる。以下にCUDAとの類似点および用語の対応表をまとめる。
類似点
- デバイスメモリにglobal, constant, local, privateのメモリ領域がある
- グリッドとスレッドブロックと共通した考え方で多数のカーネルスレッドを同時に実行する方式
用語対応表
CUDA | OpenCL |
---|---|
シェアードメモリ | ローカルメモリ |
ローカルメモリ | プライベートメモリ |
スレッド | ワークアイテム |
スレッドブロック | ワークグループ |
グリッド | NDレンジ |
ユニファイドメモリ | SVM(Shared Virtual Memory) |
また、OpenCLは各社のGPUをサポートするためにより柔軟に構成されている。例えば、CUDAはそのグリッドに含まれるブロックが最大3次元であるが、OpenCLは最大値は任意である(最小値は3次元)。OpenCLからはSPIL-Vという中間表現データが作られるので、各社はそこからGPUの機械語への変換系を用意するだけで対応することができる。OpenCL 2.0ではC++ベース、2.2ではC++14ベースのカーネル記述がサポートされている。
変数
整数型のchar, short, int, longとそれぞれの符号なし型とhalf, float, doubleの浮動小数点型に対応している。また、CUDA同様にそれぞれのベクトル型を用いることができる。
実行環境
OpenCLは1つのCPUと複数GPUで構成されるプラットフォームを想定しているため、実行プログラムはホストで実行する部分とデバイスで実行する部分に分けられる。CUDAと同様に、デバイスで実行されるプログラムをカーネルと呼ぶ。
初めにOpenCLはカーネルオブジェクトをキューに入れ、その実行条件を満たしたら実行を開始する。その後、実行が終了したカーネルをキューから取り除く。キューはインオーダとアウトオブオーダの2つの実行タイプをもち、異なるキュー同士は独立して動作する。このため、複数のキューを用いるとCUDAのストリームのような構成となる。
カーネルの実行
上記の類似点にも記載したように、OpenCLもCUDAと同様に、その最低実行単位はワークアイテム(スレッド)であり、それらをまとめたワークグループ(スレッドブロック)をGPUのCUに割り当てて実行する(以下に図を掲載)。
出展: 「GPUを支える技術」,p198,Hisa Ando,2017
上記の表にある$(sx,sy)$はワークグループ内のワークアイテムの位置であり、組み込み関数get_local_ID()
で得ることができる。同様に、そのワークグループのサイズはget_local_size()
、グローバルIDはget_global_ID()
で取得できる。また、OpenCL2.0からはCUDAのダイナミックパラレリズムに類似した実装も行うことができるようになった。
メモリ構造
OpenCLではホストCPUのメモリとGPUなどのデバイスのメモリを区別して利用することができる。デバイスメモリには1つのワークグループ内のワークアイテムで共有されるローカルメモリと1つのワークアイテムのみが利用できるプライベートメモリがある。以下にKhronosのOpenCL2.1の仕様から抜粋した図を示す。
出展: https://www.khronos.org/registry/OpenCL/specs/opencl-2.1.pdf
CUDAと同様に、ローカルメモリはオンチップSRAM、プライベートメモリはレジスタファイルもしくはデバイスメモリ、グローバルメモリとコンスタントメモリはデバイスメモリを利用する。
メモリ領域はBufferと呼ばれ、その獲得はclCreateBuffer()
関数で行われる。また、clEnqueueReadBuffer()
関数でバッファのデータをホストのメモリ領域に読み出し、clEnqueueWriteBuffer()
関数でホストのメモリ領域からバッフェに書き込み、clEnqueueCopyBuffer()
関数でバッファ間のデータコピーを行うことができる。
また、SVMを用いるとCUDAのユニファイドメモリのようにホストとデバイスの両方からアクセスできる領域を作ることができる。OpenCLでは粗粒度のSVMサポートは必須であり、細粒度のSVMサポートはオプションである。どちらにせよ仕様であるので、その性能は実装に依存する。ソースコードのサンプルはAMD APP SDK OpenCL User Guideにのっているので、参考にすると良いだろう。
なお、OpenCLは業界標準なので各社のGPUで動作するが、NVIDIAのサポート状況は芳しくなく、未だに1.2の段階で止まっている。また、CUDAで書いたほうが速度が出やすいという利点もある。しかし、NVIDIA以外のGPUではOpenCLを用いる場合が多い。
コメント
コメントを投稿