メモリアクセスの最適化

GK110 GPUはGDDR5というデータ転送速度が通常のDDR3 DRAMに比べて4~8倍程度高速のメモリを使い、さらに、メモリとの間のバス幅が384ビットと広く、通常のCPUと比べて1ケタ程度メモリバンド幅が広いのが特徴であるが、広いメモリバンド幅を有効利用するためには、いろいろな注意が必要である。

x86のSSEやAVXでは、1つのロード命令で1つのアドレスのメモリ領域からSIMDのデータ幅の4個、あるいは8個のデータ(データが32bitの場合)を読んでくる。ストアも同様でSSEやAVXレジスタの内容をストア命令が指示する1つのメモリアドレスから連続してストアする。

しかし、Kepler GPUは32命令のワープ単位でロード、ストアを実行する。この時、各スレッドが独立にメモリアドレスを計算するので、1つのロード、ストア命令のアクセスするメモリアドレスは、原則、スレッドごとに異なってしまう。つまり、最悪、32カ所のバラバラのアドレスにアクセスしなければならないということが起こる。

次の図のように、GPUのメモリ階層は、Global Memory、Local Memory、TextureとConstantなど全てのデータを入れるGDDR5 DRAMがあり、そこからL2キャッシュを経由してSMに繋がっている。

GPUのメモリ階層は、GDDR5 DRAM、L2キャッシュ、そしてSM内部のL1キャッシュ、テクスチャキャッシュ、定数キャッシュからなっている (2)

SMの内部にはL1キャッシュ、テクスチャを入れるRead Onlyキャッシュ、そして定数を入れるキャッシュがある。これ以外にSM内だけからアクセスできる独立のアドレス空間となるシェアードメモリがあるが、これについては後に述べる。

前述のように、ロード、ストア命令は32のメモリアドレスを生成するが、これがすべて同じアドレスとか連続のアドレスの場合は良いが、バラバラのアドレスの場合、一時にアクセスできるようなメモリを作るのは現実的ではない。このようなケースでは、GPUは複数回のメモリアクセスを行う。最初の1回のメモリアクセス以外をNVIDIAはReplayと呼んでおり、N回のメモリアクセスを必要とする場合はN-1回のreplayが行われることになる。Replayを行うと、当然、replayを行っている時間だけ命令の実行時間が長くなるので、replayの回数が少なくなるようプログラムを書くことが望ましい。

Kepler GPUでは、L1キャッシュは、コンパイラが使用するローカルメモリ(LMEM)のデータの格納だけに使用され、ユーザがCUDAプログラムに書いた通常のデータのキャッシングには使用されない。

GMEM(DRAM上のグローバルメモリ)への書き込みはL1キャッシュがヒットしてもそのラインは無効化し、L2キャッシュをアクセスする。L2キャッシュのラインサイズは128Bであるが、アクセスの単位は32Bのセグメントで、32スレッドからのアドレスのばらけ具合で、1、2、または4セグメントを同時にアクセスする。

ストアの場合のメモリアクセスの例 (2)

この上側の図のように、すべてのアドレスが連続の4セグメント(ただし、開始アドレスは128Bの倍数でアラインされていることが必要)に入る場合は、L2キャッシュ(あるいはDRAM)に1回の4セグメントアクセスを行う。また、連続2セグメント(開始アドレスは64Bの倍数にアライン)にすべてのアクセスが収まる場合は、1回の2セグメントアクセスを行う。

下側の図のように、アドレスがばらけており、3つの不連続なセグメントにまたがる場合は、1セグメントのアクセスが3回となり、2回はreplayとなる。そして、32スレッドのアドレスがまったくバラバラという場合は1セグメントのアクセスが32回必要ということも起こり得る。

そして、これらのパターンの組み合わせで、連続4セグメント、連続2セグメント、1セグメントの優先順でアクセスを選択し、すべてのスレッドのアクセスが完了するまでreplayを繰り返す。

なお、各スレッドが独立にストアアドレスを計算するので、2つ以上のスレッドが同一アドレスに書き込みを行うことがないとは言えない。この場合は、どれか一つの書き込みの結果が残るが、それがどのスレッドであるかは決まっていないので、同一データを書き込む場合以外では、このパターンを使ってはならない。

GMEMからの読み出しは、FermiではL1キャッシュが使われるが、KeplerではL1キャッシュは使わず、直接L2キャッシュをアクセスする。しかし、コンパイラオプションでキャッシュを使うと指定すると、コアからキャッシュをアクセスする単位は128Bとなるのに対して、キャッシュを使わないと指定すると、32Bのセグメントでナチュラルアライメントで1、2、4セグメントのアクセスを行う。

KeplerのGMEMアクセスでいうと、どちらの指定でもL1キャッシュはバイパスされてしまい、どちらを指定しても同じ結果になる。

キャッシュを使うロードの場合は128B単位のアクセスとなり、この範囲に含まれるアドレスのロードは同時に実行することができる。次の図は各スレッドが4Bずつ順にアクセスする場合を示しており、最も効率の良いメモリアクセスである。

キャッシュを使う場合は128Bのアクセスとなり、その範囲に含まれるメモリアクセスが一括して行える (2)

また、次の図は、アドレスはスレッドの順にはなっていないが、すべてのスレッドのアクセスが128Bに入っている場合を示しており、この場合も1回のメモリアクセスですべてのスレッドのロード命令が完了する。

ワープ内のスレッド番号とアドレスの順序は一致していないが、32スレッドのアクセスが128Bの範囲内に収まる例 (2)

このように128Bの範囲に32スレッド全部のロードアドレスが含まれる場合はreplayは不要であるが、残ったスレッドがあるとそのロードアドレスを含む128Bのreplayアクセスが必要となり、さらに残ったアドレスがあると、そのアドレスを含む128Bのreplayアクセスを行うというように32スレッドすべてのロードアドレスをカバーするまでreplayを行う。

キャッシュを使わない指定でコンパイルすると、32Bのセグメント単位でL2キャッシュをアクセスする。

アドレスがバラけている場合は、上の図のキャッシングロードとすると128B単位で読んでしまうので無駄な読み込みが多くなるが、キャッシングしないと32B単位となり不要な32Bセグメントを読み込まないのでバスの利用効率は高くなる (2)

キャッシングする場合の128B単位のアクセスと、キャッシングしない場合の32B単位のアクセスを比較すると、この例では32B単位の方が無駄な読み込みは少ないが、128B単位の方がメモリアクセスの回数は少ない。なお、Non-Cachingでも0~127Bの4セグメント、128~255Bの4セグメントの読み込みを行えば、Non-Cachingでも4回のメモリアクセスで済むのではないかと思われるのであるが、NVIDIAに問い合わせたところ、この図のように6回のメモリアクセスを行うとのことであった。