メモリ

概要

キャッシュ・仮想メモリ・ページをつなげて理解する

メモリは、CPU のすぐ隣にある単なる保存箱ではありません。速さ、保護、共有、見かけの広さを同時に成立させるための、多層の仕組みです。

要点
メモリを理解する鍵は、階層、局所性、仮想化、ページという4つです。これがわかると、キャッシュミス、ページフォルト、OOM、`mmap` の意味がつながります。

この章で重視すること

目次

  1. メモリ階層
  2. メモリアクセス時間とコスト
  3. 局所性
  4. キャッシュ基礎
  5. キャッシュ詳細
  6. キャッシュコヒーレンシ
  7. 仮想メモリ基礎
  8. 仮想メモリ詳細
  9. ページテーブル
  10. TLB
  11. ページ置換アルゴリズム
  12. メモリアロケータ
  13. ガベージコレクション
  14. メモリ保護
  15. mmap
  16. ページキャッシュ
  17. OOM とメモリ圧迫
  18. NUMA
  19. 次世代メモリ技術
  20. 実務的なメモリ診断
  21. 永続化とジャーナリング
  22. 設計原則として見るメモリ
  23. 比較で理解する
  24. 判断の指針
  25. 実務ミニケース
  26. FAQ
  27. 参考文献

メモリ階層

計算機の記憶は一種類ではありません。

  • レジスタ
  • L1 / L2 / L3 キャッシュ
  • DRAM
  • SSD
  • HDD

のように、速さと容量の異なる層があります。

graph LR A[レジスタ] --> B[L1] B --> C[L2] C --> D[L3] D --> E[DRAM] E --> F[SSD] F --> G[HDD]

上へ行くほど速くて小さく、下へ行くほど遅くて大きいです。

なぜ 1 種類で済まないのか

理想は「巨大で、安くて、永続化もできて、CPU 並みに速いメモリ」ですが、現実にはそんな都合のよい記憶装置はありません。そこで、

  • 速いが高価で小さい層
  • 遅いが安くて大きい層

を重ねて、全体として折り合いをつけています。

メモリ階層は苦肉の策であると同時に、計算機の性能を支える中心設計です。

階層の容量と速度のトレードオフ

メモリ階層の特性は、以下の図で示されるように指数関数的なトレードオフを示します。容量が10倍増えると、アクセス時間も10倍近く遅くなることが多いです。

graph TB subgraph speed["アクセス時間 2025年"] direction LR R[レジスタ 0.25-0.5 ns 1-2 cycles] L1[L1 キャッシュ 4-5 ns 12-20 cycles] L2[L2 キャッシュ 12-20 ns 40-60 cycles] L3[L3 キャッシュ 40-70 ns 120-200 cycles] DRAM[DRAM 100-150 ns 300-500 cycles] SSD[SSD 10-100 μs 30K-300K cycles] HDD[HDD 1-10 ms 3M-30M cycles] end subgraph capacity[容量] direction LR R2[32 KB] L12[32-64 KB] L22[256 KB] L32[8-48 MB] D[16-256 GB] S[256 GB-2 TB] H[2-12 TB] end speed --> capacity
重要なポイント
CPU から見たアクセス時間は、L3 キャッシュミスで既に**数百サイクル**の遅延が発生します。一度 DRAM にアクセスすると数千サイクル、SSD なら数百万サイクルです。この差を吸収するために、上の層が存在します。

メモリアクセス時間とコスト

2025-2026 年の具体的な数値

現在のメインストリーム CPU(Intel Core Ultra、AMD Ryzen 9000、Apple M4)では、次のアクセス時間が観測されます。

階層 アクセス時間 CPU サイクル 容量 帯域幅
レジスタ 0.25-0.5 ns 1-2 512 B - 2 KB 制限なし
L1D キャッシュ 4-5 ns 12-20 32-48 KB 256-512 GB/s
L2 キャッシュ 12-20 ns 40-60 256-512 KB 128-256 GB/s
L3 キャッシュ 40-70 ns 120-200 8-48 MB 64-128 GB/s
DRAM 100-150 ns 300-500 16-256 GB 64-100 GB/s
PCIe Gen4 SSD 20-100 μs 60K-300K 256 GB-2 TB 5-7 GB/s
HDD(7200 RPM) 1-10 ms 3M-30M 1-12 TB 100-200 MB/s

コスト指標

2026 年現在の市場価格から見た「アクセス時間あたりのコスト」:

  • レジスタ:文字通り CPU の一部
  • L1-L3 キャッシュ:ダイ上に統合(チップ面積で表現)
  • DRAM:約 $5-10 GB(消費者向け DDR5)
  • NVMe SSD:約 $0.05-0.10 GB(高速 PCIe Gen4)
  • HDD:約 $0.01-0.02 GB(容量志向)

結論: 速いほど高い。DRAM は DRAM のままで、SSD でなく DRAM に載るデータ量が性能を左右します。


局所性

キャッシュが効く背景には、局所性があります。

  • 時間局所性: 最近使ったものをまた使う
  • 空間局所性: 近い場所をまとめて使う
  • 流れ局所性(順序局所性): 逐次的なアクセスパターン(ストリーミング)

配列を順に走査すると速くなりやすいのは、この空間局所性があるからです。

局所性は性能の共通言語

局所性はメモリだけの話ではありません。DB のページキャッシュ、OS のファイルキャッシュ、CPU キャッシュ、CDN まで、かなり広い世界で効く考え方です。

つまり「近いものや最近使ったものをうまく再利用する」と速くなる、という原理があちこちで繰り返されています。

Working Set(ワーキングセット)

ある時間 T に頻繁にアクセスされるメモリ領域の集合を Working Set と呼びます。

  • Working Set がキャッシュに収まっていれば、キャッシュヒット率が高い
  • Working Set が大きくなると、キャッシュとの関係が緩くなり、ページフォルトが増える

OS や VM の性能は、多くの場合、「各プロセスの Working Set をメモリに乗せきれるか」で決まります。


キャッシュ基礎

CPU と DRAM の速度差を埋めるためにキャッシュがあります。

  • L1: とても速い(4-5 ns、12-20 cycles)
  • L2: 少し遅い(12-20 ns、40-60 cycles)
  • L3: さらに遅いが大きい(40-70 ns、120-200 cycles)

キャッシュミスが多いと、CPU はメモリ待ちで止まりがちになります。

キャッシュラインの発想

キャッシュは通常、1 バイトずつではなく一定サイズの塊でやり取りします。これをキャッシュラインと呼びます。

現代 CPU(2025年)では、キャッシュラインサイズは 64 バイト が標準です(Intel、AMD 共通)。

例えば、配列の要素1個にアクセスするだけで、その周辺 64 バイトが DRAM から L1 キャッシュへ引き込まれます。

だから、

  • 近いデータをまとめて使うと得
  • 離れた場所を飛び飛びに触ると不利

になりやすいです。

キャッシュラインの例

// 例1:キャッシュに優しいアクセス
int sum = 0;
for (int i = 0; i < 1000; i++) {
    sum += array[i];  // 連続アクセス → 1つのキャッシュラインで複数要素
}

// 例2:キャッシュに厳しいアクセス
int sum = 0;
for (int i = 0; i < 1000; i += 32) {  // stride = 32
    sum += array[i];  // キャッシュラインをまたぐ → ミス増加
}

