Skip to content
Published on

CPU Cache & メモリ階層 完全ガイド 2025: L1/L2/L3, Cache Line, False Sharing, 性能最適化

Authors

TL;DR

  • メモリ階層 = 性能の本質: L1 Cache Miss は 100 倍遅い
  • Cache Line (64 bytes): メモリアクセスの単位
  • False Sharing: マルチスレッド性能のキラー
  • Cache-friendly データ構造: Array > LinkedList、SoA > AoS
  • Mechanical Sympathy: ハードウェアを理解すれば 100 倍速い

1. メモリ階層

1.1 なぜ階層が必要か

理想: すべてのメモリが速く、大きく、安ければよいが...

現実のトレードオフ:

  • 速い = 小さい = 高価
  • 遅い = 大きい = 安価

階層構造でバランスをとる。

1.2 メモリ階層 (2025)

サイズレイテンシ備考
Register~32 個 x 64bit1 cycleCPU コア内
L1 cache32-64 KB4 cycles (~1ns)コアごと
L2 cache256 KB-1 MB10 cycles (~3ns)コアごと or 共有
L3 cache (LLC)4-128 MB40 cycles (~12ns)全コア共有
RAM (DDR5)16-512 GB100 cycles (~30ns)システム全体
NVMe SSD1-100 TB100 microseconds永続化
HDD1-20 TB10 millisecondsほぼ未使用

1.3 スケール感

L1 を 1 秒とすると:

  • L2 = 3 秒
  • L3 = 12 秒
  • RAM = 30 秒
  • SSD = 27 時間
  • HDD = 115 日

メモリは L1 より約 100 倍遅い。ディスクは 100 万倍遅い。

1.4 意味

メモリアクセス = コスト
Cache Hit = 安い
Cache Miss = 高い

良いコード = Cache Hit が多いコード


2. CPU Cache の仕組み

2.1 Cache Line

Cache Line は Cache の最小単位、通常 64 bytes

RAM から 1 byte 読む?
-> 64 bytes の Cache Line 単位で取得

効果:

  • 近傍メモリが一緒にキャッシュされる
  • 局所性 (Locality) の自然な活用

2.2 2 種類の局所性

Temporal Locality: 直近アクセスしたものは再アクセスされやすい (ループ変数)。 Spatial Locality: 近傍メモリはすぐアクセスされる (配列走査)。

良いコードは両方を活用する。

2.3 Cache Mapping

  • Direct Mapped: 各アドレスは 1 本の Cache Line のみ
  • Set Associative: 複数本から選択 (8-way が一般的)
  • Fully Associative: どこでも可 (実装困難)

現代の CPU は 8-way Set Associative が主流。

2.4 Cache 置換

  • LRU (Least Recently Used): 最も古い
  • Pseudo-LRU: 近似 (実装が容易)
  • Random: 意外にも LRU と大差ない

3. Cache Miss の種類

3.1 Compulsory Miss (Cold Miss)

初回アクセス。避けられない。

緩和: Prefetch

for (int i = 0; i < n; i++) {
    process(arr[i]);
    // CPU が arr[i+1], arr[i+2] を先読み
}

3.2 Capacity Miss

Cache が小さすぎて発生。

対策: ワーキングセットを Cache に収める、Cache Blocking (Tiling)

// Blocking なし
for (int i = 0; i < n; i++)
    for (int j = 0; j < n; j++)
        for (int k = 0; k < n; k++)
            C[i][j] += A[i][k] * B[k][j];

// Blocking あり
for (int ii = 0; ii < n; ii += BLOCK)
    for (int jj = 0; jj < n; jj += BLOCK)
        for (int kk = 0; kk < n; kk += BLOCK)
            for (int i = ii; i < ii + BLOCK; i++)
                for (int j = jj; j < jj + BLOCK; j++)
                    for (int k = kk; k < kk + BLOCK; k++)
                        C[i][j] += A[i][k] * B[k][j];

10 倍以上高速化することもある。

3.3 Conflict Miss

複数データが同じ Cache Line スロットに写像。Padding やアライメントで対処。


4. Cache-friendly データ構造

4.1 Array vs LinkedList

// Array - Cache friendly
int arr[1000];
for (int i = 0; i < 1000; i++)
    sum += arr[i];  // 連続メモリ、Prefetch が効く
// LinkedList - Cache hostile
struct Node {
    int value;
    struct Node* next;
};

Node* curr = head;
while (curr) {
    sum += curr->value;
    curr = curr->next;  // 場所不明、Cache Miss
}

ベンチマーク: 大規模データで Array は LinkedList より 10-100 倍高速

4.2 AoS vs SoA

AoS (Array of Structures):

struct Point {
    float x, y, z;
    int color;
};

Point points[1000];

for (int i = 0; i < 1000; i++)
    sum += points[i].x;
// 毎回 16 bytes を取得、使うのは 4 bytes のみ

SoA (Structure of Arrays):

