# TLB 与地址翻译 —— 一个"迭代次数不变、只改步长却性能暴跌"的实验

> [cache-organization.md](/concepts/cache/cache-organization.md) 讲了缓存怎么按**物理地址**查,但只在开头一句话带过"VA 先经 MMU+TLB 翻译成 PA"。**TLB 本身也是一层缓存,它也会 miss,而且 miss 的代价能主导整个程序的性能**——这一点常被忽略,却是很多"缓存怎么算都解释不通"的性能悬案的真凶。

> 本篇用一个经典实验讲透:**同一段循环,迭代次数完全不变,只增大访问步长,性能却在某个点突然暴跌。你以为是 CPU 缓存不够,其实是 TLB 先崩了。** 这个实验出自 Igor Ostrovsky 的 *Gallery of Processor Cache Effects*,是理解 TLB 的最佳入口。可运行的实验代码见 [demos/tlb-thrashing/README.md](/demos/tlb-thrashing/README.md)。

## 一、实验:迭代次数不变,只改步长

```c
const int N = (1 << 13);   // N = 8192,迭代次数固定
int a[D * N];              // 数组大小随步长 D 变
for (int i = 0; i < D * N; i += D)
    a[i] += 1;             // 每次跳 D 个 int,总共只写 N 次
```

关键设计:**改变步长 D,数组随之变大,但循环体只执行 N=8192 次——迭代次数恒定。** 直觉上,做的"活"(8192 次自增)一样多,性能应该差不多。可实测(D 从 16 增到 1024)是这样:

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<hi>> #C8E6C9
  BorderColor<<hi>>     #388E3C
  BackgroundColor<<mid>> #FFF9C4
  BorderColor<<mid>>    #F9A825
  BackgroundColor<<lo>> #FFCDD2
  BorderColor<<lo>>     #C62828
}
rectangle "D = 16 ~ 240\n**性能高、基本持平**\n~0.85×10⁹ 次自增/秒" <<hi>> as A
rectangle "D ≈ 256\n**断崖:性能骤降**" <<mid>> as B
rectangle "D = 256 ~ 1024\n**跌到 ~0.15×10⁹/秒**\n(慢了 5~6 倍)" <<lo>> as C
A -right-> B
B -right-> C
note bottom of A : 迭代次数一样是 8192\n为什么右边慢 5 倍?
@enduml
```

**核心谜题**:自增次数一模一样(8192 次),为什么 D≥256 时慢 5 ~6 倍?

先排除一个直觉答案——**不是 CPU 数据缓存(L1/L2/L3)**。下面先看为什么不是,再看真凶。

## 二、先排除嫌疑:为什么不是 CPU 数据缓存?

先记住这个循环的一个关键特征:**它只从头到尾扫一遍,每条数据只碰一次,从不回头重复访问。** 这一点决定了数据缓存帮不上忙、也背不了锅——下面一步步看。

数据缓存以 **64 字节 cache line** 为单位搬运(见 [cache-organization.md](/concepts/cache/cache-organization.md))。先看步长 D 怎么影响"每次访问落在哪条 line":

| 步长 D | 相邻两次访问的字节间隔 | 是否落在同一 cache line? |
|--------|----------------------|:---:|
| D=1 | 4 字节(1 个 int) | 是,16 个 int 挤在同一条 line |
| **D=16** | **64 字节** | 恰好每次跳到**下一条**新 line |
| D≥16 | ≥64 字节 | 每次都是新 line(且中间还跳过了整条整条的 line)|

**关键推理**:一旦 **D≥16**,相邻两次访问就落在不同 cache line 上。于是不管 D 是 16、256 还是 1024,这个循环在数据缓存眼里做的事**完全一样**:

| | D=16 | D=1024 | 数据缓存在乎吗? |
|---|:---:|:---:|:---:|
| 碰到多少条**不同**的 line | 8192 条 | 8192 条 | — 一样 |
| 每条 line 被碰几次 | **各 1 次** | **各 1 次** | — 一样 |
| line 之间隔多远 | 64 字节(挨着)| 4096 字节(散开)| **不在乎** |

- **为什么"每条只碰 1 次"能排除缓存容量的影响**:缓存的价值在于**重复访问时命中**(第二次读同一条 line 就快)。可这个循环每条 line **只读一次、再不回来**——第一次读必然是 miss(得从下层取上来),而缓存能不能"留住"它给下次用,在这里毫无意义(没有下次)。所以 512KB 装不装得进 L2/L3 根本不重要:**每条 line 都是一次性的冷 miss,D=16 和 D=1024 的 miss 次数完全相同(都是 8192 次)。**
- **数据缓存唯一"不在乎"的,恰恰是唯一在变的那一项——line 之间隔多远**。缓存按地址索引、一条一条地取,line 挨着还是散开,取一条的代价都一样。

> **结论**:CPU 数据缓存对 D≥16 的所有情况一视同仁(都是 8192 次一次性冷 miss),解释不了"D=1024 比 D=16 慢 5 倍"这个断崖。**唯一在变的是"line 散得多开"——数据缓存不在乎,但它背后藏着一个变量:碰了多少个不同的内存"页"。而对"页数"极其敏感的,是另一个部件——TLB。** 这就是第三节的真凶。

## 三、真凶:TLB —— 页表翻译的缓存

### 3.1 补一课:虚拟地址要翻译成物理地址

程序用的是**虚拟地址(VA)**,硬件访存前必须先翻译成**物理地址(PA)**(原因见 [cache-organization.md §1](/concepts/cache/cache-organization.md))。翻译规则以**页(page)** 为单位——x86 默认页大小 **4 KB**:

- 虚拟地址 = **页号(VPN)** + **页内偏移**。4KB 页 → 低 12 位是偏移,高位是页号。
- 内核为每个进程维护一张**页表(page table)**:VPN → PFN(物理页号) 的映射。
- 每次访存都要查页表把 VPN 翻译成 PFN——但页表在内存里,x86-64 是**四级页表**,一次翻译要访存 **4 次**。若每次访存都走完整页表,慢得不可接受。

用一张图看清"拆地址 → 只翻译页号 → 拼回物理地址"——**注意页内偏移直接透传、不参与翻译**:

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<vpn>> #E1BEE7
  BorderColor<<vpn>>     #6A1B9A
  BackgroundColor<<off>> #C8E6C9
  BorderColor<<off>>     #388E3C
  BackgroundColor<<pt>>  #FFF9C4
  BorderColor<<pt>>      #F9A825
  BackgroundColor<<pfn>> #FFCCBC
  BorderColor<<pfn>>     #E64A19
}
rectangle "虚拟地址 VA (如 48 位)" {
  rectangle "VPN 页号\n(高位)" <<vpn>> as VPN
  rectangle "页内偏移 offset\n(低 12 位, 4KB页)" <<off>> as OFF
}
rectangle "页表 / TLB\nVPN → PFN 映射\n(x86-64 四级页表,\nmiss 时一次要访存 4 次)" <<pt>> as PT
rectangle "物理地址 PA" {
  rectangle "PFN 物理页号" <<pfn>> as PFN
  rectangle "页内偏移 offset\n(原样透传!)" <<off>> as OFF2
}
VPN -right-> PT : ① 只拿页号去翻译
PT -right-> PFN : ② 查出物理页号
OFF -down[#388E3C]-> OFF2 : ③ 偏移直接照抄,不翻译
note bottom of PT
  翻译的粒度是"页":
  同一页内的 4096 个字节共用一个 VPN→PFN
  → 只要访问还在同一页内,就复用同一条翻译
end note
@enduml
```