// 例3:2次元配列の走査順
// 行優先(C言語的、メモリ上は連続)
for (int i = 0; i < N; i++) {
    for (int j = 0; j < N; j++) {
        matrix[i][j] += 1;  // 空間局所性がある
    }
}

// 列優先(キャッシュに厳しい)
for (int j = 0; j < N; j++) {
    for (int i = 0; i < N; i++) {
        matrix[i][j] += 1;  // 飛び飛びアクセス
    }
}

キャッシュ詳細

キャッシュマッピング方式

キャッシュの容量は限定されているため、メモリのどのアドレスをキャッシュのどこに置くかは重要です。3つの主な方式があります:

1. Direct Mapped(ダイレクト・マップ)

各メモリアドレスが、キャッシュ内の1つの場所にだけ対応付けられます。

メモリアドレス = [ タグ | インデックス | オフセット ]
               [高    ]            [低]

キャッシュ【インデックス】→ タグ照合 → ヒット/ミス

利点: シンプル、高速 欠点: 容量不足で追い出しが頻発(キャッシュスラッシング)

例:L1 キャッシュが 32 KB、キャッシュラインが 64 B なら、512 ラインあります。メモリアドレス 0x0000 と 0x8000 が同じラインにマップされると、交互アクセスでミスが続発します。

2. Fully Associative(フルアソシアティブ)

メモリのどのアドレスもキャッシュのどこにでも置けます。ヒット判定には全ラインを検索する必要があります。

メモリアドレス = [ タグ | オフセット ]

全キャッシュラインを並列比較 → ヒット/ミス

利点: 容量を有効活用できる 欠点: 比較回路が大きく、消費電力が多い

3. Set-Associative(セット・アソシアティブ)

キャッシュを複数の「セット」に分割し、各セット内ではフルアソシアティブに振る舞います。

メモリアドレス = [ タグ | セット | オフセット ]

キャッシュ【セット】→ 複数ラインを比較 → ヒット/ミス

例:8-way Set Associative で 64 KB キャッシュなら、64 KB ÷ 64 B/line ÷ 8 way = 128 セット。

利点: Direct Mapped と Fully Associative の中間。実装効率が良い 欠点: わずかに複雑

現代 CPU(2025年)の構成:

  • L1: 8-way または 12-way Set-Associative
  • L2: 4-way または 8-way Set-Associative
  • L3: 12-way または 16-way Set-Associative(ただし inclusive policy)

Write Back と Write Through

キャッシュにデータを書き込むときの戦略:

Write Back(ライトバック)

キャッシュに書き込み、あとで DRAM に書き戻す。

CPU
  ↓ write
キャッシュ(modified)
  ↓(evict時に)
DRAM

利点: バス帯域を節約できる 欠点: キャッシュが modified であることを追跡する必要(dirty bit)

Write Through(ライトスルー)

キャッシュと DRAM に同時に書き込む。

CPU
  ↓ write
  ↓
キャッシュ
↓
DRAM

利点: シンプル 欠点: メモリバスが混雑

現代 CPU は Write Back を採用。複数ライターがいる環境(マルチコア)では必須です。

キャッシュコヒーレンシの問題(先行)

マルチコア環境では、複数の CPU がそれぞれキャッシュを持つため、同じメモリアドレスについて各キャッシュが異なる値を持つ可能性があります。これを キャッシュコヒーレンシ 問題と呼びます。詳しくは後で述べます。


キャッシュコヒーレンシ

マルチコア環境では、複数の CPU が同じメモリを共有しています。各 CPU が独立したキャッシュを持つため、整合性を保つ必要があります。

graph TB subgraph core1[コア1] L1a[L1 キャッシュ] CPU1[CPU1] end subgraph core2[コア2] L1b[L1 キャッシュ] CPU2[CPU2] end subgraph core3[コア3] L1c[L1 キャッシュ] CPU3[CPU3] end L3[L3 キャッシュ shared] DRAM[DRAM] CPU1 --> L1a CPU2 --> L1b CPU3 --> L1c L1a --> L3 L1b --> L3 L1c --> L3 L3 --> DRAM

MESI プロトコル

最も有名なキャッシュコヒーレンシプロトコルは MESI(Modified, Exclusive, Shared, Invalid)です。各キャッシュラインは4つの状態を持ちます:

状態 説明 読取 書込
M (Modified) 自コアが修正済み、他は無効 自由 自由、invalidate不要
E (Exclusive) 唯一、他は無効 自由 自由、状態変わらず
S (Shared) 複数コアが読取 自由 要 RFO(Read For Ownership)
I (Invalid) 無効 不可 不可

例:共有メモリを2つのコアが読み書きする場合

初期状態:両キャッシュで I

Core 1 が読み
    Core 1: IE (独占)
    Core 2: I

Core 2 が読み
    Core 1: ES (シェア) + invalidate
    Core 2: IS

Core 1 が書き
    Core 1: SM + invalidate broadcast
    Core 2: SI
    (他のコアも I になる)

MOESI, MESIF, Directory ベース

他にも派生プロトコルがあります:

  • MOESI:AMD が採用。Owner 状態を追加し、shared から modified への遷移を最適化
  • MESIF:Intel が採用。Forward 状態を追加
  • Directory Based:大規模 NUMA システムで使用。中央ディレクトリでコヒーレンシを追跡

False Sharing(偽共有)

別々のデータなのに同じキャッシュラインに乗ると、片方を修正するたびに他方も invalidate される現象を false sharing と呼びます。

// 例:False Sharing
struct {
    int counter_A;  // byte 0-3
    int counter_B;  // byte 4-7
} shared;

// スレッド1
while (true) {
    shared.counter_A++;  // byte 0-3 を修正
}

// スレッド2
while (true) {
    shared.counter_B++;  // byte 4-7 を修正
}

// 問題:両者が同じ 64B キャッシュラインを修正
// → Core 1 が書き込み → Core 2 invalidated
// → Core 2 が書き込み → Core 1 invalidated
// → 結果、メモリバスが混雑し、性能が大幅低下

対策: Padding(パディング)で物理的に分離

struct {
    int counter_A;
    char padding[60];  // キャッシュラインの残りを埋める
} shared;

struct {
    int counter_B;
    char padding[60];
} other;

キャッシュトラッシング(Cache Thrashing)

Working Set がキャッシュサイズより大きい場合、頻繁に追い出しが発生し、ヒット率が著しく低下する現象。

Working Set >> キャッシュサイズ

→ 毎アクセスで miss → キャッシュラインが入れ替わる
→ その直後、古いアドレスを参照 → また miss
→ 結果、hit rate は数%に低下

対策:

  1. Working Set を小さくする(データ構造最適化)
  2. キャッシュサイズを拡張(ハードウェア投資)
  3. アクセスパターンを改善(アルゴリズム最適化)

仮想メモリ基礎

各プロセスに「広くて連続したアドレス空間があるように見せる」仕組みです。

利点:

  • 保護
  • 隔離
  • 見通しのよいアドレス空間
  • 必要なページだけ載せる demand paging

仮想メモリは「大きく見せる」だけではない

「RAM が足りないぶんをディスクでごまかす仕組み」とだけ覚えると、本質を外します。仮想メモリの大事な価値は、

  • 各プロセスを隔離できる
  • 同じアドレスを別プロセスで安全に使える
  • カーネル空間とユーザー空間を分けられる
  • ページ単位で保護属性を変えられる

ことです。