struct Points {
    float x[1000];
    float y[1000];
    float z[1000];
    int color[1000];
};

for (int i = 0; i < 1000; i++)
    sum += points.x[i];
// 4 bytes 取得、100% 活用、SIMD も容易

効果: 4-10 倍高速。ゲームエンジン (ECS)、データ分析、ML で使用。

4.3 Struct Padding とアライメント

struct Bad {
    char a;     // 1 byte + 7 padding
    double b;   // 8 bytes
    char c;     // 1 byte + 7 padding
};
// sizeof(Bad) = 24 bytes (16 無駄!)

struct Good {
    double b;   // 8 bytes
    char a;     // 1 byte
    char c;     // 1 byte + 6 padding
};
// sizeof(Good) = 16 bytes

ルール: 大きいフィールドを先に。

4.4 Hash Map の Cache 特性

  • Open Addressing (Robin Hood, Swiss Table): 1 配列に集約、Cache friendly。
  • Separate Chaining (伝統的): 衝突時 LinkedList、Cache hostile。

Google の Swiss Table は SIMD で 16 スロットを同時検索。


5. False Sharing

5.1 問題

struct Counters {
    atomic<int> a;  // 4 bytes
    atomic<int> b;  // 4 bytes
};
// 同じ Cache Line (64 bytes) に配置される

Thread 1 が a を書き、Thread 2 が b を書く。独立だが 同じ Cache Line のため相互無効化、無限 Ping-Pong。

性能低下: 10-100 倍。

5.2 検出

perf c2c record ./my_program
perf c2c report

5.3 対処

Padding:

struct Counters {
    alignas(64) atomic<int> a;
    alignas(64) atomic<int> b;
};

C++17 hardware_destructive_interference_size:

#include <new>
struct Counters {
    alignas(std::hardware_destructive_interference_size) atomic<int> a;
    alignas(std::hardware_destructive_interference_size) atomic<int> b;
};

Thread-local:

thread_local int a;
thread_local int b;

5.4 LongAdder (Java)

// AtomicLong - False Sharing 発生しうる
AtomicLong counter = new AtomicLong();
counter.incrementAndGet();

// LongAdder - セルに分散して False Sharing 回避
LongAdder counter = new LongAdder();
counter.increment();

内部で Padding を利用。16+ スレッドで 100 倍以上高速


6. MESI Protocol

6.1 Cache Coherence

マルチコアで同じ Cache Line を持つと一貫性問題が発生。MESI は 4 状態を管理:

  • M (Modified): dirty、このコアのみ
  • E (Exclusive): clean、このコアのみ
  • S (Shared): clean、複数コア
  • I (Invalid): 無効

6.2 状態遷移

[I] -> read  -> [E] (単独) or [S] (共有)
[E] -> write -> [M]
[S] -> write -> [M] (他コアに invalidate)
[M] -> 他コア read -> [S] + writeback

MOESIMESIF などの派生もある。Coherence Traffic こそ False Sharing のコスト源。


7. NUMA — Non-Uniform Memory Access

7.1 NUMA とは

大規模サーバ (40+ コア) は複数 Socket を持ち、各 Socket に自分のメモリバンクがある。

[Socket 1]      [Socket 2]
   |               |
[Memory 1]    [Memory 2]
   |               |
   +--- Bus -------+
  • ローカルメモリ: ~100ns
  • リモートメモリ: ~200ns

7.2 影響

配置ミスで 50% の性能劣化。

7.3 NUMA-aware プログラミング

numactl --cpunodebind=0 --membind=0 ./my_program
numactl --hardware
#include <numa.h>
void* mem = numa_alloc_local(size);

7.4 ユースケース

  • Databases (PostgreSQL, MySQL): NUMA-aware 設定は必須
  • JVM: -XX:+UseNUMA
  • Redis: 単一スレッドのため NUMA 感度が高い
  • Kubernetes: NUMA Topology Manager

8. Mechanical Sympathy

8.1 概念

Martin Thompson (LMAX Disruptor 創始者) が強調:

"機械がどう動くかを理解すれば、最適なコードが書ける。"

レーサーがエンジンを理解するように、プログラマも CPU を理解すべき。

8.2 LMAX Disruptor

  • 課題: 毎秒 600 万件の金融取引
  • 解: Mechanical Sympathy を極限まで追求
  • 手法: Ring Buffer (配列ベース、Cache friendly)、False Sharing 回避 (Padding)、Single-writer 原則 (ロックフリー)、Pre-allocation (GC 回避)
  • 結果: Java で C++ 相当の性能

8.3 7 原則

  1. Single Writer Principle — ロック不要
  2. Write Combining
  3. Pad to avoid False Sharing
  4. Immutable オブジェクト
  5. GC Pause 回避 (pre-allocate)
  6. Cache-friendly データ構造
  7. ハードウェアを理解する

8.4 例

// Bad - False Sharing
class BadCounter {
    long count;
}