**这张图埋了后面断崖的种子**:翻译按**页**为单位,同一页内的 4096 字节共用一条 `VPN→PFN`。**只要连续访问落在同一页里,就一直复用这一条翻译(TLB 命中);一旦每次访问都跳到新页,就每次都要新翻译**——这正是大步长踩坑的地方(见 3.3)。

#### 那张"页表"里面长什么样?—— x86-64 的四级页表

上图把"页表"画成一个黑盒。实际上单张扁平页表存不下:48 位地址、4KB 页,有 2³⁶ 个页号,一张表就要几百 GB。所以 x86-64 用**多级(4 级)页表**——把 VPN 的 36 位再切成 **4 段各 9 位**,像一棵树逐级往下查,每级一张小表(每张 512 项、正好占一页):

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<va>>  #E1BEE7
  BorderColor<<va>>      #6A1B9A
  BackgroundColor<<tbl>> #FFF9C4
  BorderColor<<tbl>>     #F9A825
  BackgroundColor<<reg>> #E3F2FD
  BorderColor<<reg>>     #1976D2
  BackgroundColor<<pg>>  #FFCCBC
  BorderColor<<pg>>      #E64A19
}
rectangle "虚拟地址 48 位 = [9][9][9][9][12]" <<va>> as VA
rectangle "CR3 寄存器\n(指向本进程顶级表,\nfork/切进程时换)" <<reg>> as CR3
rectangle "① PML4 表\n用 bits[47:39] 索引" <<tbl>> as T1
rectangle "② PDPT 表\n用 bits[38:30] 索引" <<tbl>> as T2
rectangle "③ PD 表\n用 bits[29:21] 索引" <<tbl>> as T3
rectangle "④ PT 表\n用 bits[20:12] 索引\n→ 得到 PFN" <<tbl>> as T4
rectangle "物理页 + 页内偏移\nbits[11:0] 透传" <<pg>> as PG
CR3 -right-> T1 : 访存1
T1  -right-> T2 : 访存2
T2  -right-> T3 : 访存3
T3  -right-> T4 : 访存4
T4  -right-> PG
VA ..> T1 : 每级各取 9 位当索引
note bottom of T4
  一次 page walk = **4 次访存**(逐级读表)
  每次还可能自己 miss 到内存
  → 一次 TLB miss 代价几十~上百拍