つまり仮想メモリは、性能の仕組みでもあり、保護と抽象化の仕組み でもあります。

flowchart LR A[仮想アドレス] --> B[ページテーブル] B --> C[物理アドレス]

Demand Paging(遅延割り当て)

仮想メモリの実装では、全プロセスが全ページを一度に DRAM に持つわけではなく、アクセスがあったときに初めて DRAM に読み込みます。これを demand paging と呼びます。

利点:

  • メモリを効率的に使用
  • 初期化が高速(全ページを用意するまで待たない)
  • 大きなプロセスでも小さい Working Set なら高速

発動タイミング:

  1. malloc()new で メモリを要求 → ページテーブルだけ更新、実ページは未割り当て
  2. そのメモリにアクセス → ページフォルト(trap)
  3. OS が実ページを割り当て → ハンドラから戻る

この「アクセス時にページが用意される」流れが demand paging です。

Copy-on-Write(CoW)

fork() システムコールで親プロセスを複製するとき、全ページをコピーするのは無駄です。代わりに、読取時は共有し、書込時にコピーする戦略を CoW と呼びます。

fork() 直後
    親ページテーブル --共有--> 物理ページ
    子ページテーブル --共有--

親が書き込み
    → ページフォルト(protection fault)
    → OS がページをコピー
    → 親ページテーブル --> 新物理ページ(親用)
    子ページテーブル --> 元物理ページ(子用)

効果: fork 後すぐに exec する場合(シェルなど)、ページコピーのオーバーヘッドが激減。


仮想メモリ詳細

セグメンテーション vs ページング

仮想メモリの実装方式は2つあります:

セグメンテーション(古い方式)

論理的な単位(コード、データ、スタック)ごとにセグメント化し、各セグメントの開始アドレスとサイズをセグメントテーブルで管理。

論理アドレス = [ セグメント番号 | オフセット ]
             
セグメント[N] = {base, limit}

物理アドレス = base + offset

問題: 外部断片化(fragmentation)が起きやすい。

ページング(現代的)

メモリを固定サイズ(通常 4 KB)のページに分割し、ページテーブルで対応付け。

仮想アドレス = [ ページ番号 | オフセット ]
             
ページテーブル[ページ番号] = {フレーム番号, 権限, ...}

物理アドレス = (フレーム番号 << 12) + オフセット

利点: 外部断片化なし、メモリ効率が良い。

現代 OS はほぼすべてページングを採用。Intel, AMD, ARM, MIPS など。

ページサイズの選択

一般的なページサイズは 4 KB(64 bits x86-64)ですが、変動します:

アーキ 標準 代替
x86-64 4 KB 2 MB, 1 GB (huge pages)
ARM 4 KB 2 MB, 1 GB (huge pages)
Power 4 KB 16 MB, 16 GB
MIPS 4 KB 16 MB

小さいページ(4 KB)の利点:

  • ページテーブルが柔軟
  • 外部断片化を最小化

小さいページの欠点:

  • ページテーブルが大きくなる
  • TLB ミスが増える

大きいページ(2 MB, 1 GB)の利点:

  • ページテーブルが小さい
  • TLB ミス率が低い
  • DRAM 帯域が節約

大きいページの欠点:

  • 外部断片化のリスク
  • 小さなアロケーションが無駄になりやすい

Transparent Huge Pages (THP)

Linux 2.6.38+ では、Transparent Huge Pages という機構が導入され、4 KB ページを自動的に 2 MB ページに昇格させる。

通常:4 KB ページ × 512 = 2 MB
THP:2 MB ページ × 1 = 2 MB(ページテーブル階層 1段削減)

効果: TLB ミス率が大幅に低下(10-20%)。

問題: 昇格・降格のコストと、メモリ断片化のリスク。


ページテーブル

仮想アドレスから物理アドレスへの変換は、ページテーブルで行われます。

ページテーブル構造(x86-64)

x86-64 では、64 ビットアドレスを複数レベルのテーブルで管理します。

4-level ページテーブル(Intel Ivy Bridge 以前、AMD Bulldozer)

仮想アドレス(48 ビット有効)
[ PML4 | PDPT | PD | PT | offset ]
[ 9 b  | 9 b  |9 b |9 b|12 b    ]

PML4(ページマップレベル4)テーブル(512エントリ × 8B = 4 KB)
  ↓
PDPT(ページディレクトリポインタテーブル)(512エントリ)PD(ページディレクトリ)(512エントリ)PT(ページテーブル)(512エントリ)4 KB ページフレーム

各エントリは 8 バイト。全プロセスに全テーブルを割り当てると、メモリの浪費。

5-level ページテーブル(Intel Coffee Lake 以後、 La Core Ultra)

57 ビットアドレス対応:

[ PML5 | PML4 | PDPT | PD | PT | offset ]
[ 9 b  | 9 b  | 9 b  |9 b |9 b |12 b    ]

何が増えたか: PML5(ページマップレベル5)が追加。アドレス空間が 2^48 から 2^57 に拡大。

ページテーブルエントリ(PTE)の構造

各 PTE は 64 ビット:

[ 物理フレームアドレス(40-51 bit) | D | A | PCD | PWT | U/S | R/W | P ]
  • P(Present): 1 = 物理ページが DRAM に存在
  • R/W(Read/Write): 1 = 書込許可、0 = 読取のみ
  • U/S(User/Supervisor): 1 = ユーザーモード許可、0 = カーネルのみ
  • PWT/PCD: キャッシュ制御ビット
  • A(Accessed): アクセスされたら 1 に(ページ置換アルゴリズムで使用)
  • D(Dirty): 書き込まれたら 1 に(Write-back 管理で使用)

ページテーブルウォーク

仮想アドレスを物理アドレスに変換するには、複数階層のテーブルを順に辿ります(TLB ミスの場合):

仮想アドレス 0x7f1234567890
[ 0x123 | 0x045 | 0x006 | 0x789 | 0x890 ]
(PML4)   (PDPT)  (PD)    (PT)    (offset)

CR3 レジスタ → PML4 テーブルベース
PML4[0x123] → PDPT テーブルへのポインタ
PDPT[0x045] → PD テーブルへのポインタ
PD[0x006] → PT テーブルへのポインタ
PT[0x789] → 物理フレームアドレス(+ 0x890 オフセット)

コスト: 各テーブルアクセスは DRAM アクセス。4段階あると、最悪 4 回の DRAM アクセス。これが TLB ミス時のコストです。


TLB

Translation Lookaside Buffer(TLB)は、ページテーブル変換結果をキャッシュする高速メモリです。

TLB の役割

flowchart TB V["仮想アドレス"] --> T["TLB 参照 (並列、高速)"] T --> H["ヒット: 物理アドレス即座に返却 (4-5 ns)"] T --> M["ミス: ページテーブルウォーク (100-1000 ns)"]

TLB エントリ

[ VPN(仮想ページ番号) | PFN(物理フレーム番号) | 権限 | ASID ]
  • VPN: ページテーブルの検索キー
  • PFN: 物理フレーム番号
  • 権限: Read, Write, Execute など
  • ASID(Address Space ID): プロセス ID(context switch 時にフラッシュ不要にする)

TLB サイズ

2025 年の CPU:

階層 サイズ 遅延
TLB L1(Instruction) 128 エントリ 1-2 ns
TLB L1(Data) 128-256 エントリ 1-2 ns
TLB L2 512-1024 エントリ 5-10 ns

