基礎●●●○○

CPUキャッシュ

シーピーユーキャッシュ

CPUと主記憶の速度差を埋める小容量高速メモリ。局所性を活かすと桁違いに速くなる。

概要

CPUキャッシュは、CPUと主記憶(RAM)の間に挟まれた小容量・高速なメモリで、メモリ階層の最上段近くを担う存在です。CPUが主記憶からデータを読むには約100ナノ秒かかりますが、これは現代のCPUにとって数百命令ぶんの時間に相当します。そこで「最近使ったデータとその周辺」をCPUのすぐそばに写し取っておき、大半のアクセスを数ナノ秒で済ませるのがキャッシュの仕事です。

ハードウェアが全自動で管理するため、プログラムから直接操作することはできませんし、普段は存在すら意識しません。しかし性能の世界では話が別で、「キャッシュに乗るかどうか」でプログラムの速度は数倍から数十倍変わります。同じアルゴリズム・同じ計算量でも、データの並べ方とアクセス順序しだいで実行時間が桁で変わる — その分かれ目を握っているのがCPUキャッシュです。

Webアプリのキャッシュ(RedisやCDN)と発想は同じですが、こちらはナノ秒の世界で、ハードウェアに焼き込まれた仕組みという点が異なります。

なぜ生まれたか

1980年代以降、CPUの動作周波数は急速に伸びた一方、主記憶に使われるDRAMのアクセス速度はわずかしか改善しませんでした。両者の差は開き続け、CPUは「1命令を1ナノ秒未満でこなせるのに、データの到着を100ナノ秒待つ」という状態に陥ります。どれだけCPUを速くしても、メモリ待ちで遊んでいては意味がありません(メモリの壁)。

速いメモリ素子(SRAM)は存在しましたが、DRAMの数十倍高価で大容量化できません。そこで「主記憶を全部SRAMにする」のではなく、「小さなSRAMをCPUの隣に置き、使用頻度の高いデータだけを写しておく」という折衷案が採られました。プログラムのメモリアクセスには時間的・空間的な局所性(同じ場所・近くの場所が繰り返し使われる偏り)があるため、小さなキャッシュでもヒット率は9割を超えます。ハードウェアが透過的に管理するため既存のプログラムを書き換える必要もなく、この設計はすべての現代CPUの標準になりました。

詳細

L1・L2・L3の分担

現代のCPUキャッシュは通常3段構成です。L1は各コア専用で最速(数十KB、1〜数ナノ秒。命令用とデータ用に分かれる)、L2も各コア専用でやや大きい(数百KB〜1MB程度、十数ナノ秒)、L3は全コア共有で大きい(数十MB、数十ナノ秒)という分担です。CPUはデータが必要になるとL1→L2→L3→主記憶の順に探し、見つかった階層から取り寄せます。L1ヒットと主記憶アクセスの差はおよそ100倍。つまり「キャッシュミスを1回減らす」ことは「命令を数百個減らす」ことに匹敵します。

コア 1L1(数十KB / 数ns)L2(〜1MB / 十数ns)コア 2L1(数十KB / 数ns)L2(〜1MB / 十数ns)L3 共有キャッシュ(数十MB / 数十ns)主記憶 DRAM(数十GB / 約100ns)
マルチコアCPUのキャッシュ構成 — L1/L2はコア専用、L3は全コア共有

キャッシュラインという単位

キャッシュ理解の急所は、データの移動が1バイト単位ではなく「キャッシュライン」(一般に64バイト)というかたまり単位で行われることです。ある1バイトを読むと、その周囲64バイトがまるごとキャッシュに載ります。空間的局所性への賭けであり、続けて隣のデータを読めばタダ同然でヒットします。

この性質は実務のコードに直接効きます。定番の例が2次元配列の走査です。多くの言語では2次元配列は行優先(1行分が連続したメモリ)で置かれるため、行方向にたどれば1ラインの読み込みで連続要素をまとめて処理でき、列方向にたどると毎回別のラインを取りに行ってミスの連続になります。走査の向きを変えるだけで、同じ計算が数倍から10倍以上速くなることは珍しくありません。同様に、ポインタで散らばったリンクリストより連続配置の配列が速い、使うフィールドだけを連続配置する(構造体の配列より配列の構造体)ほうが速い、といった「キャッシュフレンドリー」なデータ構造の定石も、すべてこのライン単位の読み込みから導かれます。データベースのインデックスにB木のような「ノードを太くして段数を減らす」構造が使われるのも、ディスクとメモリという別の階層で同じ発想を適用した例です。

この「走査の向きだけで速度が桁で変わる」現象は、実際にアクセスを1つずつ追うと腑に落ちます。

⚡ 体験: キャッシュラインと走査順序

8×8 の2次元配列(番地 #0〜#63)。メモリ上は行優先で連続に並び、 1キャッシュライン = 連続4要素、キャッシュ容量は4ライン(LRU)です。

ヒット 0 / ミス 0
ヒット ミス→ライン読込 キャッシュ上のライン
キャッシュ(4ライン / LRU)
(空き)
(空き)
(空き)
(空き)

どちらかの走査を実行して、ヒット(緑)とミス(朱)の出方を観察してみてください。

書き込みと一貫性 — マルチコアの難しさ

読み込みだけでなく書き込みもキャッシュを経由します。すると並行処理で厄介な問題が生じます。同じデータのコピーが複数コアのキャッシュに存在し得るため、コア1が書き換えた値をコア2の古いコピーとどう整合させるか — これを解決するのがキャッシュコヒーレンスプロトコル(MESIなど)で、あるコアが書き込むと他コアの同じラインを無効化して一貫性を保ちます。

ここから生まれる有名な性能バグが「偽共有(false sharing)」です。2つのスレッドがそれぞれ別の変数を更新しているだけなのに、その2変数がたまたま同じ64バイトのラインに同居していると、書き込みのたびに互いのキャッシュを無効化し合い、まるで同じ変数を取り合っているかのように遅くなります。論理的には競合ゼロなのに数倍遅くなるため原因に気づきにくく、スレッドごとのカウンタをライン境界にパディングして分離する、といった対策が定番です。

実務での構え方

CPUキャッシュの最適化が本当に必要になるのは、数値計算・ゲーム・データベースエンジンのような性能クリティカルな領域が中心です。とはいえ「メモリは連続に、アクセスは順番に」という原則を知っているだけで、日常のコードでも不利な選択を自然に避けられます。そして重要なのは、推測ではなく計測で確かめることです。プロファイラやCPUのパフォーマンスカウンタでキャッシュミス率を測れば、ボトルネックが計算なのかメモリなのかを切り分けられます。「速いはず」の最適化がキャッシュを乱して逆効果になることもある — ナノ秒の世界でも、モニタリングと同じく計測が最後の審判です。