end note
@enduml
```

- **为什么分 4 级而不用一张大表**:大表要连续几百 GB 物理内存、且大部分是空的(进程只用一小片地址)。多级页表**按需分配**:没用到的地址范围,那一枝子表根本不建,省下巨量空间。
- **代价就是"逐级走" **：查一个地址要从 CR3 出发,逐级读 PML4→PDPT→PD→PT **四张表、四次访存**,最后一级才拿到 PFN。这就是 3.1 说的"一次翻译要访存 4 次"的由来。
- **所以才更需要 TLB**:每次访存都走 4 级页表(4 次访存)慢到无法接受——**TLB 把整条 `VPN→PFN` 的最终结果缓存起来,命中时一步到位、跳过整个四级 walk**。这就是下一节。

**这几张表到底存在哪?**——这是最容易含糊的点,一次说清:

| 东西 | 存在哪 | 里面装什么 |
|------|--------|-----------|
| **CR3 寄存器** | CPU 里的寄存器 | 当前进程 **PML4 表的物理地址**(页表树的根)。切进程时内核换 CR3,就切到另一棵树 |
| **PML4 / PDPT / PD 表** | **物理内存**(内核分配) | 每个表项存**下一级表的物理地址** |
| **PT 表(最后一级)** | **物理内存** | 表项存**目标数据页的物理地址(PFN)** |
| **TLB** | CPU 里的硬件(小、快) | 缓存**整条 `VPN→PFN`** 的最终结果,跳过 walk |

几个关键点:

- **四级页表全在物理内存里**,由**内核**为每个进程各自分配、管理(是进程内核态数据的一部分)。CR3 只是个"指针",指向这棵树的根。
- **page walk 由 MMU 硬件用物理地址直接读这些表**——读页表**本身不需要再翻译**(否则就无限递归了)。所以"翻译访存 4 次"访的都是物理内存里的页表页。
- **页表既然在物理内存,读它也走 CPU 缓存**:page walk 的 4 次访存,若表项正好在 L1/L2(热页表)就快、否则打到内存就慢。现代 CPU 还有专门的 **page-walk cache** 缓存中间级(PML4/PDPT/PD)表项。**这就是为什么 TLB miss 代价是"几十~上百拍"这么大一个范围**——取决于那 4 次读表命中到哪一级。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<cpu>> #E3F2FD
  BorderColor<<cpu>>     #1976D2
  BackgroundColor<<mem>> #FFCCBC
  BorderColor<<mem>>     #E64A19
}
rectangle "CPU 芯片内" <<cpu>> {
  rectangle "CR3 寄存器\n(存 PML4 物理地址)" <<cpu>> as CR3
  rectangle "TLB\n(缓存 VPN→PFN 结果)" <<cpu>> as TLB
  rectangle "MMU\n(硬件走 page walk)" <<cpu>> as MMU
}
rectangle "物理内存(内核分配,每进程一棵)" <<mem>> {
  rectangle "PML4 表" <<mem>> as P1
  rectangle "PDPT 表" <<mem>> as P2
  rectangle "PD 表" <<mem>> as P3
  rectangle "PT 表" <<mem>> as P4
}
CR3 -down-> P1 : 根指针
MMU -down-> P1
P1 -down-> P2
P2 -down-> P3
P3 -down-> P4
TLB -left-> MMU : miss 时才让 MMU 去走表
note bottom of P4 : 四张表都在物理内存\nMMU 用物理地址直接读(不再翻译)\n读表也经 L1/L2 缓存 + page-walk cache
@enduml
```

> 记住这个数量级:**TLB 命中 ≈ 1 拍;TLB miss → 四级 page walk ≈ 4 次访存、几十~上百拍**。两者差几十上百倍——这正是后面"每次踩新页 → 每次 TLB miss → 性能暴跌"的量化根源。

### 3.2 TLB 就是"页表翻译的缓存"

