--- description: 'TLB 抖动实验用步长扫描访问模式(D=1 到 1024)自动检测 L1/L2 TLB 拐点,配合 dTLB-load-misses 与 page-walk 硬件计数器,定量展示页表翻译缓存被打爆后性能断崖的过程。' keywords: ["TLB 页表翻译缓存抖动实验", "实验", "演示", "TLB", "dTLB-load-misses", "walk_active", "cycle", "walk"] updated: 2026-08-29 --- # TLB 页表翻译缓存抖动实验 > **代码获取**:本实验源码与 `REPRO.md` 复现手册已同步至公开仓库 [derekzhuo/geek-doc.cn](https://github.com/derekzhuo/geek-doc.cn?utm_source=geek-doc.cn&utm_medium=doc&utm_campaign=repo_clone),clone 即跑:`git clone --depth 1 https://github.com/derekzhuo/geek-doc.cn.git` ## 快速开始 ```bash cd demos/tlb-thrashing make # 默认 -O0 -g,适合 perf 分析 make run # 编译 + 全量扫表 D=1→1024,自动检测拐点 make release # -O2 编译,验证优化不影响 TLB 结论 make clean ``` ## 用法 ```bash ./tlb_thrashing # 全量扫表 (D=1→1024),自动检测 L1/L2 拐点 ./tlb_thrashing # 单步长模式,100000 趟(适合 perf record) ./tlb_thrashing # 单步长模式,指定趟数 ``` ## 核心结论 只看表里三个数字就够了: | D | 工作集 (8D 页) | dTLB-load-misses (次) | walk_active (cycle) | 平均 walk 延迟 (拍/walk) | |---:|------:|---------------------:|--------------------:|----------------------:| | 4 | 32 | 1,334 | 47,571 | 35.7 | | 32 | 256 | 1,514 | 192,081 | 126.9 | | 256 | 2048 | 1,638 | 453,269 | 276.7 | **三个反直觉现象,一条逻辑链串起来**: | 现象 | 直觉会以为 | 实际原因 | |------|-----------|---------| | `dTLB-load-misses` 全程 ~1500 次,不随 D 涨 | 工作集越大 walk 越多 | TLB prefetcher 提前做了 walk,计数器不涨 | | `walk_active` 跳 10 倍 (47K→453K cycles) | — | prefetcher 偷偷做的 walk 暴露在 cycle 计数值里 | | 平均每次 walk 从 36 拍涨到 277 拍 | walk 变多导致慢 | **页表页自身被 `a[i]` 数据从 L1/L2 cache 挤出去了** | > **一句话**:断崖不是因为 TLB 不够大,walk 一直在走(prefetcher 干的)。断崖是因为页表页被高频数据挤出 L1/L2 cache,CPU 做 page walk 读 PTE 时被迫打 L3/内存 → walk 单价暴涨 → `walk_active` 断崖。 ### 关键澄清:"后台" ≠ "免费" 你可能会问:prefetcher 是异步的、在后台做 walk,为什么还会拖慢吞吐? **TLB prefetcher 的"后台"只是说 walk 启动得比 demand load 早,不是说 walk 不占任何硬件资源。** 它和 demand load 共享同一套 Page Miss Handler (PMH)、同一套内存子系统。三条绑定线: | 约束 | 机制 | |------|------| | **PMH 槽位有限** | PMH 只能同时处理 1~4 个并发 walk。当 walk 从 4 步 L1 命中 (共 ~16 拍) 变成 4 步含一次 L3 命中 (~200 拍) → 每个 walk 占住 PMH 的时间长了 ~12 倍 → prefetcher 能维持的并发预取量暴跌 → 跟不上了 | | **in-progress stall 不计数** | demand load 到达时如果 prefetcher 的 walk 还在进行中,CPU **照样 stall 等它完成**。但这不算 `dTLB-load-misses`——因为 walk 早就启动了,不是 demand load 触发的。计数器只涨在"load 到了、walk 还没开始"的情况 | | **内存带宽争抢** | prefetcher 读 PTE 页表页 (从 L3/内存) 和 `a[i]++` 读数组数据走同一条内存总线。walk 贵了 ~7.7 倍 → prefetcher 吃掉的内存带宽也多了 ~7.7 倍 → 留给 `a[i]++` 的带宽被挤占 | > **一句话**:prefetcher 提前启动 ≠ CPU 不等。当 walk 慢到 prefetcher 跟不上访问流时,"后台"就变成了"前台阻塞"——demand load 照样 stall,只是这个 stall 被 `walk_active` 计了、没被 `dTLB-load-misses` 计。 --- ## 一、实验设计 ### 代码骨架 ```cpp constexpr int N = (1 << 13); // 8192,固定不变 double measure(int D, int rounds) { const int size = D * N; std::vector a(size, 0); madvise(a.data(), size * sizeof(int), MADV_NOHUGEPAGE); // 预热 for (int i = 0; i < D * N; i += D) a[i] = 0; // 测量 auto t0 = std::chrono::high_resolution_clock::now(); for (int r = 0; r < rounds; ++r) { for (int i = 0; i < D * N; i += D) a[i]++; } auto t1 = std::chrono::high_resolution_clock::now(); return static_cast(rounds) * N / (t1 - t0).count(); } ``` 核心循环:`for (int i = 0; i < D * N; i += D) a[i]++;` - `N=8192` 固定,`D` 是唯一变量 - 无复杂计算、无分支、无系统调用——纯内存访问 - 所有性能变化**只来源于虚拟地址翻译(TLB / Page Walk)+ CPU Cache** ### 工作量恒定,只改变"翻页频率" 无论 D 取什么值,每趟都执行 8192 次 `a[i]++`: | D | 数组总大小 | 每趟碰页数 (8D 页) | 每趟数据量 | |---:|--------:|:--------------:|---------:| | 1 | 32 KB | 8 | 32 KB | | 4 | 128 KB | 32 | 32 KB | | 16 | 512 KB | 128 | 512 KB | | 64 | 2 MB | 512 | 512 KB | | 256 | 8 MB | 2048 | 512 KB | | 1024 | 32 MB | 8192 | 512 KB | > 碰页数 = D × 8192 × 4B / 4096B = **8D** - D=1~8:连续访问,多个元素共享一个 cache line → 每趟数据量 32 KB - D≥16:步长 ≥ 64B,每次访问落在不同 cache line → 每趟数据量恒定为 512 KB **计算量完全相同**(都是 8192 次自增),唯一变量是步长 D 带来的翻页频率变化。 ### 两个关键设计 **1. MADV_NOHUGEPAGE 关掉 THP** 默认 `transparent_hugepage=madvise`,内核会把 ≥2MB 的连续 4KB 匿名页合并成 2MB 大页。不关掉的话: - D=64 的 2MB 数组 → 1 个 2MB 大页 → **1 个 TLB 项就够** - D=1024 的 32MB 数组 → 16 个 2MB 大页 → **16 个 TLB 项就够** - → `dTLB-load-misses` 几乎为 0,实验退化成 L1 dcache 测试,结论失真 ```cpp madvise(a.data(), size * sizeof(int), MADV_NOHUGEPAGE); ``` 验证方法:`cat /proc/$(pidof tlb_thrashing)/smaps | grep -A1 "Anonymous"` 应看到 `AnonHugePages: 0 kB`。 **2. 预热消除 page fault 干扰** 预热循环 `a[i]=0` 确保所有虚拟页已分配物理页(触发缺页中断),计时期间不再有 page fault。 ### 数组大小不是混杂变量 数组从 32 KB 涨到 32 MB,但未访问的元素从不进 cache,且 D≥16 后每趟数据量恒定 512KB——数据 cache 压力早已饱和,之后的吞吐变化只能用 TLB 解释。 --- ## 二、四级页表基础:地址翻译的完整数据流 ### 2.1 虚拟地址拆分 x86_64 长模式 48 位虚拟地址,按 9+9+9+9+12 拆成五段: ```bash 虚拟地址 48 位: [47:39] PML4(PGD) 索引 → 9 位, 256~511 项中选一 (用户态用低半区) [38:30] PDPT(PUD) 索引 → 9 位, 0~511 [29:21] PMD 索引 → 9 位, 0~511 [20:12] PTE 索引 → 9 位, 0~511 [11:0] 页内偏移 → 12 位, 0~4095 ``` ### 2.2 每一级存什么 每级页表都是一个 **4KB 的物理页**,里面有 512 个 **8 字节的页表项**(512 × 8B = 4KB)。每个页表项 64 位,关键字段: ```bash 页表项 (PTE/PDE/PDPTE/PML4E) 的 64 位格式: [63:48] 保留 (或用于软件扩展) [47:12] 下一级物理页的 PFN (Physical Frame Number) [11:0] 标志位: P(存在) R/W U/S PWT PCD A(访问位) D(脏位) ... ``` **每级存的是下一级的物理页地址**: | 级别 | 表中每一项存什么 | 读出来做什么 | |------|-----------------|-------------| | **PML4** | 下一级 PDPT 页的物理地址 | CR3 → PML4[VA 47:39] → 拿到 PDPT 的物理基地址 | | **PDPT** | 下一级 PMD 页的物理地址 | PDPT[VA 38:30] → 拿到 PMD 的物理基地址 | | **PMD** | 下一级 PTE 页的物理地址 | PMD[VA 29:21] → 拿到 PTE 页的物理基地址 | | **PTE** | **数据页**的物理地址 | PTE[VA 20:12] → 拿到数组数据页的物理基地址 | 最后一步拼接:**物理地址 = PTE.PFN << 12 | VA[11:0] (页内偏移)** 完整流程图示: ```bash CR3 (存 PML4 页的物理地址) │ └→ PML4 页 (1 页, 4KB, 在物理内存某处) │ └→ PML4[VA 47:39] → PDPT 的物理基地址 │ └→ PDPT 页 (1 页, 4KB) │ └→ PDPT[VA 38:30] → PMD 的物理基地址 │ └→ PMD 页 (1 页, 4KB) │ └→ PMD[VA 29:21] → PTE 页的物理基地址 │ └→ PTE 页 (N 页, N 随 D 增长) │ └→ PTE[VA 20:12] → 数据页的物理基地址 │ └→ 物理地址 = PFN << 12 | VA[11:0] └→ 拿到 a[i] 的值 ``` ### 2.3 每一级会不会变化? | 级别 | 页数 | 访问时在不在 cache? | 会不会被 `a[i]` 数据挤出? | 本实验中会变吗? | |------|:---:|------|:---:|:---:| | **PML4** | 1 页 | ✅ 永远在 L1 | ❌ 不挤 (1 页 4KB, L1D 32KB 绰绰有余) | ❌ 不变 | | **PDPT** | 1 页 | ✅ 永远在 L1 | ❌ 不挤 (同上) | ❌ 不变 | | **PMD** | 1 页 | ✅ 永远在 L1/L2 | ❌ 不挤 (1 页, 但 D=256+ 时数组 512KB 可能挤出 L1 → 在 L2, 仍快) | ❌ 不变 | | **PTE** | 1→16 页 | ⚠️ 取决于 D | ✅ **会被挤出** — 这是断崖根因 | ✅ 唯一变量 | ### 2.4 前三级为什么固定不变? 根本原因:**每级一项覆盖的地址范围太大**,本实验的 32MB 数组完全装在一个 PMD 项的覆盖范围内。 ```bash PML4: 每项覆盖 512GB → 用户态 128TB = 256 项 → 但 1 页 PML4 有 512 项 → 1 页够 PDPT: 每项覆盖 1GB → 用户态 128TB = 128K 项 → 但实际进程的堆/栈/mmap 集中在少数几个 1GB 区域 → 1 页 PDPT (512项, 覆盖 512GB) 足够一个普通进程 PMD: 每项覆盖 2MB → 本实验最大数组 32MB → 32MB / 2MB = 16 个 PMD 项 → 1 页 PMD 有 512 项 → 远未用满 ``` 所以前三级固定 = **1+1+1 = 3 页(~12KB)**。 ### 2.5 这些页表页本身也在 cache 里竞争 关键认知:**页表页也是普通物理内存**,和 `a[i]` 的数组数据走同一套 cache hierarchy。 ```bash L1D (32KB) L2 (1MB) L3 (13.75MB) ┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐ │ PML4 PUD PMD │ │ PTE(部分) │ │ PTE(其余) │ │ 1页 1页 1页 │ │ a[i] 数据 │ │ a[i] 数据 │ │ PTE(少量) │ │ │ │ │ │ a[i] 数据(D≤4) │ │ │ │ │ └─────────────────┘ └──────────────┘ └──────────────────┘ D=4: 所有 PTE (1页) + 数据 (32KB) 全在 L1D → walk 全程 L1 命中 → 36 拍 D=32: 数据 512KB 溢出 L1D, 占 L2; PTE 仍 1 页, 被挤出 L1 在 L2 → 127 拍 D=256: 数据 512KB + PTE 4 页(16KB) → L2 1MB 勉强装下但 LRU 竞争激烈 → PTE 被频繁挤出 L2 → 打 L3/内存 → 277 拍 ``` > 更完整的页表翻译原理见 [page-table-translation](/concepts/cache/page-table-translation.md),逐级物理地址还原过程见[附录](#附录-x86_64-四级页表完整详解)。 --- ## 三、perf 观测指南 ### 核心事件 | 事件 | 含义 | 单位 | 作用 | |------|------|:---:|------| | `dTLB-loads` | 数据 TLB 读翻译总次数 | 次 | 验证计算量恒定 (~8.192e8) | | `dTLB-load-misses` | 一级 dTLB miss 计数 | 次 | 仅供参考 (此机上有 counter aliasing 现象,值偏小) | | `dtlb_load_misses.stlb_hit:u` | L1 miss 但 L2 STLB 命中 | 次 | **关键信号** — 反映 L1 溢出但不溢出 STLB 的阶段 | | `dtlb_load_misses.miss_causes_a_walk:u` | L1+STLB 都 miss,触发 page walk | 次 | **关键信号** — 反映 STLB 也溢出的阶段 | | `dtlb_load_misses.walk_active:u` | Page walk 总 cycle 数 | cycles | **真信号** — 反映 walk 真实代价 (次数 × 单价) | > 完整 TLB 事件列表 (包括 `page_walker_loads.dtlb_l1/l2/l3/memory` 等) 见 [TLB 事件全览](#tlb-事件全览)。 ### 什么是 STLB?为什么有两级 TLB? 和 CPU 数据缓存分 L1/L2/L3 一样,**TLB 也是分级的一层缓存,只是缓存的不是数据,是"虚拟地址 → 物理地址"翻译**。 ```bash 小/快 大/慢 ┌──────────────┐ ┌──────────────────┐ │ L1 dTLB │ miss → │ L2 STLB │ miss → page walk (4 次内存读) │ 64 项 × 4KB │ │ 1536 项 │ │ ~1~2 拍 │ │ ~8~12 拍 │ 200+ 拍 └──────────────┘ └──────────────────┘ 覆盖 256KB 覆盖 6MB 理论上无限 ``` | 层级 | 全称 | 大小 | 延迟 | 覆盖范围 (4KB 页) | 作用 | |------|------|:---:|:---:|:---:|---| | **L1 dTLB** | Level-1 Data TLB | 64 项 | 1~2 拍 | 256 KB | 覆盖大部分热点页的翻译 | | **L2 STLB** | **S**econd-Level **TLB** | 1536 项 | 8~12 拍 | 6 MB | L1 miss 时的后备,兜住中等工作集 | | **Page Walk** | 走四级页表 | 无上限 | 200+ 拍 | 全部内存 | STLB 也 miss 的最后手段 | Intel 的 perf 把 L2 TLB 命名为 **STLB**(Second-level TLB 的缩写)。它和 L1 dTLB 一样存在每个核里,比 L1 dTLB 大 24 倍(1536 vs 64),但查表多花 5~10 拍。 ### `stlb_hit` 到底在数什么? `dtlb_load_misses.stlb_hit:u` 计数的是 **L1 dTLB miss → 去查 STLB → 命中** 这个路径。 CPU 处理一次 load 的地址翻译流程: ```bash load 发起 ├─ 查 L1 dTLB → 命中 → 翻译完成, 1 拍 ← 不计数 └─ 查 L1 dTLB → 未命中 ├─ 查 L2 STLB → 命中 → stlb_hit +1, 翻译完成, ~8 拍 └─ 查 L2 STLB → 未命中 └─ 硬件 page walk → miss_causes_a_walk +1, 200+ 拍 ``` **关键恒等式**: ```bash L1 dTLB miss 总次数 = stlb_hit + miss_causes_a_walk ``` 当 `dTLB-load-misses` 计数器不准(counter aliasing)时,用这个恒等式验证 L1 miss 的真实量级。 ### 最精简命令 ```bash # 完整 TLB 事件测量 — 推荐命令 (5 列数据) for d in 4 8 16 32 64 128 256; do echo "=== D=$d ===" perf stat -e dTLB-loads,dTLB-load-misses,\ dtlb_load_misses.walk_active:u,\ dtlb_load_misses.miss_causes_a_walk:u,\ dtlb_load_misses.stlb_hit:u \ ./tlb_thrashing $d 2>&1 | grep -E 'dTLB|walk_active|miss_causes|stlb_hit' done # 深度分析:看 walk 打到哪级缓存 (Ice Lake+) perf stat -e dtlb_load_misses.miss_causes_a_walk,page_walker_loads.dtlb_l3,page_walker_loads.dtlb_memory \ ./tlb_thrashing 256 ``` --- ## 四、实测数据 (demo-lab, release -O2) ### 测试环境 | 项 | 值 | |---|---| | CPU | Intel Xeon W-2155 @ 3.30GHz | | 微架构 | Skylake-SP (Model 85) | | 核心 | 10C/20T | | L1 dTLB | 64 项 × 4KB | | L2 STLB | 1536 项 (4KB + 2MB 共享) | | PWC | 32 项 PTE | | L1D | 32 KB / core | | L2 | 1 MB / core | | L3 | 13.75 MB (共享) | ### 完整数据 (含 5 列 TLB 事件) > 单位:`dTLB-loads` / `dTLB-load-misses` / `stlb_hit` / `miss_causes_a_walk` = **次**,`walk_active` = **cycles** | D | 工作集 (8D 页) | dTLB-loads (次) | dTLB-load-misses (次) | stlb_hit (次) | miss_causes_a_walk (次) | walk_active (cycles) | |---:|------:|----------:|-----------------:|--------:|------------------:|------------:| | 4 | 32 | 818,021,923 | 1,688 | 33,785 | 1,105 | 55,766 | | 8 | 64 | 816,343,286 | 1,487 | 470,668 | 2,323 | 32,615 | | 16 | 128 | 814,835,148 | 1,780 | 3,140,609 | 2,556 | 98,218 | | 32 | 256 | 818,146,581 | 831 | **62,408,527** | 1,855 | 68,048 | | 64 | 512 | 784,208,378 | 901 | **141,985,029** | **29,990** | **338,468** | | 128 | 1024 | 819,972,564 | 3,829 | 108,765,066 | 13,455 | 461,777 | | **256** | **2048** | 820,031,140 | **15,997** | 94,210,609 | **25,862** | **1,113,178** | > `dTLB-loads` ≈ 8.19e8 = `rounds × N = 100000 × 8192`,计算量恒定 ✓ > > **注意**:`dTLB-load-misses` 计数在此 CPU 上明显偏小 (counter aliasing),下面以 `stlb_hit` + `miss_causes_a_walk` 子事件作为真实 L1 miss 分解信号。 ### 数据趋势的三阶段 把工作集与 L1 dTLB (256KB) / L2 STLB (6MB) 容量的关系标出来,三阶段就清楚了: | D | 工作集 | L1 dTLB 比 | STLB 比 | 阶段 | 主导信号 | |---:|:---:|:---:|:---:|---|---| | 4 | 128 KB | 0.5x ✅ | 0.02x ✅ | **L1 内** | 全 L1 命中,walk_active 极低 | | 8 | 256 KB | 1.0x ✅ | 0.04x ✅ | **L1 边界** | 工作集刚填满 L1,stlb_hit 极低 | | 16 | 512 KB | 2.0x ❌ | 0.08x ✅ | **L1 溢出** | stlb_hit 首次跳 6.7x (3.1M) | | 32 | 1 MB | 4.0x ❌ | 0.17x ✅ | **L1 全面失守** | stlb_hit 跳 20x (62M),但 STLB 仍扛得住 | | 64 | 2 MB | 8.0x ❌ | 0.33x ✅ | **PTE 缓存压力** | miss_causes_a_walk 跳 15x (30K),walk_active 跳 5x (338K) | | 128 | 4 MB | 16x ❌ | 0.67x ✅ | **L2 STLB 临界** | STLB 6MB 还能装下 4MB,stlb_hit 略降 | | **256** | **8 MB** | **32x ❌** | **1.33x ❌** | **STLB 溢出** | miss_causes_a_walk 仍涨,walk_active 跳 2.4x 至 1.1M | ### 三阶段序列图 一次 `load a[i]` 的地址翻译流程,随工作集增长的三个阶段: ```plantuml @startuml title CPU Load 地址翻译三阶段 — 随工作集 D 增长 actor "CPU Core\n(load a[i])" as CPU participant "L1 dTLB\n(64项, 1~2拍)" as L1TLB participant "TLB Prefetcher" as PREF participant "L2 STLB\n(1536项, 8~12拍)" as STLB participant "PMH\n(2个walk槽位)" as PMH participant "PWC\n(PDE×4 + PTE×32)" as PWC database "L3 / 内存\n(L3~200拍, 内存~300拍)" as MEM CPU -> L1TLB: VA 翻译请求 alt 阶段 1: D ≤ 8 (工作集 ≤ 256KB, L1 内) L1TLB --> CPU: L1 dTLB hit ✓\n(1~2 cycles) note right #90EE90: D=4: 128KB (0.5x L1)\nD=8: 256KB (1.0x L1)\nprefetcher 预取命中完美 else 阶段 2: D = 16~64 (工作集 512KB~2MB, STLB 接管) L1TLB -[#FFA500]> CPU: L1 miss ❌ PREF -[#FFA500]> STLB: prefetcher 已预取下一页 PTE L1TLB -> STLB: 查 STLB STLB --> CPU: STLB hit ✓\n(8~12 cycles) note right #FFA500: D=16: 512KB (0.08x STLB)\nD=32: 1MB (0.17x STLB)\nstlb_hit 跳至 62M 次\n\nD=64: 2MB (0.33x STLB)\nPTE 缓存压力开始显现 else 阶段 3: D = 128~256 (工作集 4~8MB, STLB 溢出) L1TLB -[#FF6347]> CPU: L1 miss ❌ L1TLB -> STLB: 查 STLB STLB -[#FF6347]> CPU: STLB miss ❌ note over PREF #FF6347: walk 太慢,prefetcher\n跟不上 demand load STLB -> PMH: 触发 page walk PMH -> PWC: 查 PWC (PDE/PTE 缓存) alt D = 64~128: PWC 部分命中\n(PTE 在 L1/L2 cache) PWC --> PMH: PTE (~12~50 cycles) else D = 256: PWC miss, PTE 被 a[i] 挤到 L3 PWC -[#FF6347]> PMH: PWC miss PMH -> MEM: 四级页表读 PTE MEM --> PMH: PTE (~200 cycles) note right #FF6347: a[i] 数据把 PTE 从\nL1/L2 cache 挤出去了 end PMH -> STLB: 填充 STLB 条目 STLB -> L1TLB: 填充 L1 dTLB L1TLB --> CPU: 翻译完成\n(stall ~50~200+ cycles) note right #FF6347: D=128: 4MB (0.67x STLB) 临界\nD=256: 8MB (1.33x STLB) 溢出\nwalk_active 跳至 1.1M cycles end @enduml ``` > **读图提示**:绿色 = L1 全命中 (低延迟),橙色 = L1 打穿但 STLB 兜底,红色 = STLB 也溢出,page walk 走内存成为瓶颈。 ### 关键观察 **1. `stlb_hit` 才是反映"TLB 真实在打"的指标**——D=32 时 stlb_hit 跳到 62M(占 dTLB-loads 的 7.6%),而 dTLB-load-misses 仍是 831 这个荒谬的小数字。 **2. `miss_causes_a_walk` 反映"STLB 也扛不住"**——D=64 之前都是 ~2K 的背景噪声;D=64 跳到 30K(15x),这是 STLB 即将崩溃的早期信号。 **3. `walk_active` 是最终的"综合账单"**——它由 walk 次数 × walk 单价决定,是性能断崖的真正指标。 **4. D=8 反常低**——工作集刚好填满 L1 dTLB (256KB),TLB prefetcher 命中完美,不需要 STLB 介入 → walk_active 反而比 D=4 低。 **5. STLB 容量对应的页数**:L2 STLB 1536 项 × 4KB = 6MB → D=128 (4MB) 还能装,D=256 (8MB) 超过 STLB → 真正的 STLB 抖动从 D=256 开始。 --- ## 五、逐 D 分步分析 按工作集与 L1 dTLB (256KB) / L2 STLB (6MB) 容量的关系,把 7 个 D 划成三个阶段,逐段解读 TLB 事件。 ### 阶段 1:L1 dTLB 内(D=4, 8)— 全命中,walk 极少 | D | 工作集 | stlb_hit (次) | miss_causes_a_walk (次) | walk_active (cycles) | 说明 | |---:|---:|---:|---:|---:|---| | 4 | 128KB | 33,785 | 1,105 | 55,766 | L1 dTLB 64 项(覆盖 256KB) 装得下,几乎全 L1 命中 | | 8 | 256KB | 470,668 | 2,323 | **32,615** | 工作集刚好填满 L1,prefetcher 完美命中 → walk_active 反而**最低** | **关键发现**:D=8 的 walk_active 反而比 D=4 还低 40%!原因是: - D=4:工作集 128KB,只用 L1 dTLB 的 32 项 (一半),但访问模式是 4 步长,prefetcher 难以完美预测 - D=8:工作集 256KB,**正好填满 L1 dTLB 64 项**。stride=8 的访问模式页号规律性强,prefetcher 一次走完 4 级页表后可以稳定预取下一页 → 后续 load 到 STLB 已就绪 → walk_active 反而下降 `stlb_hit` 在 D=8 比 D=4 高 14x(33K → 471K),但 `miss_causes_a_walk` 仍只是 2K —— STLB 没有被打到,walk_active 主要来自 §六 解释的系统背景噪声。 ### 阶段 2:L1 全面失守(D=16, 32)— STLB 接管 | D | 工作集 | stlb_hit (次) | miss_causes_a_walk (次) | walk_active (cycles) | 说明 | |---:|---:|---:|---:|---:|---| | 16 | 512KB | 3,140,609 | 2,556 | 98,218 | L1 dTLB 装不下了,stlb_hit 跳 6.7x | | 32 | 1MB | **62,408,527** | 1,855 | 68,048 | L1 完全失守,stlb_hit 跳 20x | **关键发现**:D=32 的 `stlb_hit` 跳到 62M(占 dTLB-loads 的 7.6%),但 `miss_causes_a_walk` 仍只是 1.8K,walk_active 也没有明显增长。 - 工作集 1MB,L2 STLB 6MB 装得下 - 7.6% 的访问走 STLB (L1 miss),但都在 STLB 命中 - TLB prefetcher 在 STLB 内仍能高效预取 - walk 几乎全部是系统噪声(A-bit 清零、TLB shootdown、中断泄漏) **这里有个反直觉的发现**:单纯看 walk_active,D=32 (68K) 比 D=16 (98K) 还低!但这不是 walk 变少了,而是 page walk 走的是 PTE 仍在 L1/L2 的 fast path + TLB prefetcher 提前完成得更好。**`stlb_hit` 才是 L1 失守的真正信号。** ### 阶段 3:STLB 也扛不住(D=64, 128, 256)— walk 真正变贵 | D | 工作集 | stlb_hit (次) | miss_causes_a_walk (次) | walk_active (cycles) | 说明 | |---:|---:|---:|---:|---:|---| | 64 | 2MB | **141,985,029** | **29,990** | 338,468 | STLB 覆盖 6MB 仍装得下,但 PTE 缓存压力显现 | | 128 | 4MB | 108,765,066 | 13,455 | 461,777 | STLB 覆盖 6MB 装得下 4MB,stlb_hit 略降 | | **256** | **8MB** | 94,210,609 | **25,862** | **1,113,178** | **8MB > STLB 6MB 容量** → 真正 STLB 抖动 | **关键发现 1:stlb_hit 在 D=64 达到峰值 142M,然后开始下降** 不是 stlb_hit 减少了,而是 STLB 不再能 100% 接住 L1 miss: - D=64: STLB 6MB 装得下 2MB 工作集,stlb_hit 142M - D=128: STLB 6MB 装得下 4MB 工作集,stlb_hit 略降到 109M(仍有富余) - D=256: 工作集 8MB > STLB 6MB,**部分 L1 miss 在 STLB 也 miss** → 流向 `miss_causes_a_walk` **关键发现 2:miss_causes_a_walk 在 D=64 首次跳到 30K (15x),D=256 仍维持 25K** - D=64 (2MB) 之前:~2K 的背景噪声 - D=64 (2MB) 跳到 30K:是 PTE 缓存压力的早期信号 - PTE 页表从 1 页 (D=32) 涨到 2 页 (D=64) - PTE 自身需要 2 × 4KB = 8KB 物理内存存放 - L1D 32KB 已被数据(128KB/趟)挤满 → PTE 落到 L2 → walk 变慢 - D=256 (8MB):stlb_hit 94M 仍很大,但 miss_causes_a_walk 25K + 每次 walk 变慢 → walk_active 跳到 1.1M **关键发现 3:walk_active 真正在 D=256 爆炸** | 阶段 | walk_active (cycles) | 增量 | |:---|---:|---:| | 阶段 1 (D=4, 8) | 32K~56K | — | | 阶段 2 (D=16, 32) | 68K~98K | 微弱 (×2) | | 阶段 3 起点 (D=64) | 338K | 跳 5x | | 阶段 3 中点 (D=128) | 461K | 1.4x | | 阶段 3 终点 (D=256) | **1,113K** | **跳 2.4x** | > **一句话总结三阶段**: > - 阶段 1 (D≤8):L1 装得下 → 全 L1 命中 → walk 几乎都是噪声 > - 阶段 2 (D=16~32):L1 失守但 STLB 接管 → **stlb_hit 暴增但 walk 不变** > - 阶段 3 (D≥64):STLB 也不够用 → **miss_causes_a_walk 跳 15x + PTE 被挤出 cache → walk 单价暴涨** --- ## 六、核心谜题深度拆解 ### 谜题 1:`dTLB-load-misses` 为什么是常数 (~1500)? **TLB prefetcher 做了 walk,但不计数。** 现代 Intel CPU 内置 next-page TLB prefetcher:看到页号单调递增 (page0→page1→page2...),提前把下一页的 PTE 拉进 STLB。stride 访问正好产生这个模式 → 数据访问到达时 TLB 已命中 → 从不触发 page walk → `dTLB-load-misses` 计数器不涨。 **那 ~1500 次是什么?** 均匀散布在整段测量的系统背景噪声,约 4 walk/秒: | 来源 | 机制 | |------|------| | A-bit 硬件置位 | 内核扫描清零 A-bit → 下次访问硬件 walk 置回 | | TLB shootdown | 其他 19 个核修改页表 → IPI 失效本核 TLB 项 | | 中断内核态泄漏 | `dTLB-load-misses` 没加 `:u`,内核中断的 miss 也计数 | | 页表页 cache eviction | PTE 页表被挤出所有 cache → 读 PTE 本身也 miss | ### 谜题 2:walk_active 为什么暴涨? **walk 次数没变(prefetcher 隐藏了),但每次 walk 的单价暴涨。** ```bash 平均 walk 延迟 = walk_active (cycles) ÷ dTLB-load-misses (次) D=4: 47,571 cycles ÷ 1,334 次 ≈ 35.7 cycles/walk D=32: 192,081 cycles ÷ 1,514 次 ≈ 126.9 cycles/walk (3.6x) D=256: 453,269 cycles ÷ 1,638 次 ≈ 276.7 cycles/walk (7.7x) ``` 根因是 §五 讲的 PTE 缓存竞争:page walk 做 4 步读,前三步 (PML4/PDPT/PMD) 始终在 L1/L2,最后一步 PTE 随 D 增大被逐级挤出 L1→L2→内存。 #### 但 prefetcher 不是"后台"的吗?为什么 walk 变贵会影响吞吐? 这是最常见的误解。"后台/异步"只说明 prefetcher **比 demand load 先启动 walk**,不是说 walk 不消耗资源、不影响流水线。prefetcher 和 demand load 共享三样东西: **① PMH (Page Miss Handler) 槽位竞争** ```bash PMH 只有 ~2 个 walk 槽位 (Skylake-SP) D=4 (walk ≈ 16 cycles): 槽位空闲率 90%+ → prefetcher 随时有坑位可用 D=256 (walk ≈ 200 cycles): 每个 walk 占坑 200 cycles → prefetcher 绝大多数时间无坑可用 → 无法预取 → STLB 不覆盖所有页 → demand load 到了, 发现 STLB miss → 但 prefetcher 已经在走这个 walk 了 (只是在等内存) → CPU 等这个 in-progress walk → 不计数为 miss ``` **② in-progress stall:等了但不算 miss** CPU 等翻译的完整状态机: ```bash load 发起 ├─ STLB hit → ~0 stall, 不计 miss ✓ ├─ walk 已在进行 → stall 等 walk 完成, 不计 miss ← 这才是主体! └─ walk 未开始 → 触发 walk + stall, 计 miss ← ~1500 次 ``` 当 D=256、每次 walk ≈ 200 cycles 时,绝大多数 load 走的是**中间那个分支**——prefetcher 启动了 walk,但 walk 太慢还没完成,load 照样等,只是计数器不涨。`walk_active` 把这些"等了但没计数"的 cycles 全部抓住了。 **③ 内存带宽争抢** prefetcher 读 PTE 走的是和 `a[i]++` 一样的 L3/内存总线: - D=4 时 walk 4 步全 L1 命中 (~4 cycles × 4 = ~16 cycles),prefetcher 几乎不吃带宽 - D=256 时 walk 最后一步打 L3 (~200 cycles),prefetcher 吃掉大量 L3 带宽 → 留给 `a[i]++` 的带宽减少 → 数组访问也变慢了 > **一句话**:prefetcher 的"异步"只省掉了 walk 启动的那一拍。walk 一旦启动就要占 PMH 槽位 + 打内存读 PTE + 完成前阻塞 demand load。walk 变贵 = prefetcher 变慢 = 跟不上了 = demand load 照样等 = 吞吐下降。`dTLB-load-misses` 只数了第三种分支 (~1500 次),但吞吐损失的主体在第二种分支,被 `walk_active` (cycles) 捕获。 ### 谜题 3:反常谷底——D=64/512/1024 没继续崩 最反直觉的数据:D=256 walk_active=453K,D=512 却跌回 88K。 这只能由 PWC (Page Walk Cache) 的非线性命中率解释。Intel PWC 结构: | 缓存 | 容量 (项) | 每项覆盖 | |------|:---:|------| | PDE 缓存 | 4 | 2 MB / 项 | | PTE 缓存 | 32 | 4 KB / 项 | - **D=256 (sweet spot of disaster)**:数组 8MB 正好用满 4 项 PDE,PTE 工作集 2048 远超 PTE 缓存 32 → 每次 walk 全程打内存 - **D=512/1024**:步长大到单次 walk 落在同一 PDE 内的概率升高,PTE 缓存的 LRU 命中率反而回升 → walk 拍数回落 > 简单的"步长越大越糟"不成立——PWC 命中率是步长与缓存容量的非线性函数。 ### 反推 CPU 微架构 两级断崖位置 (8D=256 和 8D=2048) 匹配: | 参数 | 推测值 | 说明 | |------|:---:|------| | L1 dTLB | 64 项 | 8D=64 处还是平台区 | | L2 STLB | ~1024~1536 | D=256 (2048页) 全面崩 | | PTE 缓存 | 32 项 | D=256 时 64x 溢出 | --- ## 七、结论 ### 常见误解 > "数组变大 → TLB 不够用 → TLB miss 变多 → Page Walk 次数增加 → 程序变慢" ### 真实原因 1. **Page Walk 次数几乎不变**——TLB prefetcher 看到顺序访问模式,提前异步 walk、提前填充 TLB,这些 walk 不体现在 `dTLB-load-misses` 里 2. **变化的是单次 walk 的代价**——PTE 页表被高频数组数据通过 LRU 逐级挤出 L1→L2→内存,CPU 读 PTE 时被迫打慢速存储 3. **`walk_active` 是唯一可信信号**——`dTLB-load-misses` 在 page walk 长度变化时完全失明 ### 完整逻辑链条 ```bash 固定访问总量 (8192 次/趟) → D 增大 → 遍历内存范围变大 → 数组占 L1/L2 cache 增多 → PTE 页表被逐级挤出 L1 → L2 → 内存 → Page Walk 读 PTE 延迟大幅上升 → TLB prefetcher 仍做 walk (次数不变) → walk 单价暴涨 → walk_active 断崖 → 程序性能断崖下跌 dTLB-load-misses 全程不变 ← 单纯看这个指标找不到问题 ``` --- ## 相关文档 - TLB 原理:[tlb](/concepts/cache/tlb.md) - 页表翻译:[page-table-translation](/concepts/cache/page-table-translation.md) - CPU 缓存体系:[cache-organization](/concepts/cache/cache-organization.md) ## 与缓存未命中实验的设计差异 > 两个实验都是"固定计算量、只改变访存方式、观测硬件瓶颈",但卡在不同的硬件层。详见 [demos/cache-miss](/demos/cache-miss/)。 ### 一句话区分 | | 本实验(TLB 抖动) | 缓存未命中实验 | |---|---|---| | 测什么 | 虚拟地址→物理地址的**翻译有多快**(TLB / STLB / Page Walk) | 数据在**哪一级缓存**(L1 / L2 / LLC / DRAM) | | 瓶颈硬件 | L1 dTLB → STLB → PMH → 页表遍历 | L1D → L2 → LLC → 内存控制器 | ### 设计差异 | 维度 | TLB 抖动 | 缓存未命中 | |------|---------|-----------| | 数组大小 | **32 KB → 32 MB**(自变量) | **固定 64 MB**(常量) | | 为什么这样选 | 大小本身就是 X 轴——不同大小触及不同数量的 4KB 页,从而测不同 TLB 层级的容量边界 | 64 MB > LLC ~30 MB,保证数据永远装不下,缓存行为必然暴露 | | 访问模式 | 统一跨步 (D=1,2,4,...,1024),步长是唯一变量 | 顺序 / 随机 / 跨步 (stride=2,8,16,64,256,1024) | | 步长的物理含义 | D 越大 → **触及的 4KB 页越多**(页数 = 8D) | stride 越大 → **同一条 cache line 复用越少** | | D=16 代表什么 | 128 页 (512KB),超过 L1 dTLB (64条) 但远在 STLB (1536条) 之内,性能几乎无变化 | 跨 64B = 刚好 1 条 cache line,无 L1 复用,吞吐开始下降 | | D=256 代表什么 | 2048 页 (8MB) > STLB 6MB → **"断崖式"性能下跌** | 跨 1KB = 16 条 cache line,预取器也救不了,吞吐继续跌但平缓 | ### 为什么同样 D=256,一个断崖、一个渐进 缓存实验:数据在 D=16 时就已经打穿 L1(每次跨一条 cache line),D=64 时 L2 也不行,D=256 是进一步压垮 LLC。整个过程是**逐级衰退**——没有一刀切的阈值,因为各级缓存大小不同,构成多个软边界。 TLB 实验:STLB 1536 条 × 4KB = 6MB 是一个**硬容量边界**。D≤128(工作集 ≤4MB)在边界内 → walk 轻量;D=256(8MB)跨过边界 → PTE 页表被数据挤出 cache → walk 单价从 36 拍跳至 277 拍,形成**断崖**。缓存从不会有这种断崖——因为缓存总是"miss 多则慢,miss 少则快",中间有 LLC 兜底。 ### 互补关系 一次完整访存 = 地址翻译 + 数据读取,两个实验各隔离一半: ```bash TLB 实验的领域 缓存实验的领域 ┌──────────────┐ ┌──────────────┐ │ L1 dTLB │ │ L1D cache │ │ ↓ miss │ │ ↓ miss │ │ STLB │ │ L2 cache │ │ ↓ miss │ │ ↓ miss │ │ Page Walk │ │ LLC (L3) │ │ ↓ (读PTE) │ │ ↓ miss │ │ DRAM ←───────┼── 重叠区 ────│→ DRAM │ └──────────────┘ └──────────────┘ ``` 两个实验都在 DRAM 有"最终的慢速出口",但走进去的路径不同。最坏情况是二者叠加:**STLB miss → page walk 读 PTE 到 DRAM → 拿到物理地址 → LLC miss → 再等一次 DRAM 返回数据**。 --- ## TLB 事件全览
展开完整事件列表 #### 一级事件 (perf 标准命名) | 事件名 | 含义 | |--------|------| | `dTLB-loads` | 数据 TLB 读翻译总次数 | | `dTLB-load-misses` | L1+L2 STLB 都 miss (现代 Intel) | | `dTLB-stores` | 数据 TLB 写翻译总次数 | | `dTLB-store-misses` | 数据 TLB 写未命中 | | `iTLB-loads` | 指令 TLB 翻译总次数 | | `iTLB-load-misses` | 指令 TLB 未命中 | #### 二级 sub-event (Intel 特有, `dtlb_load_misses.*`) | 事件名 | 含义 | |--------|------| | `miss_causes_a_walk` | L1+L2 STLB 都 miss,触发 page walk 次数 | | `walk_completed` | Page walk 完成次数 | | `walk_active` / `walk_duration` | Page walk 总 cycle 数 (Skylake / Ice Lake+) | | `walk_pending` | Page walk 挂起 cycle 数 | | `stlb_hit` | L1 miss 但 L2 STLB 命中 | > L1 miss 总量 = `miss_causes_a_walk` + `stlb_hit` #### 三级 sub-event:page walk 命中哪级 cache (Ice Lake+) | 事件名 | 含义 | |--------|------| | `page_walker_loads.dtlb_l1` | PTE 在 L1 | | `page_walker_loads.dtlb_l2` | PTE 在 L2 | | `page_walker_loads.dtlb_l3` | PTE 在 L3 | | `page_walker_loads.dtlb_memory` | PTE 在内存 |
--- ## 附录:x86_64 四级页表完整详解 ### 一、虚拟地址拆分(48 位规范地址) ```bash [47:39] PML4(PGD) 索引 9 位 [38:30] PDPT(PUD) 索引 9 位 [29:21] PMD 索引 9 位 [20:12] PTE 索引 9 位 [11:0] 页内偏移 12 位 (4KB) ``` CR3 寄存器保存 PML4 页表物理基地址。 所有四级页表项格式一致:64 位 (8 字节)。每页 4KB ÷ 8B = 512 项,正好对应 9 位索引 (2^9=512)。 通用字段:PFN (物理页框号) 指向下一级页表 / 数据页;权限位 (R/W/U/S/A/D 等)。 ### 二、逐级讲解 **第一级 PML4(PGD)**:CR3 → PML4 物理地址 → 用 VA[47:39] 找到 PDPT 物理地址。进程唯一一页,永远在 L1 cache。 **第二级 PDPT(PUD)**:用 VA[38:30] 找到 PMD 物理地址。1 页,永远在 L1/L2。 **第三级 PMD**:用 VA[29:21] 找到 PTE 页表物理地址。几页,永远在 L1/L2。 > 前三级总结:页数极少、全局共用、永远在 L1/L2,不会被挤占。所有 Page Walk 延迟变化集中在最后一级 PTE。 **第四级 PTE**:用 VA[20:12] 找到数据页 PFN。PTE 页表页数随访问内存范围线性增长——这是整个实验唯一变化的变量。 物理地址 = (PTE.PFN << 12) | VA[11:0] ### 三、完整 Page Walk 流程 ```bash CR3 → VA[47:39]→PML4 → VA[38:30]→PDPT → VA[29:21]→PMD → VA[20:12]→PTE → VA[11:0]+PFN → 物理地址 L1 命中 L1 命中 L1/L2 命中 ★ 唯一变量 ★ 拼接完成 ~1 cycle ~1 cycle ~2~12 cycles L1命中 ~4 cycles L2命中 ~12 cycles L3命中 ~50 cycles 内存 ~200+ cycles ``` ### 四、实验闭环 1. PGD/PUD/PMD 前三段耗时固定,永远在 cache,与 D 无关 2. PTE 页数随 D 增大 → PTE 缓存行被数组数据 LRU 挤出 L1→L2→内存 3. TLB prefetcher 异步 walk,次数不变,不被计数 4. 变化的是 walk 单价 → walk_active 断崖 ### 五、补充 **大页 (2MB) 优化**:2MB 页 → 页内偏移 21 位 → 四级变三级 (省去 PTE) → 大幅减少 PTE 页表和 Page Walk 开销。高性能程序普遍使用大页的原因。 **页表是普通内存**:页表本身也是物理内存里的数据,和业务数据一样经过 cache,同样被 LRU 淘汰——这就是 PTE 被挤出 cache 的物理基础。