通常、4 KB ページで計算すると:

  • TLB L1(128 エントリ)= 512 KB アドレス範囲カバー
  • 大規模アプリケーション(Working Set が数 GB)は TLB ミス率が高い

TLB ミスの影響

TLB ヒット:4-5 ns(1-2 cycles)
TLB ミス + ページテーブルウォーク:100-1000 ns(300-3000 cycles)

→ TLB ミス率 1% で、平均レイテンシ = 0.99 × 5 + 0.01 × 500 = 10 ns
→ TLB ミス率 10% で、平均レイテンシ = 0.9 × 5 + 0.1 × 500 = 54.5 ns

対策:

  1. Huge Pages(2 MB, 1 GB): TLB エントリ1個で大きなアドレス範囲をカバー
  2. ワーキングセット削減: データ構造最適化
  3. アクセスパターン改善: 局所性向上

ASID(Address Space ID)と PCID(Process Context ID)

プロセス切り替え時に TLB をクリア(flush)するのは高コスト。最新 CPU では ASID/PCID を導入:

TLB エントリ = [ VPN | PFN | ASID ]

プロセス A(ASID=1)と B(ASID=2)が
別々のアドレス空間でも、
TLB の同じエントリに存在可能(ASID で区別)

→ Context switch 時に TLB フラッシュ不要

効果: Context switch コストが 10-20% 削減。


ページ置換アルゴリズム

メモリが満杯で新しいページが必要なとき、どのページを追い出すかを決めるアルゴリズム。

FIFO(First In, First Out)

最も古いページを追い出す。

実装: キューで管理。

ページ BCDE(FIFO キュー)
             ↑
           最後(次に追い出し)

新しいページ F が必要
→ B を追い出し
→ CDEF

問題: Bélády’s anomaly — ページ数を増やしても hit rate が下がることがある。

LRU(Least Recently Used)

最後に使われたのが最も昔のページを追い出す。

使用時刻:
B: 時刻 100
C: 時刻 200
D: 時刻 150
E: 時刻 210

最も昔 = D(時刻 150)→ 追い出し

実装: リンクリスト(複雑)、ビットマップ(近似)。

効果: Locality と合致するため、FIFO より優れた hit rate。

Clock Algorithm(時計アルゴリズム)

LRU の近似版で、実装がシンプル。各ページテーブルエントリに accessed bit を持つ。

ページリング:
      ↓
ページ1(A=1)→ ページ2(A=1)→ ページ3(A=0)→ ページ4(A=1)
                                  ↑
                          ポインタがここ→ A=0 だから追い出し

ルール:

  1. ポインタが指すページが A=1 なら、A=0 に クリア、次へ進む
  2. A=0 なら、そのページを追い出す

効果: O(1) の追い出し、LRU に近い性能。

ARC(Adaptive Replacement Cache)

recent(最近アクセス)と frequent(頻繁)を分離。両方のキャッシュから動的に割き当て量を調整。

ARC は2つキャッシュを持つ:
- T1:最近1回だけアクセス
- T2:複数回アクセス

T1, T2 の割り当てを動的に調整 → LRU より高い hit rate

効果: スキャン耐性が高い(大規模シーケンシャル読みでキャッシュが汚染されない)。

LIRS(Low Inter-reference Recency Set)

参考回数(inter-reference recency)に基づいて優先度を付与。

IR(参考間隔):現在時刻 - 最後の参照時刻

IR が小 → 頻繁に使われている → キープ
IR が大 → 久しく使われていない → 追い出し候補

効果: Skewed(偏った)アクセスパターンで LRU より高い hit rate。

CAR(Clock with Adaptive Replacement)

Clock と ARC を組み合わせ。実装が単純で、性能が高い。


メモリアロケータ

システムから確保したメモリをプログラムに分配するコンポーネント。

システムコール:brk, mmap

brk / sbrk

ヒープの終端を拡張。

初期ヒープ:
[ text | initialized data | uninitialized data | ヒープ(small) | ... ]

brk(new_brk) で拡張:
[ text | ... | ヒープ(large) | ... ]
              ↑
            new_brk(ヒープの新たな終端)

コスト: システムコール1回(数マイクロ秒)。

mmap

任意のメモリ領域を確保。ファイルにマップすることもできる。

void *p = mmap(NULL, size, PROT_READ|PROT_WRITE,
               MAP_ANONYMOUS|MAP_PRIVATE, -1, 0);

コスト: システムコール1回 + ページテーブル管理。大きなサイズに適している。

ユーザースペースアロケータ

malloc は直接システムコールを呼ばず、あらかじめ brk/mmap で大きいメモリを確保し、内部で管理。

ptmalloc2(glibc の malloc)

flowchart TB A["Arena (スレッドごと)"] --> B0["bin[0] (8 バイト)"] A --> B1["bin[1] (16 バイト)"] A --> B2["bin[2] (24 バイト)"] A --> BX["..."] A --> BN["bin[n] (large)"]

各 bin は free list。malloc は適切なサイズの bin から取得。

特徴:

  • Multi-threaded(複数スレッド)対応
  • Lock per arena(contention 削減)
  • 各スレッドが自分の arena を持つ

問題: 複数 arena 間での メモリリバランスが難しい。長寿命プロセスで断片化が起きやすい。

jemalloc

Facebook が開発。ptmalloc より局所性が高く、断片化が少ない。

flowchart TB R["Run (小単位、64 KiB)"] --> S["Small (0-14 KiB)"] R --> L["Large (14-64 KiB)"] R --> H["Huge (> 64 KiB)"] R --> M["各 run 内でサイズ階級ごとに管理"]

特徴:

  • 局所性が高い(同じサイズクラスのオブジェクトが近い)
  • マルチスレッド効率が高い
  • Memory fragmentation が低い(= 10% 程度)

採用例: Firefox, Redis, MySQL。

tcmalloc(Google)

Thread-local cache を持つ。各スレッドがローカルキャッシュから最初に取得。

Thread-local cache(スピンロック不要)
  ↓ miss
Global heap cache(スピンロック有り)
  ↓ miss
Central free list
  ↓ miss
mmap で新メモリ確保

特徴:

  • 極めて低いロック競合(スレッド間の干渉が少ない)
  • CPU キャッシュ友好的

採用例: Chrome, Bigtable。

mimalloc(Microsoft)

最新のマルチコア PC 向けに設計。

【Segment】(ページ単位)
  各スレッドが自分のセグメント → ロック不要
  
スレッド間の steal:
  スレッド1が空ければ、スレッド2が steal
  → Dynamic load balancing

特徴:

  • ほぼロック不要(lock-free)
  • メモリ効率が高い(< 5% fragmentation)
  • 消費電力が少ない

snmalloc(Microsoft)

mimalloc の後続。さらなる最適化。


ガベージコレクション

手動で free/delete しなくても、使わなくなったメモリを自動回収する仕組み。Java, Go, Python など多くの言語が採用。

参照カウント(Reference Counting)

各オブジェクトが「何個の参照を受けているか」を数える。カウントが0になったら回収。

Object *obj = new Object();  // refcount = 1
Object *other = obj;          // refcount = 2
delete other;                 // refcount = 1
delete obj;                   // refcount = 0 → 自動 free

利点: シンプル、即座に回収

欠点: 循環参照が回収できない