**TLB(Translation Lookaside Buffer,地址翻译后备缓冲)** 缓存最近用过的 `VPN → PFN` 映射,让翻译在**绝大多数时候≈0 延迟**:

```plantuml
@startuml
skinparam shadowing false
skinparam activity {
  BackgroundColor #E3F2FD
  BorderColor #1976D2
  DiamondBackgroundColor #FFF9C4
  DiamondBorderColor #F9A825
}
start
:CPU 发出虚拟地址 VA\n拆成 VPN + 页内偏移;
if (TLB 里有 VPN 的映射?) then (命中, ~1 拍)
  :直接得到 PFN\n拼成物理地址;
  note right: 翻译几乎免费
else (TLB miss)
  :走页表(page walk)\nx86-64 要访存 **4 次**\n~几十到上百拍;
  note right: 翻译结果填回 TLB
endif
:拿物理地址去查 L1/L2/L3 缓存;
stop
@enduml
```

**关键数字**:

- **TLB 命中**:翻译≈1 拍,可与查缓存并行(VIPT),几乎免费。
- **TLB miss**:触发 **page walk**,x86-64 四级页表要额外访存 4 次(这些访存本身还可能 miss 到内存),代价 **几十~上百拍**——和一次 cache miss 一个量级,有时更贵。

**TLB 很小**:它是全相联或高相联的小结构,典型 **L1 dTLB 只有 64 条目**(每条目覆盖一个 4KB 页),L2 TLB(STLB)约 512~1536 条目。**覆盖范围极其有限**:

- L1 dTLB(64 项 × 4KB)只覆盖 **256 KB** 的虚拟地址范围。
- L2 TLB(比如 1536 项 × 4KB)约覆盖 **6 MB**。

### 3.3 断崖:页"工作集"超过 TLB 容量 → thrashing

**这一节其实是第二节的翻版,只是把"单位"从 cache line 换成页。** 先把这个平行关系摆出来,是理解的钥匙:

| | 数据缓存 | TLB |
|---|---|---|
| 缓存的单位 | 一条 **cache line = 64 字节** | 一个 **页 = 4096 字节**(= 64 条 line = 1024 个 int)|
| 缓存的容量 | 几万条 line(几十 MB LLC)| **L1 dTLB ≈ 64 条,L2 STLB ≈ 1536 ~ 2048 条** |
| 命中 | 要访问的 line 还在缓存里 | 要访问的页的翻译还在 TLB 里 |

#### 前提:benchmark 是"重复扫描",不是扫一遍

这是理解容量为什么重要的**关键前提**,原图容易漏掉:要测"每秒多少次自增"(纵轴 10⁹ 量级),单趟 8192 次自增才几纳秒、根本测不出——**所以这段循环被重复跑成千上万遍**。

- **单趟顺序扫**:每个页碰一下就往后走、永不回头 → TLB 多大都无所谓(反正不复用)。
- **重复扫**:每跑完一趟又从头来 → **这就要问:下一趟回到"页0"时,它的翻译还在 TLB 里吗?** 这取决于"一趟里踩过的不同页数(工作集)"和"TLB 能装多少页"的对比。**TLB 容量就是在这里起作用的。**

#### 第一步:算出"页工作集"有多大 = 8D 个页

数组 `int a[D*N]`,N=8192 → 大小 = `D × 8192 × 4` 字节 = `32D` KB = **`8D` 个页**(1 页 4KB)。

一趟扫描访问 `a[0], a[D], a[2D], …`,共 8192 次。当 D ≤ 1024(相邻访问间隔 `4D` ≤ 4096B,即不跨过整页)时,**数组跨越的每一个页都会被碰到** → 一趟踩到的不同页数 = **8D 个**。(等价算法:每页装 `1024/D` 个被访问的元素,`8192 ÷ (1024/D)` = `8D`。)

#### 第二步:拿 8D 和 TLB 容量比,断崖就出来了

用 L2 STLB ≈ **2048 条**(现代 CPU 典型值)来对:

| 步长 D | 页工作集 = 8D | 和 L1 dTLB(64) 比 | 和 L2 STLB(≈2048) 比 | 下一趟回来时翻译还在吗 | 结果 |
|:---:|:---:|:---:|:---:|:---:|:---:|
| 16 | 128 | 超 | **远小于** | STLB 里都还在 → 命中 | ✅ 快 |
| 64 | 512 | 超 | 小于 | 还在 → 命中 | ✅ 快 |
| 128 | 1024 | 超 | 小于(半) | 还在 → 命中 | ✅ 快 |
| **256** | **2048** | 超 | **≈ 等于** | **刚好装满,开始装不下** | ⚠️ **拐点** |
| 512 | 4096 | 超 | 超 2 倍 | 早被挤掉了 → miss | ❌ 慢 |
| 1024 | 8192 | 超 | 超 4 倍 | 全被挤掉 → 每次 miss | ❌ 最慢 |

