メモリ
概要
キャッシュ・仮想メモリ・ページをつなげて理解する
メモリは、CPU のすぐ隣にある単なる保存箱ではありません。速さ、保護、共有、見かけの広さを同時に成立させるための、多層の仕組みです。
この章で重視すること
目次
- メモリ階層
- メモリアクセス時間とコスト
- 局所性
- キャッシュ基礎
- キャッシュ詳細
- キャッシュコヒーレンシ
- 仮想メモリ基礎
- 仮想メモリ詳細
- ページテーブル
- TLB
- ページ置換アルゴリズム
- メモリアロケータ
- ガベージコレクション
- メモリ保護
- mmap
- ページキャッシュ
- OOM とメモリ圧迫
- NUMA
- 次世代メモリ技術
- 実務的なメモリ診断
- 永続化とジャーナリング
- 設計原則として見るメモリ
- 比較で理解する
- 判断の指針
- 実務ミニケース
- FAQ
- 参考文献
メモリ階層
計算機の記憶は一種類ではありません。
のように、速さと容量の異なる層があります。
上へ行くほど速くて小さく、下へ行くほど遅くて大きいです。
なぜ 1 種類で済まないのか
理想は「巨大で、安くて、永続化もできて、CPU 並みに速いメモリ」ですが、現実にはそんな都合のよい記憶装置はありません。そこで、
- 速いが高価で小さい層
- 遅いが安くて大きい層
を重ねて、全体として折り合いをつけています。
メモリ階層は苦肉の策であると同時に、計算機の性能を支える中心設計です。
階層の容量と速度のトレードオフ
メモリ階層の特性は、以下の図で示されるように指数関数的なトレードオフを示します。容量が10倍増えると、アクセス時間も10倍近く遅くなることが多いです。
メモリアクセス時間とコスト
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 が独立したキャッシュを持つため、整合性を保つ必要があります。
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: I → E (独占)
Core 2: I
Core 2 が読み
Core 1: E → S (シェア) + invalidate
Core 2: I → S
Core 1 が書き
Core 1: S → M + invalidate broadcast
Core 2: S → I
(他のコアも 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 は数%に低下
対策:
- Working Set を小さくする(データ構造最適化)
- キャッシュサイズを拡張(ハードウェア投資)
- アクセスパターンを改善(アルゴリズム最適化)
仮想メモリ基礎
各プロセスに「広くて連続したアドレス空間があるように見せる」仕組みです。
利点:
- 保護
- 隔離
- 見通しのよいアドレス空間
- 必要なページだけ載せる demand paging
仮想メモリは「大きく見せる」だけではない
「RAM が足りないぶんをディスクでごまかす仕組み」とだけ覚えると、本質を外します。仮想メモリの大事な価値は、
- 各プロセスを隔離できる
- 同じアドレスを別プロセスで安全に使える
- カーネル空間とユーザー空間を分けられる
- ページ単位で保護属性を変えられる
ことです。
つまり仮想メモリは、性能の仕組みでもあり、保護と抽象化の仕組み でもあります。
Demand Paging(遅延割り当て)
仮想メモリの実装では、全プロセスが全ページを一度に DRAM に持つわけではなく、アクセスがあったときに初めて DRAM に読み込みます。これを demand paging と呼びます。
利点:
- メモリを効率的に使用
- 初期化が高速(全ページを用意するまで待たない)
- 大きなプロセスでも小さい Working Set なら高速
発動タイミング:
malloc()やnewで メモリを要求 → ページテーブルだけ更新、実ページは未割り当て- そのメモリにアクセス → ページフォルト(trap)
- 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 の役割
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
対策:
- Huge Pages(2 MB, 1 GB): TLB エントリ1個で大きなアドレス範囲をカバー
- ワーキングセット削減: データ構造最適化
- アクセスパターン改善: 局所性向上
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)
最も古いページを追い出す。
実装: キューで管理。
ページ B → C → D → E(FIFO キュー)
↑
最後(次に追い出し)
新しいページ F が必要
→ B を追い出し
→ C → D → E → F
問題: 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 だから追い出し
ルール:
- ポインタが指すページが A=1 なら、A=0 に クリア、次へ進む
- 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)
各 bin は free list。malloc は適切なサイズの bin から取得。
特徴:
- Multi-threaded(複数スレッド)対応
- Lock per arena(contention 削減)
- 各スレッドが自分の arena を持つ
問題: 複数 arena 間での メモリリバランスが難しい。長寿命プロセスで断片化が起きやすい。
jemalloc
Facebook が開発。ptmalloc より局所性が高く、断片化が少ない。
特徴:
- 局所性が高い(同じサイズクラスのオブジェクトが近い)
- マルチスレッド効率が高い
- 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 phase:
1. ルートを Gray に
2. Gray から訪問 → オブジェクト Black に
3. 訪問先のオブジェクト Gray に
4. Gray が空になったら終了
Result:Black のみ生き残り
特徴: 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 の内部でも非常に重要です。
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 カーネルは、ページを active と inactive に分類:
【Active】
最近アクセスされたページ
メモリ圧迫時も回収されにくい
【Inactive】
アクセスから時間経過
メモリ圧迫時に回収候補
ページアクセス:
Inactive → Active への昇格
複数回アクセス必要
目的: 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 の構造
NUMA アクセスレイテンシ
Local memory: 100 ns(3 cycles @3 GHz)
Remote memory: 300-400 ns(9-12 cycles)
→ リモートアクセスは 3-4 倍遅い
メモリバンド幅
Local → Local: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 の永続化。
CXL(Compute Express Link)
CPU と外部メモリ(HBM、Persistent Memory など)を接続するプロトコル。
利点:
- 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(GDDR6 の 3-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 # プロファイル表示
永続化とジャーナリング
メモリと違って、ストレージは電源断後も残ります。ですが途中でクラッシュすると整合性が壊れやすいです。
そこでジャーナリングのような仕組みで、
- 変更予定を書く
- 本体を更新する
- 完了を確定する
という順で進め、クラッシュ後の回復をしやすくします。
なぜ順番が大事なのか
永続化では、「書いたつもり」と「本当に残った」は別問題です。キャッシュ、書き込みバッファ、再順序化の影響で、思った順に記録されるとは限りません。
そのためファイルシステムや 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 ディスク読み込み
→ クロックサイクル数千~数百万
read と mmap
read: 明示的にコピーして受け取るmmap: アドレス空間へ写像してアクセスする
I/O をどう見せるかの設計思想が違います。
read(fd, buf, 1MB):
1. システムコール
2. ファイルシステム読み込み(キャッシュ経由)
3. memcpy(buf, cache) →ユーザースペース
4. 戻る
mmap():
1. システムコール(ページテーブル設定のみ)
2. その後のアクセス → 直接ページキャッシュ
(copy なし)
判断の指針
メモリの問題を考えるときは、
- 容量不足か
- 局所性不足か
- コピー過多か
- 共有や競合が多いか
- 永続化境界が曖昧か
を分けて見ると整理しやすいです。
典型的な判断例
- データは入るのに遅い: 局所性やアクセス順を疑う
- OOM: リークだけでなくキャッシュや設定も見る
- 大きなファイル:
readとmmapのどちらが自然か考える
実務ミニケース
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 倍程度(キャッシュヒット率の違い)。
大きなファイル読み込みで mmap と read の差が出る
コピーコスト、ページキャッシュ、アクセスパターンの違いが効きます。常にどちらかが勝つわけではありません。
// ケース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 → プロセス終了
対策:
- 同時接続を制限(Working Set 削減)
- メモリ上限を cgroup で設定(OOM Killer を早めに実行)
- mmap を活用(ファイルキャッシュをまとめる)
- 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 |
実務チェックリスト
- アクセス順は局所性に合っているか
- 不要なコピーを増やしていないか
mmapとreadの使い分けは自然か- OOM を 1 プロセスだけの責任にしていないか
- 大規模環境では NUMA を無視していないか
- TLB ミス率を測定したか(perf など)
- キャッシュコヒーレンシのコスト(false sharing)を考慮したか
- GC pause time は許容範囲か
- メモリリーク検出ツール(ASAN, Valgrind)は実行したか
学習ロードマップ
学ぶ順番
- メモリ階層
- 局所性
- キャッシュ基礎
- キャッシュ詳細
- 仮想メモリ基礎
- ページテーブル
- TLB
- ページ置換
- メモリアロケータ
- ガベージコレクション
- mmap とページキャッシュ
- 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 をまとめて見ることで、性能問題や運用上の現象を読み解きやすくなります。