A → B → A(循環)
A の refcount = 1(B から参照)
B の refcount = 1(A から参照)
→ どちらも削除されず、メモリリーク

Mark-Sweep(マーク・スイープ)

使用中のオブジェクトをマークし、マークされていないオブジェクトを回収。

ルート
  ↓
グローバル変数、スタック、レジスタ参照オブジェクトから辿る
  ↓
【Mark フェーズ】
  訪問したオブジェクトに印付与(リカーシブ)
  ↓
【Sweep フェーズ】
  ヒープを走査、印なしオブジェクトを free

利点: 循環参照を回収できる

欠点: 一時停止時間が長い(GC pause)

例:

GC pause: 50 ms
→ アプリケーション停止
→ 高遅延アプリ(ゲーム、オンライントレード)では問題

Mark-Compact(マーク・コンパクト)

Mark-Sweep に加え、生きているオブジェクトをメモリ前方に詰める(defragmentation)。

Before:[ OBJ1 | free | OBJ2 | free | OBJ3 ]
After: [ OBJ1 | OBJ2 | OBJ3 ]

利点: メモリ効率が高い、キャッシュ局所性が高い

欠点: コンパクト処理のコストが高い(全オブジェクトを移動)

Generational GC(世代別 GC)

「新しいオブジェクトはすぐ死ぬ」という観察に基づく。

【Young 世代】(0-1 歳)
  小さいエリア、頻繁に GC
  → 短い GC pause

【Intermediate 世代】(1-5 歳)
  中程度エリア、たまに GC

【Old 世代】(5+ 歳)
  大きいエリア、稀に GC

効果: 大多数の GC が Young 世代だけを対象 → pause time 短縮。

採用: JVM(Serial, Parallel, CMS, G1)、Python、.NET。

Concurrent & Incremental GC

GC を小分けにして、アプリと並行実行。

Incremental:
GC少し → App実行 → GC少し → App実行 → ...
→ pause time の最大値が小さい

Concurrent:
GC(別スレッド) ← → App(メインスレッド)
→ GC overhead が増えるが、pause time は最小化

例: CMS GC(Oracle JVM, deprecated)、G1 GC、ZGC。

JVM GC の進化(2025年)

GC タイプ Pause スループット 用途
Serial Generational 可変(大) シングルスレッド
Parallel Generational 可変(中) 最高 バッチ処理
CMS Incremental 低(~100ms) Web サーバー
G1 Generational + Incremental 低(~200ms) 標準
ZGC Concurrent low-latency 超低(<10ms) 高遅延要求
Shenandoah Concurrent OpenJDK

Go の GC:三色マーキング

Go は write barrier を使う concurrent GC。

三色:
- Black:スキャン完了、子も safe
- Gray:スキャン中、子が未確認
- White:未スキャン

初期:すべて White

GC mark phase1. ルートを Gray2. Gray から訪問 → オブジェクト Black3. 訪問先のオブジェクト Gray4. Gray が空になったら終了

ResultBlack のみ生き残り

特徴: pause time が少ない(< 100μs)、スループット 99%+。


メモリ保護

Read/Write/Execute(RWX)権限

仮想メモリの基本的な保護機構。各ページに権限を設定:

ページテーブルエントリ:
[ ... | R | W | X | ... ]

R=1:読取可能
W=1:書込可能
X=1:実行可能

例: コードセグメント(R, X)、データセグメント(R, W)。

ASLR(Address Space Layout Randomization)

メモリレイアウトをランダム化し、バッファオーバーフロー攻撃を困難に。

実行のたび、base address が異なる

実行1:コード @ 0x400000, ヒープ @ 0x600000
実行2:コード @ 0x560000, ヒープ @ 0x760000

→ 攻撃者はハードコードされた return address を使えない

採用: Linux(2.6.12+)、Windows(Vista+)、macOS。

DEP(Data Execution Prevention)/ NX(No eXecute)

スタックやヒープを非実行化。バッファオーバーフロー後のコード注入を防ぐ。

スタック:R/W(読取・書込可) × (実行不可)
ヒープ:R/W(読取・書込可) × (実行不可)
コード:R/X(読取・実行可) × (書込不可)

→ スタック上のシェルコード実行不可

採用: x86 の NX ビット(2003)、ARM の XN ビット。

SMEP(Supervisor Mode Execution Protection)

カーネルが誤ってユーザースペースのコードを実行することを防ぐ。

ユーザーページ:U=1, X=1
カーネルが実行 → ページフォルト(protection fault)

効果: カーネルエクスプロイト難化。

SMAP(Supervisor Mode Access Prevention)

カーネルがユーザースペースのメモリを読み書きすることを防ぐ(一部例外あり)。

クローズドバグ例:
カーネル bug で ユーザーバッファに直接アクセス
→ SMAP あれば protection fault → 検出容易

mmap

mmap はファイルやデバイスをアドレス空間へ写像する仕組みです。

  • read: コピーして受け取る
  • mmap: その内容がある領域を自分の空間へつなぐ

大量ファイルや共有メモリ、効率的 I/O を理解するときに重要です。

read との違いを直感で

read は「倉庫から荷物を机に持ってくる」感じです。mmap は「倉庫の棚へ机から直接アクセスできるようにする」感じに近いです。

もちろん内部ではページフォルトやページキャッシュが関わるので単純ではありませんが、発想としては

  • 明示的にコピーするか
  • アドレス空間の見え方を変えるか

の違いです。

mmap の種類

ファイル写像

void *p = mmap(NULL, size, PROT_READ|PROT_WRITE,
               MAP_SHARED, fd, offset);

MAP_SHARED: 変更がファイルに反映 MAP_PRIVATE: Copy-on-Write、変更は自プロセスのみ

匿名写像(共有メモリ)

void *p = mmap(NULL, size, PROT_READ|PROT_WRITE,
               MAP_SHARED|MAP_ANONYMOUS, -1, 0);

プロセス間共有メモリ。fd を -1 に指定。

何に使うか

  • 大きなファイルを効率よく読む(ページキャッシュを活用)
  • プロセス間で共有メモリを使う
  • ライブラリや実行ファイルを読み込む

OS の内部でも非常に重要です。

flowchart LR A[ファイル] --> B[ページキャッシュ] B --> C[プロセスのアドレス空間]

mmap の性能特性

read() との比較

read(fd, buf, 100 MB):
  - システムコール1回 → kernel space
  - ファイル読み込み(ページキャッシュ経由)
  - memcpy(buf, page_cache) → user space
  - システムコール戻る
  - 合計コスト:copy cost + syscall overhead

mmap(fd, 100 MB) → 初回 + 各ページアクセス:
  - システムコール1回(mmap自体)
  - ページキャッシュに対応付け(ページテーブル更新)
  - 後のアクセス → ページキャッシュ直接アクセス(copy なし)
  - 合計コスト:syscall + page table walk

結論:

  • 小さいファイル(< 10 MB):read で十分
  • 大きいファイル(> 100 MB):mmap で copy コスト削減
  • ランダムアクセス:mmap が有利(copy なし)
  • シーケンシャル読み:read でも十分(prefetch による最適化)

ページキャッシュ

ファイル I/O の世界では、ディスクを毎回そのまま読みに行くのではなく、OS がメモリ上へ持ったページキャッシュが大きな役割を果たします。

ページキャッシュの構造(Linux)