**这就是断崖 D≈256 的来历:`8 × 256 = 2048`,正好等于 L2 STLB 的条目数。**

#### 第三步:为什么"装得下"是平的、"装不下"是断崖

这是经典的**"工作集 vs 缓存容量 → thrashing(抖动)"**,只不过缓存是 TLB、缓存行是页:

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<ok>> #C8E6C9
  BorderColor<<ok>>     #388E3C
  BackgroundColor<<bad>> #FFCDD2
  BorderColor<<bad>>    #C62828
}
rectangle "8D ≤ STLB (D ≤ 256)\n一趟踩的页数装得进 TLB\n→ 第一趟填满后,以后每趟\n  所有翻译都还在 TLB 里(命中)\n→ 翻译≈免费,和 D 无关 → **平台**" <<ok>> as A
rectangle "8D > STLB (D > 256)\n一趟踩的页数装不下 TLB\n→ 回到页0 时它已被后面的页挤出\n  每趟每个页都要重新 page walk\n→ **每次访存前挂一趟 walk** → 断崖" <<bad>> as B
A -right-> B : 8D 越过 STLB 容量
note bottom of A : 为什么是"平的":\n只要装得下,不管 D=16 还是 240,\n稳态都是全命中,速度一样
note bottom of B : 为什么是"断崖":\n一旦装不下,复用率从 ~100% 掉到 0,\n没有中间地带 → 陡降
@enduml
```

- **D ≤ 256(装得下)**:第一趟把 8D 个页的翻译填进 STLB;从第二趟起,每个页回来时翻译**还在**(没被挤掉)→ 全命中 → 翻译几乎免费。**不管 D 是 16 还是 240,只要工作集装得下,稳态速度都一样 → 曲线是平的。**
- **D > 256(装不下)**:一趟要踩 4096、8192 个页,远超 STLB 的 2048 条 → 你还没回到"页0",它的翻译早被后面的页挤出去了 → **下一趟每个页都得重新 page walk** → 复用率从"几乎 100%"直接塌到 0。**没有中间地带,所以是断崖不是缓坡。**
- **叠加 L1 dTLB(64 条)**:其实 D≥16 时工作集(≥128 页)就已超过 64 条的 L1 dTLB;但 D≤256 时还落在 L2 STLB 内,STLB 命中只多花几拍、几乎无感——所以平台区不是"零 TLB miss",而是"L1 dTLB miss 但 STLB 兜住、便宜"。**真正的断崖是工作集越过 L2 STLB、跌进"每次全 page walk"那一刻。**

#### 隐藏机制:访问一个页,会顺带把后面 7 个页的"翻译原料"缓存起来

有个容易忽略的细节能解释"为什么平台区那么便宜、walk 代价为什么是个范围"。**先纠正一个常见误解:访问 `a[0]` 触发 walk,并不会把后面页的翻译也填进 TLB——TLB 一次只装 `a[0]` 那一个页的一条。** 但你的直觉指向一个**真实存在、只是层次不同**的机制,它发生在**数据缓存**里:

- 一个**页表项(PTE)= 8 字节**,而缓存按 **64 字节 cache line** 搬运 → **一条 line 装 8 个 PTE = 8 个连续页的翻译**。
- walk 去读 `a[0]` 所在页的 PTE 时,按 line 加载,**顺带把相邻 7 个页的 PTE 一起拉进了 L1/L2 数据缓存**。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<w>> #FFF9C4
  BorderColor<<w>>     #F9A825
  BackgroundColor<<c>> #C8E6C9
  BorderColor<<c>>     #388E3C
}
rectangle "访问 a[0](页0) → TLB miss → walk 读页0 的 PTE" <<w>> as W
rectangle "PTE 只有 8B,但按 64B line 加载\n→ 页0~页7 的 8 个 PTE 一起进 L1 数据缓存" <<c>> as C
rectangle "后续访问 页1~页7:\nTLB 仍 miss、仍要 walk,\n但读 PTE 时**已在 L1** → walk 只花几拍(不打内存)" <<c>> as R
W -down-> C
C -down-> R
note bottom of R : 所以 TLB miss 的代价是个范围:\nPTE 在 L1 → 几拍;PTE 也 miss 到内存 → 上百拍
@enduml
```

**这就补全了两件事**:

- **为什么平台区又平又快**:遍历的是**连续页**(页0,1,2…),PTE 也连续 → 每 8 个页才 1 次 PTE 的 cache miss,walk 大多命中 L1、极便宜;加上 STLB 兜住,平台区 TLB 开销几乎无感。这也是"D≥16 早超了 L1 dTLB 却没崩"的原因。
- **上一级更省**:PD 表一个表项(PDE)覆盖 **2MB(512 页)**,中间级表项复用范围极大,CPU 还有专门的 **page-walk cache** 缓存它们 → walk 的前几级几乎总命中,真正可能 miss 的只有最后一级 PTE。

> **但这不改变断崖的结论**:PTE 缓存只影响"每次 walk 多贵",不影响"要不要 walk"。断崖的本质仍是**页工作集 8D 超过 L2 STLB → 每趟回来都得重新 walk**;而且 D 很大时,数据本身的访问也在把这些 PTE 的 cache line 挤出去,walk 又退回"打内存"的上百拍——两头一起恶化,断崖更陡。

> **但 PWC (Page-Walk Cache) 会让"8D > STLB"不是立刻崩**——实测 (release -O2 数据) 中 `dTLB-load-misses` 在 D=4~1024 全程几乎不变 (~1500), 但 `walk_active` 在 D=32 和 D=256 出现两个清晰断崖 (192K 和 453K, 4x 和 9.6x 基线)。中间的 D=64/128/512/1024 反而是"谷底" (60~90K), 因为**PWC 命中率取决于"步长 × 缓存容量"的不连续函数, 不是单调的**——D=256 是"sweet spot of disaster" (PDE 用满 4 项 + PTE 工作集 2048 远超 32 项 PTE 缓存, 每次 walk 全程打内存); D=512/1024 步长够大, 单次 walk 落在同一 PDE 内的概率升高, PTE cache LRU 命中率反而回升。**真断崖点是 `8D > STLB + PTE缓存 + PDE 用满`**, 也就是 PWC 完全覆盖不了工作集、且步长又不够大到能局部命中时才崩。详见 [demos/tlb-thrashing/README.md §实测案例分析](/demos/tlb-thrashing/README.md#实测案例分析-shrdlab31-服务器-release--o2)。

#### 实测样例:不同 CPU 断崖的绝对 D 值会变

上面表格用"L2 STLB ≈ 2048"算出 D=256 是拐点,这是"现代 CPU 典型值"。**实测不同代次/品牌上断崖的绝对 D 值会变**——因为各代 L1 dTLB / L2 STLB 容量不同:

| 微架构 | L1 dTLB | L2 STLB | 推算 L1 拐点 D | 推算 L2 拐点 D |
|--------|--------:|--------:|:--------------:|:--------------:|
| 老服务器 / Sandy Bridge 之前 | 32~64 | 256~512 | ~ 4 ~8 | ~ 32 ~64 |
| Intel Sandy/Ivy Bridge | 64 | 512 | ~8 | ~64 |
| Intel Haswell / Broadwell | 64 | 1024 | ~8 | ~128 |
| Intel Skylake (客户级) | 64 | 1536 | ~8 | ~192 |
| Intel Ice Lake / Skylake-X | 64 | 2048 | ~8 | ~256 |
| AMD Zen3 | 64 | 3072 | ~8 | ~384 |
| AMD Zen4 | 64 | 4096 | ~8 | ~512 |

表中数据是按"拐点 D ≈ STLB容量/8"算的。**实测时 demo 会自动检测实际拐点**:
- **L1 dTLB 拐点** = 吞吐首次跌到 70% 以下
- **L2 STLB 拐点** = 在 L1 拐点之后再**腰斩**(跌到 L1 拐点的一半以下)

所以本机跑出来可能跟表里略有偏差,完全正常——**原理不变,只是不同 CPU 的"STLB 容量"常数不同。** `8D > STLB` 就崩,`8D ≤ STLB` 就平。

> ⚠️ **L1/L2 拐点可能不都明显**:当两级 TLB 容量接近(比如老 CPU 的 L1=64 / L2=512,比值只有 8x),或 L2 已经很大(Zen4 的 4096,超过本实验最大步长 D=1024 对应的 8D=8192),那"两级断崖"在 [D=1,1024] 这个采样区间里**可能只看到一级**。demo 在这种情况下会输出提示而不是乱标拐点。

> **一句话**:本表用来体会"原理",跑 [demos/tlb-thrashing/](/demos/tlb-thrashing/) 用本机实测数据——它会把"实测 L1 拐点 D=?"和"实测 L2 拐点 D=?"以及反推的 TLB 容量参考值直接打出来。

> **谜底(精确版)**:不是数据放不进 CPU 缓存(512KB 数据量始终没变),而是**一趟扫描踩到的"页工作集 = 8D"越过了 TLB 容量(本机实测值,典型 ≈2048 条)**。装得下时,重复扫描每趟都命中、翻译免费 → 平台;`8D > STLB` 后,页工作集在 TLB 里放不下、每趟回来都被挤空 → 每次访存前都挂一趟几十上百拍的 page walk → 断崖。**做的自增一样多(8192 次/趟),暴涨的是"每次自增前那道地址翻译的过路费"——从命中免费,变成每次一趟 page walk。**

## 四、为什么这个坑特别隐蔽

TLB 问题比普通 cache miss 更难排查,因为它**不在你盯的那个维度上**:

1. **数据量没变**:实际触达 512KB,cache 命中率分析完全正常——你会得出"缓存没问题"的错误结论。
2. **迭代次数没变**:算法复杂度分析也看不出差异,O(N) 就是 O(N)。
3. **它藏在"访问模式"里**:同样的数据量、同样的次数,只是**空间跨度(步长)** 不同,TLB 压力就天差地别。大步长 / 随机访问 / 指针跳转密集的结构(链表、树、哈希表)最容易踩。

## 五、怎么确认是 TLB,以及怎么救

### 5.1 用 perf 确认

TLB miss 有专门的性能计数器,一测便知(见 [../code/perf.md](/tools/code/perf.md)):

```bash
# 直接看 dTLB 加载 miss 和 page walk 开销
perf stat -e dTLB-loads,dTLB-load-misses,dtlb_load_misses.walk_active ./app
# 大步长版本会看到 dTLB-load-misses 飙高、walk 周期占比大
perf stat -e cycles,instructions,dTLB-load-misses ./app
# 事件名因型号而异,先查
perf list | grep -i "dtlb\|tlb\|walk"
```

判据:**如果 dTLB-load-misses 随步长增大而暴涨、且 page-walk 周期占了总周期的一大块,就是 TLB 瓶颈**,而不是数据缓存。

### 5.2 怎么救

| 手段 | 原理 | 适用 |
|------|------|------|
| **改数据布局/访问模式** | 让访问**空间局部**(小步长、顺序访问),复用同一页,减少踩的页数 | 首选,治本 |
| **大页(Huge Pages)** | 用 2MB / 1GB 大页替代 4KB:**一个 TLB 条目覆盖 2MB 而非 4KB**,覆盖范围×512,同样的 TLB 条目数能罩住大得多的工作集 | 大数据集、数据库、JVM 堆 |
| **减小工作集的空间跨度** | 数组换成紧凑结构、避免大步长跳跃、SoA 换 AoS | 数值计算、遍历 |
| **预取** | 软件/硬件预取能藏 cache miss,但对 TLB miss 帮助有限(page walk 难提前) | 辅助 |

大页是针对 TLB 的"银弹"级手段。**它为什么有效?关键在地址位的重新划分——页越大,偏移越长、页号越短,一条 TLB entry 就能覆盖越大的范围。**

#### 原理:页变大 → 偏移变长 → 页号变短 → 一条 entry 覆盖更大

回到 3.1 的地址拆分:虚拟地址 = **页号 VPN + 页内偏移**,分界线在哪,由**页大小**决定——偏移要能寻址页内每个字节,所以 `偏移位数 = log2(页大小)`。**页越大,偏移吃掉越多低位,留给页号的高位就越少**:

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<vpn>> #E1BEE7
  BorderColor<<vpn>>     #6A1B9A
  BackgroundColor<<off>> #C8E6C9
  BorderColor<<off>>     #388E3C
}
rectangle "4KB 页 (48 位地址)" {
  rectangle "VPN 页号 = 高 36 位\n(2³⁶ 个页号)" <<vpn>> as V1
  rectangle "偏移 = 低 12 位\n(2¹²=4KB)" <<off>> as O1
}
rectangle "2MB 大页" {
  rectangle "VPN 页号 = 高 27 位\n(页号短了 9 位)" <<vpn>> as V2
  rectangle "偏移 = 低 21 位\n(2²¹=2MB)" <<off>> as O2
}
rectangle "1GB 大页" {
  rectangle "VPN = 高 18 位" <<vpn>> as V3
  rectangle "偏移 = 低 30 位\n(2³⁰=1GB)" <<off>> as O3
}
V1 -[hidden]down- V2
V2 -[hidden]down- V3
note right of O1 : 分界线在第 12 位
note right of O2 : 分界线右移到第 21 位\n→ 偏移 +9 位, 页号 −9 位\n→ 一条 entry 覆盖 ×2⁹=512
note right of O3 : 分界线到第 30 位\n→ 一条 entry 覆盖 1GB
@enduml
```

一条 TLB entry 缓存的是"一个 VPN → 一个 PFN",**它覆盖的地址范围 = 一个页的大小**。所以:

| 页大小 | 偏移位数 | 页号位数(48位下) | **一条 TLB entry 覆盖** | 64 条 L1 dTLB 总覆盖 |
|:---:|:---:|:---:|:---:|:---:|
| **4KB** | 12 | 36 | 4 KB | 256 KB |
| **2MB** | 21 | 27 | **2 MB(×512)** | **128 MB** |
| **1GB** | 30 | 18 | **1 GB(×262144)** | 64 GB |

**因果链一句话**:偏移变长 → 页号变短 → 同样一片地址空间被**更少的页号**覆盖 → 同样 64 条 entry 能罩住 **512 倍**的范围 → TLB 命中率暴涨。**外加**:偏移吃掉了原来 PT 那一级索引的位,**四级页表少走一级**(2MB 大页走到 PD 就停、PDE 直接指向大页,见 3.1 四级表),page walk 本身也更便宜。

#### 回到 benchmark:大页让断崖消失

那个最惨的 D=1024:数据 `32D`=32MB。

- **4KB 页**:要 `32MB/4KB` = **8192 个页** → 远超 L2 STLB 的 2048 条 → 每趟全 miss → 断崖。
- **2MB 大页**:只占 `32MB/2MB` = **16 个大页** → **16 条 TLB entry 就全装下** → 每趟全命中 → 翻译几乎免费 → **断崖直接消失**。

> 这就是大页"银弹"的本质:**不减少数据量、不改访问模式,只是把"页"这个计量单位放大 512 倍,让原本装不下的页工作集,瞬间缩进 TLB 容量之内。**

```bash
# 查看/开启透明大页(THP)
cat /sys/kernel/mm/transparent_hugepage/enabled
# 显式大页:mmap 时加 MAP_HUGETLB,或用 hugetlbfs
# 一个 2MB 大页条目 = 512 个 4KB 页,TLB 覆盖范围直接 ×512
```

> 注意大页的取舍:减少 TLB miss,但可能增加内存碎片、fork 时 CoW 复制粒度变大(呼应 [../process/process-creation.md](/concepts/process/process-creation.md) 的写时复制)。数据库(Oracle/PostgreSQL)、JVM 大堆常显式配大页正是为了压 TLB miss。

## 六、和本仓库其他文档的关系

- **前置**:[cache-organization.md](/concepts/cache/cache-organization.md)(缓存按物理地址组织、VA→PA 翻译的那一句,就是本篇展开的起点;TLB 本身是全相联小结构,见其第四节全相联"只用于条目极少的结构——最典型是 TLB")。
- **对比**:本篇讲的是**地址翻译**这一层的缓存(TLB),[cache-organization.md](/concepts/cache/cache-organization.md) 讲的是**数据**这一层的缓存(L1/L2/L3)。两层都会 miss,都影响性能,但维度不同——这个实验的价值就在于**把两者分开**:数据缓存无辜,TLB 才是真凶。
- **指令端**:本文聚焦 dTLB(数据地址翻译);iTLB(指令地址翻译)有自己的一套，详见 [i-cache.md](/concepts/cache/i-cache.md) 第六节。
- **NUMA 关联**:跨 NUMA 节点访问远端内存时,page walk 本身也可能访问远端页表,更贵(见 [../numa/numa.md](/concepts/numa/numa.md))。
- **共享内存**:[../elf/shared-memory.md](/concepts/elf/shared-memory.md)——多个进程的 PTE 指向同一个 PFN（共享内存的本质），跨 CPU TLB shootdown 的典型场景：一核写共享页改 PTE 的 A/D 位，其他核需要 IPI 失效对应 TLB 条目。
- **观测**:[../code/perf.md](/tools/code/perf.md)(dTLB-load-misses、page-walk 事件)。

## 七、一句话总结

> **"迭代次数不变、只增大步长却性能暴跌 5~6 倍",元凶不是 CPU 数据缓存(实际触达的 512KB 数据量始终没变),而是 TLB——地址翻译那一层的缓存。步长越大,单位工作踩的 4KB 页越多;当页数越过 TLB 的几十~上千条目容量,几乎每次访存都要 TLB miss、走一次几十上百拍的 page walk,于是"做同样多的活,每次活前的翻译过路费暴涨"。确认用 `perf` 看 dTLB-load-misses,救治首选改访问模式(空间局部),大数据集上大页(一条目覆盖 2MB)是针对 TLB 的银弹。**