// Good - Padding
class GoodCounter {
    long p1, p2, p3, p4, p5, p6, p7;
    long count;
    long p9, p10, p11, p12, p13, p14, p15;
}

@Contended アノテーション (Java 8+):

@sun.misc.Contended
class Counter {
    long count;
}

9. 計測と Profiling

9.1 perf

perf stat -e cache-misses,cache-references ./my_program

9.2 L1 Miss Rate

perf stat -e L1-dcache-load-misses,L1-dcache-loads ./my_program

L1 Miss Rate が 5% を超えるなら最適化の余地あり。

9.3 Intel VTune

商用 (無料プランあり)。メモリアクセスパターン、False Sharing 検出、SIMD 活用度を分析。

9.4 cachegrind (Valgrind)

valgrind --tool=cachegrind ./my_program
cg_annotate cachegrind.out.PID

関数ごとの Cache Miss レポート。

9.5 Microbenchmark

Google Benchmark (C++):

static void BM_Array(benchmark::State& state) {
    int arr[1000];
    for (auto _ : state) {
        long sum = 0;
        for (int i = 0; i < 1000; i++)
            sum += arr[i];
        benchmark::DoNotOptimize(sum);
    }
}
BENCHMARK(BM_Array);

JMH (Java) も同等機能。


10. 実戦最適化

10.1 Matrix Multiplication

Naive:

for (i = 0; i < N; i++)
    for (j = 0; j < N; j++)
        for (k = 0; k < N; k++)
            C[i][j] += A[i][k] * B[k][j];

B[k][j] が Cache 非親和的。

Loop Swap:

for (i = 0; i < N; i++)
    for (k = 0; k < N; k++)
        for (j = 0; j < N; j++)
            C[i][j] += A[i][k] * B[k][j];

Tiling:

for (ii = 0; ii < N; ii += T)
    for (kk = 0; kk < N; kk += T)
        for (jj = 0; jj < N; jj += T)
            for (i = ii; i < ii+T; i++)
                for (k = kk; k < kk+T; k++)
                    for (j = jj; j < jj+T; j++)
                        C[i][j] += A[i][k] * B[k][j];

SIMD + Threading (BLAS) を加えると Naive の 100 倍以上

10.2 Hash Table 比較

  • std::unordered_map: Separate Chaining、Cache 非親和的
  • Swiss Table / dense_hash_map: Open Addressing + SIMD、2-5 倍高速
  • Robin Hood hashing: もう一つの Cache-friendly 派生

10.3 ゲームエンジン ECS

従来の OOP は Cache に巨大なオブジェクトを乗せる。ECS はコンポーネントごとの配列に分割し、必要な配列だけを走査する。Unity DOTS、Unreal Mass で 10 倍以上高速化。

10.4 Columnar Database

  • Row 指向 (PostgreSQL, MySQL): 1 カラムでも全行を読む
  • Column 指向 (ClickHouse, Druid): 必要なカラムのみ読む

SELECT AVG(age) のようなクエリで Columnar は 100 倍以上高速。


クイズ

1. L1 Cache と RAM のアクセス時間差は?

答え: L1 は ~1ns (4 cycles)、RAM は ~30ns (100 cycles)、約 30 倍差。L1 を 1 秒に例えると RAM は 30 秒、SSD は 27 時間。階層の各段が 10 倍ずつ遅い。だから Cache-friendly コードは 100 倍速い。単なる最適化ではなく本質的な差。

2. False Sharing とは何か、どう防ぐか?

答え: 2 変数が同じ 64 bytes Cache Line に乗ると、スレッドがどちらかに書くたびに他スレッドの Cache が無効化され Ping-Pong 発生、10-100 倍の低下。対策は (1) Padding (alignas(64))、(2) C++17 hardware_destructive_interference_size、(3) thread_local、(4) Java @Contended。検出は perf c2c。

3. AoS と SoA の違いは?

答え: Array of Structures は 1 レコードをまとめて格納。Structure of Arrays はフィールドごとに別配列。SoA は特定フィールドのみ走査するとき Cache 利用率 100% で SIMD も可能、4-10 倍高速。Unity DOTS、Unreal Mass、データ分析、ML で採用。

4. マルチ Socket サーバで NUMA が重要な理由は?

答え: 各 Socket が専用メモリを持ち、ローカル ~100ns、リモート ~200ns。配置ミスで 50% 低下。numactl バインド、numa_alloc_local、JVM -XX:+UseNUMA で対処。PostgreSQL、MySQL、Redis のチューニングに必須。

5. Mechanical Sympathy と LMAX Disruptor の秘訣は?

答え: Martin Thompson の言葉で「機械の動作を理解すれば最適コードが書ける」。Disruptor は Java で 600 万 tx/sec: Ring Buffer (配列、Cache friendly)、Single-writer 原則 (ロックフリー)、Padding (False Sharing 回避)、Pre-allocation (GC 回避)、Immutable。結果、Java で C++ 級性能。


参考資料