💾

CUDA C++ メモリ大全

2024/08/10に公開

1.記事の概要・対象読者

CUDA C++ で登場するメモリそれぞれの性質と使い分けを、プログラマの立場から解説していきます。CUDA C++を勉強していて、CPUのメモリについて多少知識がある人を対象読者としています。 (レジスタ,L1・L2・L3キャッシュ,メモリと言われてそれぞれの違いが分かる位)

2.執筆の動機

\rm{CUDA} ~ \rm{C++} ~ \rm{はメモリの種類が多すぎる!}

これに尽きます。C++プログラミングならローカルメモリ,グローバルメモリ,スタック領域,ヒープ領域程度まで知っておけばメモリで困ることはないですが、CUDA C++のメモリの場合プログラマから制御が必要なものだけでも

  • レジスタ
  • ローカルメモリ
  • シェアードメモリ
  • グローバルメモリ
  • コンスタントメモリ
  • テクスチャメモリ
  • ユニファイドメモリ
  • ホストメモリ

があります。これらに関連するキャッシュまで考えると、複雑とかいうレベルじゃありません。

にも拘らずこれらについて調べると大抵 「NvidiaのProgramming Guide読め」 と出てきます(9.参考文献の[1])。確かにそれは正論なのですが、英語だし非常に長いので、CUDA初心者にはハードルが高すぎる。

そこで初心者向けに各メモリがどんなものでどこで使えばいいのかを日本語でまとめよう! と思って記事にしました。

3.種類の多さの原因

まず「なんでこんなにメモリ周りが複雑なのか?」と疑問に思うかもしれませんが、原因はCPUとGPUの役割の違いにあります。CPUは汎用計算装置であり、そのプログラミング言語は幅広い問題を簡単に書けるように設計されています。一方GPUは汎用性を下げる代わりに並列性を上げてより高い演算能力を持たせたものです。ゆえにCUDA C++はその演算能力を最大限引き出せるように設計されており、CPUでは汎用性確保のため隠蔽されていたハードウェア依存の部分が人間側で直接制御できるようになっています。その制御できる部分の1つがメモリであるため、隠蔽が外れて種類が多く見えるわけです。

4.GPUの基礎知識

前項で書いたようにそれぞれのメモリはGPUのハードウェア部分にかなり依存しているので、まずメモリの説明の前にGPUの構造を簡単に説明していきます。概略図で構造を示すとこんな感じになります。

GPUの最小命令実行単位はスレッドといって、これが32個まとまったものをWarpと言います。基本的には同一Warp内のスレッドは全て一緒に動きます。SM(CPUでいうシングルコア)の中にはL1キャッシュとSMを実行するユニット数個が入っています。

SMはL2キャッシュを使いながらSM外のVRAMとデータのやり取りをしています。このVRAMはCPUが管理するホストメモリとデータをやり取りし、このホストメモリがC++やPythonで普通に出てくるメモリです。

細かく言えばもっと複雑ですが、CUDA C++を書く上でここまで知っていれば問題ありません。1つ注意すべき点があるとすれば、GPUのL2キャッシュはCPUと違ってGPU全体で共有しており、コアごとではありません。

5.メモリの種類

本題のメモリに入ります。とはいえ「2.執筆の動機」で書いたようにメモリは8種類もあり、最初から解説文並べると見づらいので、表にまとめるとこんな風になります。

メモリの種類 アクセス スコープ ハードウェア位置 備考
レジスタ 読み書き 1スレッド レジスタ 最速
ローカルメモリ 読み書き 1スレッド VRAM ベンチ要員
シェアードメモリ 読み書き SM内 L1キャッシュ ブロック内データ共有
グローバルメモリ 読み書き GPU全体 VRAM ノーマル
コンスタントメモリ 読み GPU全体 VRAM ミニ情報倉庫
テクスチャメモリ 読み GPU全体 VRAM テクスチャ特化
ユニファイドメモリ 読み書き CPU・GPU全体 優等生
ホストメモリ 読み書き CPU ホストメモリ CPU用

それぞれをアクセス速度の速い順に並べると

  1. レジスタ
  2. シェアードメモリ
  3. コンスタントメモリ・テクスチャメモリ
  4. ローカルメモリ・グローバルメモリ
  5. ユニファイドメモリ

一方容量の大きい順に並べると

  1. メインメモリ
  2. グローバルメモリ
  3. ユニファイドメモリ
  4. ローカルメモリ
  5. コンスタントメモリ・テクスチャメモリ
  6. シェアードメモリ
  7. レジスタ

6.各メモリの詳細

この項ではそれぞれのメモリの特徴を解説していきます。

6.1.レジスタ

device関数で宣言されたローカル変数は優先的にレジスタ上に保存されます。 GPUが演算を行うための記憶装置であり、最も高速にアクセスできる場所です。1スレッドあたりfloat型で数十から数百個分(100B~1KB)の大きさがあります。アクセスは基本的にそのスレッド内でしか行えないですが、シャッフル命令を使うと同一Warp内のスレッドならアクセスすることができます。いかに高速なキャッシュでも、これより速くアクセスはできません。

6.2.ローカルメモリ

device関数で宣言されたローカル変数のうち、レジスタからあふれた分はこちらに入ります。VRAM上に保存されていて特別なキャッシュもないのでアクセスはかなり遅めでグローバルメモリと同等です。 レジスタと同様アクセスは基本的にそのスレッド内でしか行えず、シャッフル命令を使うと同一Warp内のスレッドならアクセスすることができます。

6.3.シェアードメモリ

