メモリ階層 — 速いものは小さく、大きいものは遅い
一言でいうと
速くて大きくて安いメモリは作れないので、小さくて速いものから、大きくて遅いものまでを層状に積み上げ、よく使うデータを上の層へ引き上げます。
なぜ必要なのか
レジスターはCPUと同じ速度で動作しますが、数十個しかありません。DRAMは数十ギガバイトを保持できますが、アクセスに数百サイクルかかります。この隔たりは、縮まるどころか、世代ごとに広がってきました。演算ユニットを増やすのは、トランジスタをもっと入れればよいのですが、データをチップの内外へ運ぶ能力は、ピンの数や配線の物理的な限界に縛られているからです。
この積み重なった隔たりを、メモリウォール(memory wall)といいます。階層構造は、この壁を迂回する唯一の現実的な方法です。
どう動くのか
典型的な階層と、おおよそのレイテンシは次のとおりです。数字は世代ごとに違いますが、桁の違いが要点です。
| 階層 | サイズ | アクセスレイテンシ(おおよそ) |
|---|---|---|
| レジスター | 数百バイト | 1サイクル未満 |
| L1キャッシュ | 32–64KB | 4–5サイクル |
| L2キャッシュ | 0.5–2MB | 12–20サイクル |
| L3キャッシュ | 数十MB | 40–70サイクル |
| DRAM | 数十GB | 200–300サイクル |
| NVMe SSD | 数TB | 数万サイクル |
この構造が実際に機能するのは、プログラムが局所性(locality)を持つからです。
- 時間的局所性: 一度使ったデータは、すぐにまた使われる可能性が高いです。ループの変数がそうです。
- 空間的局所性: あるアドレスを使うと、その近くのアドレスもすぐに使われる可能性が高いです。配列の走査がそうです。
キャッシュは、この2つの性質をそのまま利用します。データを1バイトずつ取得するのではなく、キャッシュラインの単位、通常は64バイトを丸ごと取得します。4バイトの整数を1つ読んでも、隣り合う15個が一緒に載ってきます。配列を順番に回るコードが速い理由がこれです。
キャッシュミスは、3種類に分けて見ると、対処が分かれます。
- 強制ミス(compulsory): 初めてアクセスするデータです。プリフェッチによってのみ緩和されます。
- 容量ミス(capacity): ワーキングセットがキャッシュより大きくて、押し出されます。アルゴリズムやデータ構造を変える必要があります。
- 競合ミス(conflict): 同じセットに集中して押し出されます。配列のストライド(stride)がキャッシュサイズの倍数のときに起きやすいです。
現場での姿
行優先で保存された2次元配列を列優先で走査すると、同じ演算なのに数倍から数十倍遅くなります。毎回のアクセスで新しいキャッシュラインを取得し、そのうち4バイトだけを使って捨てるからです。行列の乗算でタイリング(ブロッキング)を使う理由も同じです。ワーキングセットをキャッシュに収まるサイズに切り分けて、再利用を増やすのです。
AIワークロードでは、この問題がさらにあからさまに現れます。LLM推論のデコード段階は、巨大な重みを1回読み込んで、小さな入力と掛け合わせて捨てます。演算強度(取得したバイトあたりに行う演算の数)が極端に低いので、演算器はほとんど遊んでいて、メモリ帯域幅が性能を決めます。GPU使用率が30パーセントを超えられないというよくある嘆きの正体がこれです。このとき、より高価な演算チップを買うのはお金を使うだけの選択で、答えは、読むバイトを減らすこと(量子化)や、再利用を増やすことです。
続くクイズで確認すること
キャッシュラインがなぜ64バイトずつ動くのか、同じアルゴリズムでアクセス順序を変えただけで、なぜ倍数で遅くなるのかを、説明できるかを確認します。