【ファイル】
  inode
    ↓
  【radix tree(または xarray)】← ページキャッシュの索引
    ↓
  【物理メモリページ】(各ページ 4 KB)

ファイルの offset から対応するページを高速に検索。

アドレス空間

Linux では、各ファイルに対応するページキャッシュを address_space という構造体で管理:

struct address_space {
    struct inode *host;           // 対応する inode
    struct xarray i_pages;        // ページ索引
    unsigned long nrpages;        // ページ数
    struct list_head i_mmap_writable;
    // ...
};

何がうれしいのか

  • 何度も読むデータを速く返せる
  • read でも mmap でも共通の土台として使える
  • 書き込みをまとめやすい(write-back)

つまりページキャッシュは、「ファイルシステムのためのキャッシュ」であり、メモリとストレージの橋渡しです。

ページキャッシュの管理:Active / Inactive リスト

Linux カーネルは、ページを activeinactive に分類:

Active】
  最近アクセスされたページ
  メモリ圧迫時も回収されにくい

【Inactive】
  アクセスから時間経過
  メモリ圧迫時に回収候補

ページアクセス:
InactiveActive への昇格
複数回アクセス必要

目的: Working Set を Active に保つ → メモリ効率向上。

ページキャッシュの統計

Linux では free, vmstat コマンドで確認可能:

$ free -h
              total        used        free      shared     buffers     cached
Mem:          31Gi        28Gi       2.9Gi      1.2Gi      120Mi        18Gi
Swap:          32Gi       5.3Gi        27Gi

# cached = ページキャッシュ(18 GB)

OOM とメモリ圧迫

メモリが苦しいとき、システムは単に遅くなるだけではなく、回収、追い出し、場合によってはプロセス終了まで行います。

メモリ圧迫の段階

1. Green(余裕がある)

利用可能メモリ > high watermark

→ 動作:通常

2. Yellow(やや圧迫)

low watermark < 利用可能メモリ < high watermark

→ 動作:バックグラウンド kswapd が ページ回収開始
        アプリケーションは通常動作

3. Red(危機的)

利用可能メモリ < low watermark

→ 動作:フォアグラウンド同期 (direct reclaim)
        メモリ割り当てをブロック、ページ回収

4. OOM(メモリ枯渇)

利用可能メモリ ≈ 0

→ 動作:OOM Killer 発動
        メモリ食いプロセスを SIGKILL

OOM の見方

OOM は「このプロセスが悪い」と単純に決められないことも多いです。

  • リーク
  • キャッシュの膨張
  • 同時実行の増えすぎ
  • メモリ制限の設定

など複数の原因が絡みます。

OOM Killer(Linux)

メモリがなくなったとき、最も「悪い」プロセスを選んで SIGKILL。

スコア計算:

score = (rss + swap + file_pages) × adj / 100
  • rss:物理メモリ使用量
  • swap:スワップ使用量
  • adj:oom_adj フラグ(ユーザーが調整可能)
# PID 1234 を OOM Killer の対象外に
echo -17 > /proc/1234/oom_adj

cgroup メモリ制限

コンテナやバッチジョブには cgroup でメモリ上限を設定:

echo 1G > /sys/fs/cgroup/memory/app/memory.limit_in_bytes

# このグループ内のプロセス合計が 1 GB を超えたら、
# そのグループ内で OOM Killer

NUMA

大きなマシンでは、メモリは完全に一様な距離ではないことがあります。CPU に近いメモリと遠いメモリでコストが変わる世界です。これが NUMA です。

NUMA の構造

flowchart LR S1["Socket 1 CPU 0-7"] --> L31["L3 キャッシュ"] --> D1["Local DRAM 100 ns アクセス"] S2["Socket 2 CPU 8-15"] --> L32["L3 キャッシュ"] --> D2["Local DRAM 100 ns アクセス"] D1 <-->|"QPI / Infinity Fabric ~300-400 ns (リモートアクセス)"| D2

NUMA アクセスレイテンシ

Local memory100 ns(3 cycles @3 GHz)
Remote memory300-400 ns(9-12 cycles)

→ リモートアクセスは 3-4 倍遅い

メモリバンド幅

LocalLocal:120 GB/s
Local → Remote:60 GB/s(QPI競合)

→ リモートアクセスはバンド幅も低い

NUMA-aware プログラミング

単一ソケットの感覚でプログラムを書くと、大規模環境で急に性能差が出ることがあります。

悪い例(全スレッドが同じソケットのメモリアクセス):

void *shared_data = malloc(10 GB);  // Socket 1 で割り当て

#pragma omp parallel for
for (int i = 0; i < 1000; i++) {
    // CPU 0-7(Socket 1)も 8-15(Socket 2)も
    // shared_data を読む
    // → CPU 8-15 は リモートアクセス
}

良い例(NUMA-aware):

// numactl でバインド、または プログラム内で設定
struct bitmask *mask = numa_bitmask_alloc(numa_max_node());
numa_bitmask_setbit(mask, node);
numa_bind(mask);  // このスレッドを特定 node にバインド

void *local_data = numa_alloc_onnode(size, node);

numactl コマンド

# CPU 8-15 に実行をバインド、メモリは node 1 から割り当て
numactl --cpunodebind=1 --membind=1 ./myapp

# ノード情報確認
numactl -H
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7
node 0 size: 16384 MB
node 0 free: 8192 MB
node 1 cpus: 8 9 10 11 12 13 14 15
node 1 size: 16384 MB
node 1 free: 7890 MB

自動バランシング

最新 Linux(4.14+)では NUMA balancing が自動:

ページアクセス監視 → リモートノードのアクセス多数 
→ ページを自動マイグレーション

効果: 手動チューニング不要だが、overhead あり(2-5%)。


次世代メモリ技術

Persistent Memory(永続化メモリ)

Intel Optane(廃止)の後継として、NVDIMM や 3D XPoint の発展。

特性:

アクセス時間:100-500 ns(DRAM に近い)
容量:256 GB - 2 TB(SSD より小さい)
永続性:あり(電源が切れても保持)

用途: 長寿命トランザクションログ、checkpoint、In-memory DB の永続化。

CPU と外部メモリ(HBM、Persistent Memory など)を接続するプロトコル。

flowchart TB C["CPU"] --> P["PCIe Gen5 物理層 / CXL プロトコル"] --> B["CXL メモリ拡張ボード"] B --> H["HBM (High Bandwidth Memory)"] B --> PM["Persistent Memory"] B --> G["GPU メモリ"]

利点:

  • CPU から見て、本来の DRAM と同じインターフェース
  • 容量 expand(最大 16 TB)
  • メモリティアリング可能

2025 年状況: 仕様は完成(CXL 3.1)、製品化はまだ初期段階。

メモリティアリング(NUMA × CXL)

【Tier 0:高速】
  - L3 キャッシュ
  - DRAM(local)
  - アクセス:< 100 ns

【Tier 1:中速】
  - CXL Attached DRAM
  - HBM
  - アクセス:100-500 ns

【Tier 2:低速】
  - Persistent Memory
  - SSD
  - アクセス:1-100 μs

OS(DAMON)や application が自動的にデータをティア間で移動。

DAMON(Data Access Monitoring)

Linux 5.15+ で導入。メモリアクセスパターンを監視し、自動マイグレーション。

# DAMON 有効化
echo on > /sys/kernel/mm/damon/admin/damos0/state

# ホットデータを確認
cat /proc/damon/stats

