CUDA

1日(?)体験会
~GPU並列計算事始め~
2020/10/23 Rin Kuriyama
目標
• GPUでのCUDAプログラミングの概観を理解する。
• 簡単な神経回路をGPUで並列にシミュレーションする。
前提知識
• C
• LIF, exponential decay synapse
• CUDA環境構築
2
目次
• GPUによる並列計算基礎
• (作業) ベクトル計算
• ネットワークシミュレーションの逐次実装を見直す
• (作業) LIFネットワークの並列計算
• さらなる高速化へ
3
General Purpose GPU
4
GPUとは、CUDAとは
並列計算とは
𝑐𝑐𝑖𝑖𝑗𝑗 =
𝑛𝑛
∑
𝑘𝑘
𝑎𝑎𝑖𝑖𝑘𝑘 𝑏𝑏𝑘𝑘𝑗𝑗𝑛𝑛
𝑛𝑛
計算量はO( )n3
は個別に計算可能 = 並列に計算できる。
極論、 リソースがあれば行列積の計算は のオーダーで終わる
𝑐𝑐𝑖𝑖𝑗𝑗
𝑛𝑛2
個の 𝑛𝑛
大規模な計算はとても時間がかかる。
例) 行列の掛け算
大規模な計算を小さな計算に分割し、
複数のリソースで同時並行に実行することで、高速化する。
5
GPUとは
6
これ
GPUとは
7
Graphics Processing Unit (GPU)
もともと画像描写用の処理に特化
精度や必要な演算が決まっていた
単純なコアをたくさん用意して並列に計算
General-Purpose GPU
より高度な画像処理ができるように
= 画像処理以外にもいろいろできる
行列演算への適正が高い
「画像処理以外の目的に使おう」
という技術/考えのこと
8
Architecture of Tesla V100
Warp scheduler (32 thread/clk)
同時平行に実行されるのが保証されるのは、このwarp。
CUDA core と呼ばれるのはFP32 コア
GPU Processing Cluster (GPC)
Texture Processing Cluster (TPC)
Streaming Multiprocessor (SM)
9
GPUの基本方針
数 = 正義
Volta : V100 - 1SM あたり 64コア * SM 80 個 = 5120 cores
Ampere: GA102 1SM あたり 128コア * SM 84 個 = 10752 cores
(どちらもFP32 )
10
CUDAとは
GPGPUにNVIDIA GPUを活用するための
並列計算プラットフォームかつプログラミングモデル
C, C++ 文法で書ける (CUDA Fortran もあるらしい)
__global__ void vec_add( float *a, float *b, float *c, int n ){
int i = blockIdx.x*blockDim.x + threadIdx.x;
if (i < n) c[i] = a[i] + b[i];
}
…
int main(){
…
vec_add<<< numBlocks, ThreadsPerBlock >>>( d_a, d_b, d_c, N);
…
}
ベクトルの足し算の例: c = a + b
11
ちょっとプログラム解説
__xxx__ : 関数識別子
__global__
ホストから呼び出されるカーネル
1スレッドが実行する操作を定義
__host__
ホスト側の関数(普通のやつ)
__device__
カーネル内から呼ばれるデバイス関数
__global__ void vec_add( float *a, float *b, float *c, int n ){
int i = blockIdx.x*blockDim.x + threadIdx.x;
if (i < n) c[i] = a[i] + b[i];
}
…
int main(){
…
vec_add<<< numBlocks, ThreadsPerBlock >>>( d_a, d_b, d_c, N);
…
}
12
Block 0 Block 1 Block 2
Block 3 Block 4 ……
Thread Block Grid
<<< numBlocks, ThreadsPerBlock >>>
カーネルで1スレッドの動作を定義
呼び出し時に
"ブロック数" : numBlocks
"1ブロックあたりのスレッド数" : ThreadsPerBlock
を決めて呼び出す*
*実際はdim3という型で3次元に分割されるが、ここでは扱わない。
__global__ void vec_add( float *a, float *b, float *c, int n ){
int i = blockIdx.x*blockDim.x + threadIdx.x;
if (i < n) c[i] = a[i] + b[i];
}
…
int main(){
…
vec_add<<< numBlocks, ThreadsPerBlock >>>( d_a, d_b, d_c, N);
…
}
Hierarchy of CUDA
13
GPU構造とCUDA Hierarchy の関係
"A scalable programming model"
GPUによってコア数、SM数は異なる。
ここでCUDA の階層構造 が活きる。
SMにブロックが割り振られる
→ブロックが少なければSMが余る
→SMが足りなければブロックが重なる
Block 間同期が存在しない
全てのSMを使い切ることが大事
14
SMを使い切るために ~ Stream ~
本来、カーネルは1度に1つ実行するのが基本。
- SM が余る……
- メモリ転送とカーネル実行を同時にやりたい……
Streamの利用
- 発行順に実行される一連の操作列
異なるStreamにCUDA操作を割り当てる
ことで、“concurrent” に実行できる
(ことがある)
CUDAによる
並列化体験
15
ベクトル計算の基本から
ベクトル計算 (加算) の逐次実装 (vector.c)
まずは加算から。
n次元ベクトルの加算を行う関数vec_addを定義。
i について、他のデータを使わないため
i = 0, 1,...,n-1 は個別に計算できる。 → 並列化可能
このvec_add関数をglobal関数、つまりカーネルに書き換える。
ci = ai + bi
16
a0
a1
:
an-2
an-1
b0
b1
:
bn-2
bn-1
c0
c1
:
cn-2
cn-1
+ =
拡張子の変更 & コンパイル
CUDAのプログラムファイルの拡張子は ".cu"
コンパイラは "nvcc"
オプションは基本的にgccと同じ
nvcc -o vec vec.cu
Makefile を用意。今回は詳細の説明を省く。
17
カーネルの実装
カーネル内部ではあるスレッド i の動作を定義する。
→単純にループを外せば良い。
ではどうやってiを決めるか。 OpenMP, MPIあたりでは、自分が今何番目のスレッドなの
かを取得できる。CUDAでは一律なglobal_thread_idは割り振られない。
→ block , thread の情報からglobal_id を定める。
18
カーネル呼び出し
関数名<<< ブロック数, 1ブロックあたりのスレッド数 >>>(); として呼び出す。
今回は次元 n を標準入力から取得している。
何も考えなければ、ブロック数1, スレッド数nでも呼び出せる。
1SM 4partition, warp scheduler ( 32warp / clk )
-> 大抵は128 threads/block が使われる。
ブロック数は n/tbp の切り上げ。
(n+tpb-1)/tbp tbp = 128なら (n+127)/128
19
スレッド番号の計算
int i = threadIdx.x + blockIdx.x*blockDim.x;
20
0, 1, ...., 126, 127 0, 1, ...., 126, 127 0, 1, ...., 126, 127
Block 0 Block 1 Block 2
0, 1, ..., 126, 127, 128, ............, 255, 256, .............., 382
iが溢れる
切り上げ*128スレッドでは必要以上にスレッドが呼ばれ、そこでも同様の動作が行われる。
21
例) 要素数 350 , 128 thread/blockの場合: #Block = (int) (350+127)/128 = 3
カーネルにデータサイズnを渡し、iに制約を課す必要がある。
if( i < n ){ 処理 }
0, 1, ...., 126, 127 0, 1, ...., 126, 127 0, 1, ...., 126, 127
Block 0 Block 1 Block 2
必要なのはここらへん(0~349)まで こっち側には何もして欲しくない
n = 350
データの居場所を考える。
このままではセグフォ発生。データの格納場所が違う。
CPUが扱ってるメモリ空間ではなく、GPUが扱っているメモリ空間にデータを移す必要
がある。
cudaMalloc でデータ確保
cudaMemcpy でデータ転送
カーネル呼んで
cudaMemcpy でもっかいデータ転送
22
cudaMalloc( void **p, size_t size)
cudaMemcpy( void *dst, void *src, size_t size, direction)
direction
= cudaMemcpyHostToDevice, cudaMemcpyDeviceToHost, cudeMemcpyDeviceToDevice
余談: カーネル呼び出しとCPU処理の話
カーネル呼び出しの後、CPUはその先の処理に進む。
CPU - GPU は基本的に並列に動く。
cudaDeviceSynchronize() で同期させることが可能。
cudaMemcpy() など、内部的に直前の処理が終わるまで待つので同期する必要はない。
23
:
:
:
kernerl<<< block, threads >>>();
:
:
:
カーネル発行
終了を待たずに次の処理へ
:
:
:
kernerl<<< block, threads >>>();
:
cudaDeviceSynchronize();
:
カーネル発行
GPU処理の終了を待つ
実装してみよう
ベクトル加算を正しく計算できているか確認。
24
実行時間を測定する
シェルのtime: トータルの実行時間 (cudaMalloc, cudaMemcpy 等が含まれる)
cudaEvent API: カーネル呼び出しを含む
nvprof: カーネル単体の実行時間を計測できる。
→計算処理がどの程度速くなったか知りたい場合はcudaEventAPI
→どの処理が重いのか確認したい場合はnvprof
25
実行時間を測定する
cpu側の計測も(ロスあるかもだけど) cudaEventで測定できる。
26
余談: カーネル内のif文処理
c の値が5以上なら加算、それ以下なら引き算を行う処理
or
スレッドは同期して実行される
自分が処理していなくても、隣のスレッドの
実行を待ってしまう。
三項演算子に置き換えると、ロスが軽減される。
ci = ai + bi ci = ai − bi
27
余談: カーネル内のif文処理
28
ベクトル計算 (内積) の逐次実装
次は内積
基本は加算と同様。
i要素同士を掛けてから全て足す。
(今回はc[0]のゼロ初期化をサボるので、c[0]の値は内積*trialになる)
c0 =
n−1
∑
i=0
aibi
29
a0
a1
:
an-2
an-1
b0
b1
:
bn-2
bn-1
c0・ =
その " += " どう動く?
全スレッド同時に動いたとして、
ある値を取得、自分の値を足し、書き込む
が並列に行われる。
もうなんの値が入っているかわからん。
atomicAdd()
スレッドiが扱っている場合他スレッドからの操作をブロック。
計算が逐次に行われることを保証。
"逐次" つまりは遅い
30
ct = ct−1 + a0b0 ct = ct−1 + a1b1
ct−1
ct
atomicAdd のコスト軽減: Shared Memory
全スレッドがatomicAdd()を用いてGlobal memory に書き込んでいた。
★ Shared memory を利用
SM内部なので、グローバルメモリよりも近い
Shared memory 内で(ThreadsPerBlock)個分
まとめてから,グローバルメモリに書き込む。
N回の逐次計算が #Blocks + #ThreadsPerBlock 回に。
31
Shared Memory 有無のコード比較
ブロック内同期
Shared memory 確保(使用宣言)
Shared memory に値を格納
threadIdx.x = 0 がまとめて作業する
グローバルメモリに書き込み
Nスレッドが逐次に行っていたが、親スレッド (tid == 0: N/128個) に圧縮した。
32
さらなる高速化へ
問題点
• 今回のshared memory の使い方では tid = 0の動作中他のスレッドが停止している。
• 親スレッドは128回計算してる。
• 結局atomicAdd()を用いているため (圧縮してはいるものの)逐次動作部がある。
ツリー形式のリダクション = Parallel Reduction
ここではスキップ。 33
4 7 5 9
11 14
25
3 1 7 0 4 1 6 3
神経回路シミュレーション
並列化
34
Leaky Integrate-and-Fire model, exponential decay synapses
今回のネットワーク
ニューロン LIF
• 興奮性ニューロン
• 抑制性ニューロン
シナプス コンダクタンスベース
• exc -> inh
• inh -> exc
• それぞれ確率0.02で接続
35
¯gx
逐次実装を見直す
SoA (Structure of Array: 配列の構造体) を採用
実行時に各細胞種の個数を与える。 $./main #exc #inh > output.dat
パラメータは共通のためupdate関数はひとつ。今回のデータ構造だとupdate関数を2回呼
ぶ。
100ms程度動かしてから, t=0 でシナプス接続。
spike_propagation関数とupdate関数のループで構成。
36
逐次実装を見直す - 並列化したい場所
update関数
37
syn_propagation関数
まずはGPU側にデータをコピーする
• Neuron d_neurons[],
• connectivity d_connections[]
• cudaMalloc
• cudaMemcpy
38
関数の並列化
CUDA関数指定子: __global__
グローバルスレッドid i の計算
iについてのfor文を解消。
i < n で抑える
39
二重ポインタ(& 構造体)の罠
このままではupdate関数でエラーが発生する。
update内部の g[ch][i] が原因。
d_neuron->g[ch] はGPU側のアドレスを指している。
d_neuron->g はCPU側のアドレスを指している。
つまり、d->g[ch] というCPU側のデータにアクセスしたときにセグフォが起きる。
40
解決策
• update関数の引数を増やす。
• g, dg のデータ構造を変更する
• g[ch][i] から g[ch*n + i] へ。
• 引数は一つで済む。今後チャネルが増えても対応可
• syn_propagation には &(g[ch*n]) と渡せば良い。
(個人的に見た目がいいのは後者。ただ時間の関係上今回は前者で対処。)
41
実行時間を測定する
興奮性 : 抑制性 = 19 : 1 ぐらいにしていろいろと試そう。
42
さらなる高速化へ
43
考え方、コードの紹介
グローバルメモリへのアクセス回数を減らす
syn_propagation カーネル内部
dg[i] に毎回アクセスしているのは無駄。最後に一度更新すればよい。
カーネル内で "T tmp"を宣言。ループ文の中の dg[i] を tmp に置き換え。
ループ後にdg[i] += tmp; とする
44
カーネルを重ねる
update関数同士は順序関係ない。
(同じLIFを使っているので本来1回で計算できるが今回は割愛)
さらに言えば片方が高々数千個程度であればCUDA core, SMを使いきれない。
Stream を適用することでupdate関数を重ねることができる。
spike_propagationも、「シナプス後細胞が異なる」or「チャネルが異なる」であれば、
並列に実行できる。
45
Spike情報のコピー&出力を重ねる
毎ステップ 「spikeコピー → ファイル出力」をするのはとても時間の無駄。
• spike行列を適当なステップ数分確保し、非同期にまとめてコピーする
• cudaMemcpyAsync, cudaMallocHost, など
• pthread library (OpenMPも可?) を用いてファイル出力を非同期に行う。
CUDAの話からずれてしまうため本稿からは割愛。
46
ボトルネックを探す: nvprof
% nvprof [options] ./<prog> [args]
で実行することでお手軽に関数ごとの全体に占める割合等を確認できる。
細かい結果をファイル出力すれば、NVIDIA Visual Profiler (nvvp) で結果を可視化、分析
できる。
syn_propagation がボトルネック <= 無駄な計算が多い
47
重み行列の格納方法
ネットワークが大規模になればなるほど、重み行列(隣接行列) を無駄に確保している。
今回の非ゼロ要素は #pre*0.02 個程度でしかない。
pike_propagation関数内部で #pre 回ループしている。
-> 疎行列を工夫して格納することで、無駄なメモリ、無駄なループを削減。
COO (coordinate storage),
CSR (compressed sparse row: CRS: compressed row storage),
ELL (ELLPACK), ....,
(Nathan Bell のスライド)
48
重み行列の格納方法
今回は通常の疎行列を ELL format へ変換する。
(ELLを最初から構築する方法はちょっと思いつかない。CSRは可能だがデバッグ面倒。)
変更点
• 生成中にmax_convergenceを計測しておく。
• GPU側で疎行列 → ELL format へ変換。
• spike_propagation に渡していた preNum を maxConv へ変更
49
重み行列の格納方法 - 速度比較
50
変更前
ELL
重み行列の格納方法 - nvprof
51
変更前
ELL
(なおCPU版でELL適用すると total: 7s → 450ms)
Spike propagation の積和計算について
convergence = ある接続におけるシナプス数 / シナプス後細胞の数
1シナプス後細胞に接続しているシナプス前細胞の数の平均
このconvergence の値とシナプス後細胞の個数によっては、通常の積和計算では速度が
出ないことがある。
例: PF-MLI, PF-PC
52
j 並列化
思いつくのは、
「1シナプス後細胞に複数スレッド使えばいいのでは?」
これを(成見先生の授業では)"j 並列化" と呼んでいる。
sample code, figure
53
Parallel reduction
PF-PCに至ってはもっとconvergenceが大きく、シナプス後細胞の数も少ない。
「複数スレッドとか言わずに全部使っちゃおう」 = Parallel Reduction
シナプス後細胞について逐次、積 + 和の計算に分割し、和の部分を並列に実行する。
(Mark Harris のスライド)
結果的にシナプス後細胞については逐次実行になる。
54
Parallel Reduction の更なる沼
カーネル呼び出し回数のコントロール
最後の呼び出しって重なるよね?
カーネル呼び出しのオーバーヘッドが気になるのでまとめます。
55

CUDA1日(?)体験会 (再アップロード)