CPU とキャッシュのはなし
別にグラフィックスに限ったことじゃないし、そもそも論文とか全然関係ないけど。GPU 周りでもたまに話題になるし、自分でもたまにわけわからんくなるから整理しとく。
メインメモリは遅い
CPU からメインメモリにデータを読みに行く場合、これはとにかく遅い。例えばレジスタにあるデータを読みに行く場合と比べると、だいたい数倍から数100倍の遅さ。ヤバいからなんとかしよう。もっと早くアクセスできる場所にデータおいとこう。
キャッシュライン
CPU がメインメモリからデータを読み出すとき、必ず小さなメモリチャンクをキャッシュ上にロードする。ロード単位はプロセッサによるけど、だいたい 8 ~ 512 バイト。このロード単位をキャッシュラインと呼ぶ。
アクセス対象のデータが既にキャッシュに載ってる場合は、メインメモリじゃなくてキャッシュを読みに行く。ない場合はメインメモリにアクセスするけど、そのデータはもしかしたら既にキャッシュラインに載ってキャッシュに読み込まれてる途中かもしれないから、いまのキャッシュラインがロードされるのを待って、欲しいデータがキャッシュに載らなかったのを確認してから、改めて欲しいデータをキャッシュラインに載せる。これがキャッシュミスヒット。遅い。
メインメモリにデータを書きだすときもだいたい同じ。まずキャッシュに書きこんで、適当なタイミングでそのキャッシュラインをメインメモリにフラッシュする。だから、CPU がデータ書き込み命令を出してから実際にメインメモリに書き出されるまでは、ちょっとだけタイムラグがある。
リードキャッシュとライトキャッシュ
リードキャッシュは「読み込み」を高速化するキャッシュ。以下、処理の流れだけどあくまで「概念」だから適当に読み流すの推奨。
function 読み込み命令 { if(読み込みデータがキャッシュに載ってない) { 読み込みデータをキャッシュに載せる; } キャッシュから読み込み; }
ライトキャッシュは「書き込み」を高速化するキャッシュ。ライトバックキャッシュとライトスルーキャッシュがある。ライトバックキャッシュは、
function 書き込み命令 { 書き込みデータをキャッシュに載せる; キャッシュにダーティフラグを立てる;// メインメモリとキャッシュの整合が崩れる } function CPUが暇なときに呼ばれるコールバック { if(キャッシュにダーティフラグが立ってたら) { キャッシュからメインメモリにコピー;// メインメモリとキャッシュの整合を確保 ダーティフラグを折る; } }
ライトスルーキャッシュは、
function 書き込み命令 { 書き込みデータをキャッシュに載せる; キャッシュからメインメモリにコピー;// メインメモリとキャッシュの整合を確保 }
ライトバックキャッシュが使えるときは「CPUが暇なときに呼ばれるコールバック」を明示的に呼び出すための命令みたいなやつが一緒に使えたりするから、そういうの使って整合性に気を配る。
ロードヒットストア
ロードヒットストアは特にヤバい。しかもイマドキのゲーム機でよく使われてる ppc だとよく起こる。たとえば int 変数を float 変数に代入したり、その逆だったり。ppc は int/float をレジスタ上で直に変換する命令を持ってないから、一旦メインメモリに書きだしてから変換して、その直後に読み込む。んで、その変数を使おうとするとストールする。
float f = 1.f; int i = f;
i に f を読み込むためには、f がメインメモリ上で int に変換され終わってなきゃいけない、つまり、f がメインメモリに書き出され終わってなきゃいけない。そのために数10サイクル待たされる。
int hoge(int* a, int* b) { *a = 3;// (1) *b = 5;// (2) return *a + *b; }
場合分け 1: a と b が違うアドレスだった場合。(1) と (2) に依存関係はない、並列実行も可能。早い。
場合分け 2: a と b が同じアドレスだった場合。必ず (1)→(2) の順番で計算を実行しないと結果が狂う。だから (2) の前に (1) の完了を待つ。(1) の結果がキャッシュ経由でメインメモリに書き出されるのを待って、(2) の前に *b をリロードしてから 5 を代入する。遅い。
CPU は a と b がどこにあるのかなんてわかんないから、仕方なく「場合分け 2」のバイトコードを吐き出す。困った。。まぁこの場合は引数宣言に __restrict つければいいんだけど。
L1 Cache と L2 Cache
ふつうキャッシュは 2 種類ある。すぐ近くにある L1(Level 1)Cache と、ちょっと遠くにある L2(Level 2)Cache の 2 つ。CPU はまず L1 にアクセスしにいく。L1 になかったら L2 にアクセスして、みつけたら読み込んで L1 に保存する。L2 にもなかったらメインメモリにアクセスして、読み込んでから L2 と L1 に保存する。
I-Cache と D-Cache
キャッシュって物理的に 2 つあるらしい。命令コードが載ってる I-Cache(Instruction Cache)と、データが載ってる D-Cache(Data Cache)の 2 つ。
D-Cache の制御はわりとかんたん、小さく連続した領域にデータをまとめて、その領域に連続してアクセスするようにする。よくあるケースが行列へのアクセス。
float m[100][100];
ふつうはこれ、データの並びは m[0][0], m[0][1], m[0][2], ... みたいになってるから、その順番にアクセスする。たとえばループを組む場合はこうする。
for(int col = 0; col < 100; ++col) { for(int row = 0; row < 100; ++row) { float val = m[col][row]; ... // val を使った計算 } }
2次元配列の場合はたしかインデクス計算周りの事情もあったような気がするけど、まぁそれはいまはいいや。
I-Cache はヤバい。リンカのクセを読んでそれっぽいコードを書く。まず、キャッシュに全部載るようにコードブロックはできるだけ小さくする。そこから関数を呼び出す場合、その関数はコード上で近くに書いておく。近くに書いた関数は普通はアドレス的にも近くなるから。たとえば
int f1() { ... } int f2() { ... } int f3() { ... }
とかいうコードを書くと、f1/f2/f3 はたぶんアドレス的に連続した領域に配置される、たぶん。けどできるだけ関数呼び出しはしない。間違っても別の翻訳単位(obj)にある関数なんて呼ばない。
あとインライン化はよく吟味して使う。コードがでかくなってキャッシュに載り切らなくなるから。インライン化したら遅くなったなんてのはよくある話。最適化するならまず計測から。計測できなかったら最適化しないぐらいの勢いで。
キャッシュコヒーレンシ
ロードヒットストアみたいなやつ、マルチコアだともっとヤバい。コアが 2 つあったら L1/L2 も 2 セットある。
同じ変数を複数コアからアクセスする場合を考える。
int flag = 0; critical_section cs; inline void locked_write(int* dst, int src) { enter_critical_section(&cs); *dst = src; leave_critical_section(&cs); } void core1_func() { while(true) { if(flag == 0) locked_write(&flag, 1), hoge(); } } void core2_func() { while(true) { if(flag == 1) locked_write(&flag, 0), fuga(); } } int main() { ... initialize_critical_section(&sc); run_on_core1(core1_func); run_on_core2(core2_func); while(true){ ... } ... }
さて、ここで問題です。hoge() と fuga() は必ず同じ回数だけ実行されるでしょうか。正解は「わからない」。それぞれのコアで flag 変数を書きこんでも、それがメインメモリに反映されるまでにタイムラグがあるから。そこらへんをちゃんとやりたかったら、各コアで共有変数(この場合は flag)を書き込みしたら必ずキャッシュフラッシュする。
ちなみにこれ、CPU/GPU で同じデータ領域を読み書きするときも同じことが(理論的には)起こりうる。異なるコアからのデータの読み書き、って意味では CPU/CPU でも CPU/GPU でも一緒だし。システムが勝手にやってくれる場合もあるけど、気を付ける。GPU がアクセスするデータを CPU から書き換えたら、GPU のリードキャッシュがちゃんとクリアされてるかどうか確認する。