効果: メモリティアリング環境で自動最適化 → 性能 10-20% 向上。

HBM(High Bandwidth Memory)

GPU や AI チップに装備される高帯域メモリ。

特性(HBM3, 2024年):

帯域幅:1.5 TB/s(GDDR63-4 倍)
レイテンシ:100-200 ns(GDDR より低い)
容量:最大 192 GB(1 チップ)

採用: NVIDIA H100, AMD MI300 など。

DDR5, LPDDR5 の進化

DDR4(2013-):  3200 MHz, 51 GB/s 帯域幅
DDR5(2021-):  6400 MHz, 102 GB/s 帯域幅
LPDDR5(2021-):7500 MHz, 102 GB/s 帯域幅(モバイル)
LPDDR5X(2024-):8533 MHz, 120 GB/s 帯域幅(モバイル)

実務的なメモリ診断

free コマンド

$ free -h
              total        used        free      shared     buffers     cached
Mem:          31Gi        28Gi       2.9Gi      1.2Gi      120Mi        18Gi
Swap:          32Gi       5.3Gi        27Gi

# 読み方
# - total:システム総メモリ
# - used:割り当て済み(cached含む)
# - free:完全に未使用
# - cached:ページキャッシュ(再利用可能)
# 実質的な余裕 = free + cached(この例:21 GB)

vmstat コマンド

$ vmstat 1 10
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa
 0  0 5500000 2900M   120M  18000M   10   5   100   200  300  200 30 10 60  0

# 読み方
# - r:runqueue(CPU 待機スレッド数)
# - b:I/O 待機プロセス
# - swpd:スワップ使用量
# - si/so:スワップ in/out(高いと圧迫の兆候)
# - wa:wait I/O(% CPU)

ps コマンド

$ ps aux | sort -k4 -rn | head
root       1234 45.2 12.3   5000000  3800000  ...

# RSS(Resident Set Size):12.3%(物理メモリ使用率)
# VSIZE(Virtual Size):5000000 KB

/proc/meminfo

$ cat /proc/meminfo
MemTotal:       32000000 kB
MemFree:         3000000 kB
MemAvailable:   21000000 kB  ← 実質余裕(ページキャッシュ回収可)
Buffers:          120000 kB
Cached:         18000000 kB
SwapTotal:      33000000 kB
SwapFree:       27000000 kB
Dirty:              1000 kB  ← write-back待ち
Writeback:            0 kB  ← write-back中
AnonPages:       4500000 kB  ← anonymous(malloc)メモリ
Slab:             200000 kB  ← kernel slab allocator

pmap, smaps(プロセス別詳細)

$ pmap -x 1234
Address           Kbytes     RSS   Dirty Mode   Mapping
00400000         50000   48000    100  r-x-- a.out
7ffff7000000     2000   2000      0  r-x-- libc-2.31.so
7ffff7200000     100000   80000      0  r-x-- ...(省略)

$ cat /proc/1234/smaps | grep -A 5 "Rss:"
# より詳細(ページテーブルスキャンのコスト有り)

メモリリーク検出

Valgrind

valgrind --leak-check=full ./myapp

詳細なメモリリーク情報(ただし 10-50倍遅い)。

AddressSanitizer(ASAN)

gcc -fsanitize=address -g -O1 myapp.c -o myapp
./myapp

use-after-free, buffer overflow, leak 検出(3倍遅い)。

tcmalloc heap profiler

env HEAPPROFILE=./heap ./myapp
# heap.0001.heap, heap.0002.heap, ... が生成
pprof ./myapp ./heap.0001.heap  # プロファイル表示

永続化とジャーナリング

メモリと違って、ストレージは電源断後も残ります。ですが途中でクラッシュすると整合性が壊れやすいです。

そこでジャーナリングのような仕組みで、

  1. 変更予定を書く
  2. 本体を更新する
  3. 完了を確定する

という順で進め、クラッシュ後の回復をしやすくします。

なぜ順番が大事なのか

永続化では、「書いたつもり」と「本当に残った」は別問題です。キャッシュ、書き込みバッファ、再順序化の影響で、思った順に記録されるとは限りません。

そのためファイルシステムや DB は、

  • どの順で永続化するか
  • どこまでをコミットとみなすか
  • 回復時に何を信じるか

をかなり慎重に設計します。

Write Barrier(書き込みバリア)

キャッシュの write-back が完了するまで待つ。

// ファイルディスクリプタ fd に対して fsync
fsync(fd);  // システムコール
// → キャッシュの変更が SSD/HDD に書き込まれるまで待機
// → システムコール戻る

コスト: 数 ms(SSD),数十 ms(HDD)。

Linux ファイルシステムの ジャーナリング

ext4(2008-、標準):

【Log space】(ジャーナル)
  - Transactions(一連の変更)をログ

【Metadata space】(メタデータ)
  - inode, directory などの構造

Commit フロー:
1. Transactions を journal に write
2. Journal を disk に persist(fsync)
3. Metadata を update
4. Journal から削除
5. クラッシュ後、journal から復旧

設計原則として見るメモリ

メモリ設計では、「どれだけ持つか」だけでなく「どう触るか」が非常に重要です。

見るべき軸:

  • 連続して触るか
  • 繰り返し触るか
  • コピーを増やしていないか
  • 共有しすぎていないか
  • 永続化境界は明確か

容量と局所性は別の問題

メモリが十分あっても遅いことはあります。逆に容量が限られていても、局所性が高ければ気持ちよく動くことがあります。容量問題とアクセス問題を分けて考えるのがコツです。

メモリ設計チェックリスト

□ Working Set はどのくらい?
□ Working Set はキャッシュに乗るか?
□ アクセスパターンは局所性が高いか?
□ false sharing はないか?
□ 不要なコピーは増やしていないか?
□ malloc/free の頻度は?
□ GC pause は許容範囲か?

比較で理解する

キャッシュミスとページフォルト

  • キャッシュミス: より遅いメモリ階層へ取りに行く
  • ページフォルト: 仮想メモリ管理の介入が必要になる

どちらも「待つ」現象ですが、層も重さも違います。

キャッシュミス:
  L1 miss → L2 取得(10 ns)
  L3 miss → DRAM 取得(100 ns)
  DRAM miss → ページフォルト(1000+ ns)

ページフォルト:
  物理ページなし → ページ割り当て or ディスク読み込み
  → クロックサイクル数千~数百万

readmmap

  • read: 明示的にコピーして受け取る
  • mmap: アドレス空間へ写像してアクセスする

I/O をどう見せるかの設計思想が違います。

read(fd, buf, 1MB):
  1. システムコール
  2. ファイルシステム読み込み(キャッシュ経由)
  3. memcpy(buf, cache) →ユーザースペース
  4. 戻る

mmap():
  1. システムコール(ページテーブル設定のみ)
  2. その後のアクセス → 直接ページキャッシュ
  (copy なし)

判断の指針

メモリの問題を考えるときは、

  1. 容量不足か
  2. 局所性不足か
  3. コピー過多か
  4. 共有や競合が多いか
  5. 永続化境界が曖昧か

を分けて見ると整理しやすいです。

典型的な判断例

  • データは入るのに遅い: 局所性やアクセス順を疑う
  • OOM: リークだけでなくキャッシュや設定も見る
  • 大きなファイル: readmmap のどちらが自然か考える
flowchart TD A[変更予定を記録] --> B[本体を更新] B --> C[完了を記録] C --> D[障害後も回復しやすい]

