Appearance
TLB 页表翻译缓存抖动实验
快速开始
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 <D> # 单步长模式,100000 趟(适合 perf record)
./tlb_thrashing <D> <rounds> # 单步长模式,指定趟数核心结论
只看表里三个数字就够了:
| 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<int> 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<double>(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~40952.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 拍更完整的页表翻译原理见 concepts/cache/page-table-translation.md,逐级物理地址还原过程见附录。
三、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 事件全览。
什么是 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 | Second-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四、实测数据 (shrdlab31, 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] 的地址翻译流程,随工作集增长的三个阶段:

读图提示:绿色 = 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 次数增加 → 程序变慢"
真实原因
- Page Walk 次数几乎不变——TLB prefetcher 看到顺序访问模式,提前异步 walk、提前填充 TLB,这些 walk 不体现在
dTLB-load-misses里 - 变化的是单次 walk 的代价——PTE 页表被高频数组数据通过 LRU 逐级挤出 L1→L2→内存,CPU 读 PTE 时被迫打慢速存储
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 原理:concepts/cache/tlb.md
- 页表翻译:concepts/cache/page-table-translation.md
- CPU 缓存体系:concepts/cache/cache-organization.md
与缓存未命中实验的设计差异
两个实验都是"固定计算量、只改变访存方式、观测硬件瓶颈",但卡在不同的硬件层。详见 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四、实验闭环
- PGD/PUD/PMD 前三段耗时固定,永远在 cache,与 D 无关
- PTE 页数随 D 增大 → PTE 缓存行被数组数据 LRU 挤出 L1→L2→内存
- TLB prefetcher 异步 walk,次数不变,不被计数
- 变化的是 walk 单价 → walk_active 断崖
五、补充
大页 (2MB) 优化:2MB 页 → 页内偏移 21 位 → 四级变三级 (省去 PTE) → 大幅减少 PTE 页表和 Page Walk 开销。高性能程序普遍使用大页的原因。
页表是普通内存:页表本身也是物理内存里的数据,和业务数据一样经过 cache,同样被 LRU 淘汰——这就是 PTE 被挤出 cache 的物理基础。