メモリ

目次

概要

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

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

要点

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

この章で重視すること

メモリ階層

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

  • レジスタ
  • 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なら数百万サイクルです。この差を吸収するために、上の層が存在します。


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

現在の具体的な数値

現在のメインストリーム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

コスト指標

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

  • レジスタ:文字通り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が大きくなると、キャッシュとの関係が緩くなり、ページフォルトが増える

OSVMの性能は、多くの場合、「各プロセスの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ベース

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

  • MOESIAMDが採用。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. アクセスパターンを改善(アルゴリズム最適化)

仮想メモリ基礎

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

利点:

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

「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)の利点:

小さいページの欠点:

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

大きいページの欠点:

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

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 GCZGC

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と外部メモリ(HBMPersistent Memoryなど)を接続するプロトコル。

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

利点:

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と一緒に読むとよい点

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


実務での見方

遅い処理を疑うとき

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

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

インフラ運用で効く場面

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

Intel 64-bit Architecture メモリ管理

Intel64ビットアーキテクチャにおけるメモリ構造:

仮想メモリアドレス空間

x86-64では、理論的に2^64バイトのアドレス空間をサポート。実装では48ビット(256TB)に制限。

仮想メモリレイアウト (Linux x86-64):

0xFFFFFFFF FFFFFFFF - カーネルスペース
0xFFFF800000000000
0x00007FFFFFFFFFFF - ユーザー空間(mmap、ヒープ、スタック、テキスト)
0x0000000000000000

ページテーブル構造(4段階)

仮想アドレス分解:
[63:48] [47:39] [38:30] [29:21] [20:12] [11:0]
  符号  PML4    PDPT    PDT     PT      Offset

4段階ページウォーク:
1. CR3 レジスタから PML4 テーブル取得
2. PML4 -> PDPT (Page Directory Pointer Table)
3. PDPT -> PDT (Page Directory Table)
4. PDT -> PT (Page Table)
5. PT -> 物理ページ
6. Offset で物理メモリアドレス確定

TLB (Translation Lookaside Buffer): 仮想->物理アドレスマッピングのキャッシュ

// TLB効率化の例
int sum_array_good(int* arr, int n) {
    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum += arr[i];    // シーケンシャルアクセス
    }
    return sum;
}

AMD EPYC マルチソケット・NUMA設計

NUMA (Non-Uniform Memory Access) マルチソケット構成:

Socket 0                    Socket 1
- 8コア                    - 8コア
- L3キャッシュ             - L3キャッシュ
- ローカルMEM (50ns)      - ローカルMEM (50ns)
  vs Infinity Fabric (200ns)

NUMA最適化

# NUMA ノード確認
numactl --hardware

# ローカルメモリへの割り当て
numactl --localalloc ./app

# メモリバインディング
numactl --membind=0 ./app

FreeBSD カーネルメモリ管理

FreeBSD による UMA (Universal Memory Allocator):

// UMA ゾーン作成
uma_zone_t my_zone = uma_zcreate(
    "myobjects",
    sizeof(my_object),
    NULL, NULL, NULL, NULL,
    0, 0
);

// メモリ割り当て
my_object *obj = uma_zalloc(my_zone, M_WAITOK);

// メモリ解放
uma_zfree(my_zone, obj);

メモリページ状態遷移: Free -> Cached -> Active -> Inactive -> Free

Linux Kernel メモリ管理

Page Allocator と Buddy System

Buddy system の階層:

Order 0 (4KB)     ■  ■  ■  ■
Order 1 (8KB)     ■■ ■■
Order 2 (16KB)    ■■■■
Order 3 (32KB)    ■■■■■■■■

カーネルメモリ割り当て:

// Order = 2 -> 4ページ (16KB) 割り当て
struct page *pages = alloc_pages(GFP_KERNEL, 2);
__free_pages(pages, 2);

SLUB Allocator

小さなオブジェクト割り当て用:

SLUB スラブ構造:

┌──────────────────────────┐
│ Slab (1ページ = 4KB)      │
├──────────────────────────┤
│ obj 1 │ obj 2 │ ... │free│
│ 64B   │ 64B   │      space│
└──────────────────────────┘

メモリモニタリング

# ページキャッシュ確認
cat /proc/meminfo

# プロセス単位でのメモリ使用
ps aux --sort=-%mem | head -10

# VmRSS: 物理メモリ使用量
cat /proc/[pid]/status | grep Vm

OOM Killer

# OOM Killer スコア確認
cat /proc/[pid]/oom_score

# スコア調整 (-1000 ~ 1000)
echo -100 > /proc/[pid]/oom_score_adj

メモリ効率化のベストプラクティス

  1. メモリリーク検出

    # Valgrind
    valgrind --leak-check=full ./myapp
    
    # ASAN (AddressSanitizer)
    gcc -fsanitize=leak ./app.c -o app
    
  2. キャッシュ利用

    #define CACHE_LINE_SIZE 64
    
    struct aligned_data {
        int hot_data;
        char pad[60 - sizeof(int)];
    } __attribute__((aligned(64)));
    
  3. メモリプール

    class ObjectPool:
        def __init__(self, cls, init_count=100):
            self.cls = cls
            self.available = [cls() for _ in range(init_count)]
        
        def acquire(self):
            return self.available.pop() if self.available else self.cls()
        
        def release(self, obj):
            self.available.append(obj)
    
  4. NUMA 最適化

    numactl --localalloc --cpunodebind=0 ./app
    

まとめ

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

参考文献

公式・標準

講義・記事

書籍

解説・補助