実務ミニケース

2 次元配列の走査順で速さが変わる

データ配置とアクセス順が局所性に合うかどうかで、キャッシュ効率が大きく変わります。

// 例:1000×1000 の行列
double matrix[1000][1000];

// 行優先(キャッシュ友好)
for (int i = 0; i < 1000; i++) {
    for (int j = 0; j < 1000; j++) {
        sum += matrix[i][j];  // 空間局所性:毎回近い場所
    }
}
// → L1 hit rate ~80%

// 列優先(キャッシュ非友好)
for (int j = 0; j < 1000; j++) {
    for (int i = 0; i < 1000; i++) {
        sum += matrix[i][j];  // 飛び飛び:キャッシュラインの浪費
    }
}
// → L1 hit rate ~5%

性能差: 2-5 倍程度(キャッシュヒット率の違い)。

大きなファイル読み込みで mmapread の差が出る

コピーコスト、ページキャッシュ、アクセスパターンの違いが効きます。常にどちらかが勝つわけではありません。

// ケース1:順読み(read が有利)
size_t total = 0;
char buf[4096];
while ((n = read(fd, buf, 4096)) > 0) {
    total += analyze(buf, n);
}
// シーケンシャルプリフェッチが効く

// ケース2:ランダムアクセス(mmap が有利)
char *p = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);
for (off_t i = 0; i < file_size; i += 1000) {
    process(p[i]);  // 直接アクセス、copy コスト なし
}

OOM で落ちる

単に「メモリが足りない」だけでなく、仮想メモリの使い方、ページキャッシュ、割り当て方、リークが絡んでいることがあります。

症例: 8 GB マシンで大規模スクレイピング

初期状態:メモリ利用 60%

→ スクレイピング開始
→ メモリ 80% → 90% → 95%
→ キャッシュ回収開始(Green → Yellow)
→ swap in/out 激増(vmstat で si, so が 1000+)
→ ページフォルト激増 → システムスラッシング
→ 実効スループット 90% 低下
→ タイムアウト → OOM Killer → プロセス終了

対策:

  1. 同時接続を制限(Working Set 削減)
  2. メモリ上限を cgroup で設定(OOM Killer を早めに実行)
  3. mmap を活用(ファイルキャッシュをまとめる)
  4. Go GC のような low-latency GC を使用

FAQ

仮想メモリはスワップの別名か

違います。スワップは一部の実装手段であって、仮想メモリの本質はアドレス空間の抽象化と保護です。

スワップなしの仮想メモリシステムもあります(いくつかの組み込みシステム)。

ページフォルトは異常か

異常な場合もありますが、需要読み込みでは正常です。文脈を見て判断する必要があります。

正常なページフォルト:
- malloc 直後のメモリアクセス
- mmap 直後のアクセス
- Working Set 以上のページアクセス

異常なページフォルト:
- Segmentation Fault(存在しないアドレス)
- 保護されたページへのアクセス(R/W 違反)

キャッシュに優しいコードとは何か

近い場所をまとめて触り、何度も使うデータを手元に残しやすいコードです。アルゴリズムだけでなくデータ配置が大事です。

例:

  • 行優先の配列走査
  • ループのブロッキング(loop tiling)
  • 構造体の詳細な配置管理

Huge Pages を使うべきか

利点: TLB ミス削減 → 10-20% 性能向上(ページテーブルが大規模な場合)

欠点:

  • メモリ断片化(2 MB page の境界に align する必要)
  • Transparent Huge Pages(THP)の自動昇格オーバーヘッド(2-5%)

結論: Working Set が数 GB 以上なら、THP を試す価値あり。

NUMA バインディングは必須か

必須ではない。 Linux の自動 NUMA balancing で 90% 対応。

手動バインドが価値あるケース:

  • NUMA-aware アルゴリズムを実装している
  • 非常に大規模(> 1 TB メモリ)
  • 低遅延要求(trading など)

何に使うか

  • キャッシュミスやメモリ待ちの理解
  • OOM やページフォルトの読み解き
  • 大きなファイルを効率よく扱う設計
  • マルチコアアプリケーションの同期・局所性最適化

何に似ているか

メモリ階層は、机の上、引き出し、棚、倉庫の関係に似ています。近いほど速いが狭く、遠いほど広いが遅いです。


よくある誤解

  • 仮想メモリ = ただのスワップだと思う
  • ページフォルト = 異常終了だと思う
  • メモリは1枚岩だと思う
  • キャッシュは CPU とは別物だと思う
  • NUMA は大企業だけの問題だと思う
  • GC は Java だけだと思う

ミニ比較表

概念 主役 混同しやすいもの
キャッシュ 速度差の吸収 仮想メモリ
仮想メモリ 保護と抽象化 スワップだけの仕組み
TLB アドレス変換の高速化 一般キャッシュ
read 明示的コピー I/O mmap
mmap アドレス空間への写像 単なる高速 read
Demand paging 遅延割り当て すべてをメモリに乗せる
CoW Copy-on-Write 通常のコピー
ページキャッシュ ファイル層のキャッシュ メモリキャッシュ
世代別 GC 新・中・老オブジェクト分離 Mark-Sweep

実務チェックリスト

  • アクセス順は局所性に合っているか
  • 不要なコピーを増やしていないか
  • mmapread の使い分けは自然か
  • OOM を 1 プロセスだけの責任にしていないか
  • 大規模環境では NUMA を無視していないか
  • TLB ミス率を測定したか(perf など)
  • キャッシュコヒーレンシのコスト(false sharing)を考慮したか
  • GC pause time は許容範囲か
  • メモリリーク検出ツール(ASAN, Valgrind)は実行したか

学習ロードマップ

学ぶ順番

  1. メモリ階層
  2. 局所性
  3. キャッシュ基礎
  4. キャッシュ詳細
  5. 仮想メモリ基礎
  6. ページテーブル
  7. TLB
  8. ページ置換
  9. メモリアロケータ
  10. ガベージコレクション
  11. mmap とページキャッシュ
  12. OOM, NUMA, 永続化

この順にすると、速さの話から保護と抽象化の話へ自然につながります。

CPU と一緒に読むとよい点

  • キャッシュミス
  • TLB ミス
  • メモリバウンド
  • False sharing(キャッシュコヒーレンシ)

は CPU 側の性能観測とも直結します。CPU とメモリは分けて学んでも、理解は途中で必ず合流します。


実務での見方

遅い処理を疑うとき

「計算が重い」のか「データが遠い」のかで対策が変わります。後者なら、アルゴリズムよりもデータ配置やアクセス順の方が効くことがあります。

# perf で見分ける
perf stat ./myapp
# → IPC(命令/サイクル)が低い → CPU 待ち(メモリ)
# → IPC が高い → 計算が回転している

インフラ運用で効く場面

  • OOM 調査(free, vmstat, /proc/meminfo)
  • スワップや reclaim の理解
  • 大きなファイル処理(mmap vs read)
  • DB や検索基盤のメモリ設計
  • コンテナのメモリ上限設定(cgroup)
  • 高速取引・ゲームの遅延測定

まとめ

メモリは、速さ、保護、共有、見かけの広さを同時に支える多層の仕組みです。局所性、キャッシュ、仮想メモリ、ページ、NUMA をまとめて見ることで、性能問題や運用上の現象を読み解きやすくなります。