それぞれのSMのL1キャッシュ部分を使用するメモリです。チップ上にあるのでVRAMよりもはるかに高速であり、1つのSMあたり32KB(float型で8千個)前後の容量を持ちます。同一ブロック内ならすべてのスレッドがアクセスできます。使う上での注意点として、L1キャッシュとハードを共有している関係上、シェアードメモリを多く使うほどL1キャッシュの容量は小さくなってキャッシュヒット率が低下します。そのため利用する場合の容量は最小限にするべきです。

6.4.グローバルメモリ

VRAMを使用するメモリです。数GB~数十GBとGPU上のメモリの中で最大の大きさですが、他のメモリに比べてアクセス速度が遅いです。ただしL2キャッシュに乗るサイズ(合計数MB~数十MB程度)であればそれなりの速度でアクセスできます。 データはカーネル上全てのスレッドがアクセスすることができるので、速度さえ気にしないのならば容量・スコープ速度ともに最大で最も広く利用されています。

6.5.コンスタントメモリ

グローバルメモリ同様VRAMを使用するメモリですが、グローバルメモリと異なりGPU側からは読み取りしか行えず、容量も64KBしかありません。 その代わりSMごとにあるコンスタントキャッシュと呼ばれるチップ上のキャッシュを利用してデータを参照でき、シェアードメモリに次いで高速です。特にワープ全体が同じアドレスにアクセスする場合にはその速度はレジスタに匹敵します。

6.6.テクスチャメモリ

コンスタントメモリと同様にVRAMを使用するが読み取りしかできないメモリで、SMごとにあるリードオンリーキャッシュと呼ばれるチップ上のキャッシュを利用してデータを参照でき、コンスタントメモリに次いで高速です。このメモリの最大の特徴はテクセルの高速低精補間やテクスチャ座標の境界処理などを、読み取りプロセスの一部として行うことができることであり、一部のアプリケーションにおいてパフォーマンス向上につながります。ただし使用場面はかなり特殊なので、初心者がすぐ覚える必要はあまりありません。

6.7.ユニファイドメモリ

CPU・GPU両方からアクセスすることのできるメモリです。ホストメモリ・VRAMに対応するので使える容量も大きいですが、両方で使えるようになっている分速度は最遅です。速度を犠牲にしている分面倒な部分が隠蔽されていて扱いやすく、プログラミングをする上で非常にありがたい存在です。

6.8.ホストメモリ

ホスト(CPU)側にあるメモリです。基本的にはGPU側から直接アクセスはできず、GPU側にデータを流すにはPCIE経由となるため非常に遅いです。加えて帯域幅もVRAMほどは大きくありません。しかしVRAMよりも大容量にするのが簡単であり、普通のプログラミングで扱うメモリなので特別な扱いは必要ありません。

7.使い分け

ではここまで書いた特徴をもとにこれらのメモリをどのように使い分ければいいのかという話をしていきます。ホストメモリはそもそもCPU側からしかアクセスできないのでこれ以外の使い分けとなります。

アクセス速度をほとんど気にしなくていい用途ならユニファイドメモリが第一候補です。 容量は大きく、どこからでもアクセスできて、扱いが一番簡単です。具体的には非常に小さなデータのホストとのやり取りや、計算時間がほとんどを占めるプログラムにおけるホストとのデータのやり取りなどで力を発揮します。

それ以外のデータはなるべくレジスタに入るよう、ローカル変数に入れることを検討するべきです。 レジスタは最速でデメリットもないので、共有の必要なし・少量ならここに入れるのが最善です。ただしローカル変数が多すぎてレジスタの容量を超えてローカルメモリに入ると、速度はグローバルメモリと同程度まで下がるので、注意が必要です。

データをブロック内で共有する必要がある場合、シェアードメモリの利用が最速です。 ただし1ブロック当たり32KB前後しかないことと、使いすぎるとL1キャッシュを圧迫すること、Warp内での共有で良いならレジスタに対しシャッフル命令を行うことでも対応できることに注意が必要です。

上のいずれにも該当しない場合、よく分からなければすべてグローバルメモリで問題ありません。 スコープ範囲もカーネル全体ですし、最も広く使えるメモリなのでこれでできないことはありません。

もし使うとすれば、コンスタントメモリは少量かつスレッドから何度も参照するデータ、例えば各スレッドが多用する数式の係数や対応表などで利用できます。 使うことのメリットは、グローバルメモリからの転換なら速度の向上が期待できますし、レジスタやシェアードメモリからの転換ならばその分他のデータをレジスタ・シェアードメモリに割り当てたり、L1キャッシュの増加につながります。

テクスチャメモリは名前の通りテクスチャマッピングなどで使うために用意されているメモリなので、そういった用途でCUDA C++を利用する場合に参考文献9.[1]などを調べてみてください。具体例のコードもネット上に転がっているはずです。

8.おわりに

ここまでCUDAのメモリについての解説を行ってきましたが、実際の各メモリの仕様というのはここに書ききれないほど複雑ですし、実際にプログラミングしてみるとL2キャッシュなどもかなり大きく絡んできて、思ったように速度が出ないことも良くあります。 そのときに自分なりに理由付けをしながらコードを組んでいくことができれば、きっとあなたのGPUメモリに対する理解は大きく深まるはずです。

9.参考文献

[1] 「CUDA C++ Best Practices Guide」(Nvidiaが出しているCUDA C++ 指南書)
https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/
(2024/08/09 参照)

[2] John Cheng, Max Grossman,Ty McKercher著,株式会社クイープ訳,『CUDA C プロフェッショナル プログラミング』,株式会社インプレス,初版第2刷,ISBN 978-4-8443-3891-8

Discussion