TLB miss 的实际代价:一次大页 benchmark 的测量与分析
用指针追逐测量 4KB 页与 2MB 大页下的随机访存延迟。实测加速比约 1.1 倍,明显低于 TLB 覆盖范围相差 512 倍所暗示的量级。perf 计数显示两组 dTLB miss 率分别为 30.35% 与 0.01%,TLB 机制符合预期;差距有限的原因是单次 TLB miss 的代价约为 12 纳秒,远低于「四次内存访问」的常见估计。
「大页能减少 TLB miss,所以更快」这句话几乎出现在每一篇讲 DPDK 或数据库调优的文章里。但它快多少,很少有人给出数字。
本文记录一次测量:测量方案的设计、四个会让这类 benchmark 静默失效的陷阱、实测结果,以及结果与理论预期不符时的排查过程。
一、理论预期
x86-64 用四级页表,一次地址翻译最坏需要四次内存访问。TLB 缓存「虚拟页到物理页」的映射,命中就跳过整个 page walk。
问题在于 TLB 的容量很小。Intel 服务器 CPU 的 L2 STLB 典型只有 1500 到 2000 项。按 1536 项算覆盖范围:
| 页大小 | 覆盖范围 |
|---|---|
| 4 KB | 1536 × 4KB = 6 MB |
| 2 MB | 1536 × 2MB = 3 GB |
相差 512 倍。
一个 256MB 的工作集,用 4KB 页时远超 TLB 覆盖范围,随机访问几乎每次都要 miss;换成 2MB 页则轻松装下。按这个差距推算,大页带来的改善应该相当可观。
二、测量方案
要让这个测量成立,有几件事必须做对。
2.1 指针追逐,而不是顺序遍历
最直接的写法测不出任何东西:
for (size_t i = 0; i < n; i++)
sum += buf[i * STRIDE];
两个原因,都是硬件在「帮忙」:
- 硬件预取器会识别固定步长,提前把数据和页表项都加载好
- 访存可以并行。地址互相独立,现代 CPU 靠 line fill buffer 能让十几个 cache miss 的延迟互相重叠
顺序访问测到的是带宽,而 TLB miss 的代价体现在延迟上。
所以改用指针追逐:
p = *(void **)p;
把内存切成若干槽位,每个槽位开头存放「下一个槽位的地址」,串成一个环。下一次访问的地址来自本次读到的值,构成真实的数据依赖:CPU 无法乱序执行,预取器无法预测,多次 miss 无法并行。每次迭代的耗时就是一次完整内存访问的延迟。
2.2 随机置换,并且是覆盖全部槽位的单环
用 Fisher-Yates 洗牌打乱访问顺序,然后按置换把槽位首尾相接:
for (size_t i = 0; i < n; i++) {
void **slot = (void **)(base + perm[i] * STRIDE);
*slot = base + perm[(i + 1) % n] * STRIDE;
}
(i + 1) % n 让最后一步指回第一步,形成闭环。这样写的关键在于:按一个置换串联,数学上保证是单个哈密顿环,覆盖全部 n 个槽位。
一个常见的错误写法是给每个槽位随机挑一个 next:
*slot = base + (rand() % n) * STRIDE; /* 错误 */
这会形成若干互不相连的小环。从某一点出发可能只在一个几十元素的小环里打转,工作集从 256MB 缩成几十 KB,装进 L2 之后 TLB 压力直接归零。
2.3 步长取一个页的大小
STRIDE = 4096,每跳一步换一个页,使每次访问都需要一个新的 TLB 条目,把 TLB 压力最大化。
代价是每个 4096 字节的槽位只用开头 8 字节,其余浪费。这是刻意的。
2.4 对照组显式拒绝透明大页
#ifdef MADV_NOHUGEPAGE
if (!huge)
madvise(p, size, MADV_NOHUGEPAGE);
#endif
理由见第三节的陷阱二。
2.5 防止循环被优化掉
static volatile uintptr_t sink;
sink = (uintptr_t)p;
volatile 意味着每次访问都必须真实发生,因此写入 volatile 变量是一个可观测的副作用。编译器为了算出 p 的最终值就必须跑完所有迭代,而每次迭代依赖前一次,它也无法走捷径。
这里顺带说明,volatile 只有两个合法用途:访问设备寄存器(MMIO),以及防止优化器删掉 benchmark 代码。它不是线程同步工具,不产生任何 CPU 内存屏障,也不保证原子性。
2.6 成批计时
clock_gettime 自身开销约 12 纳秒,与被测量的几十纳秒同量级。因此计时只在循环外各做一次:
double t0 = now_sec();
for (size_t i = 0; i < iters; i++)
p = *(void **)p;
double t1 = now_sec();
return (t1 - t0) * 1e9 / (double)iters;
跑两千万次,时钟开销被摊薄到每次约 0.0000012 纳秒。
2.7 预热
正式计时前先空跑 iters / 10 次,结果丢弃。四个作用,按重要性排列:
- CPU 频率爬升。现代 CPU 空闲时降频,负载上来才升频。不预热的话测量区间的前一部分跑在低频上,这往往是最大的误差源
- 让 TLB 和 cache 进入稳态。注意不是「变快」,工作集远大于 TLB,预热后仍然是高 miss 率。预热的意义是让 miss 率达到长期运行下的代表性水平,而不是测到一个还没开始 miss 的乐观值
- 训练分支预测器
- 把起点推到链上一个随机位置
预热必须走和测量完全相同的代码路径,否则预热的是错的东西。
2.8 自检
benchmark 最危险的不是跑不出来,而是跑出一个错的数字而你以为它对。所以代码里加了两条断言,任一不通过就直接退出:
/* 一、走 n 步必须回到起点,证明是覆盖全部槽位的单环 */
void *q = start;
for (size_t k = 0; k < n; k++) q = *(void **)q;
if (q != start) { fprintf(stderr, "链不是单环\n"); exit(1); }
/* 二、前 16 步的步长不能恒定,恒定说明置换退化成了轮转 */
q = start;
ptrdiff_t d0 = (char *)*(void **)q - (char *)q;
int constant = 1;
for (int k = 0; k < 16 && constant; k++) {
void *nx = *(void **)q;
if ((char *)nx - (char *)q != d0) constant = 0;
q = nx;
}
if (constant) { fprintf(stderr, "置换失效,预取器会生效\n"); exit(1); }
第二条尤其重要。洗牌代码写错时,置换很容易退化成一个轮转([0,1,2,3,4] 变成 [1,2,3,4,0]),链就成了固定步长的顺序遍历。形式上仍然是指针追逐,有数据依赖,但地址序列完全可预测,预取器照样生效,于是绕回第 2.1 节要避免的问题,而程序不会报任何错。
三、四个会让这类 benchmark 静默失效的陷阱
上一节的设计分别对应四个陷阱。单独列出来,因为它们的共同特征是不报错、只给出错误的数字。
陷阱一:被测对象被硬件优化掉了
顺序或固定步长的访问会被预取器识别,测到的是带宽而非延迟。
检测:加 2.8 的步长自检。或者用 perf stat -e dTLB-load-misses 看 miss 数是否符合预期——如果工作集远超 TLB 覆盖却几乎不 miss,说明访问模式被优化了。
陷阱二:对照组被透明大页污染
Linux 的透明大页(THP)如果处于 always 模式:
$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
那么 mmap 出来的匿名映射会被内核自动提升成 2MB 大页。于是所谓的「4KB 组」其实也是大页,两组都是大页,加速比自然接近 1。
这个陷阱最难想到,因为问题不在实验组,在对照组。对照组看起来「什么都没做」,反而容易被认为是安全的。
检测:grep -E "AnonHugePages|KernelPageSize" /proc/<pid>/smaps 看实际用的页大小。
预防:2.4 节的 MADV_NOHUGEPAGE。
陷阱三:被测代码被编译器删掉
循环结果没人使用时,-O2 会把整个循环判定为死代码删除,测出接近 0 的数字。
检测:一个好到不真实的数字,比一个差的数字更可疑。差的数字你会去查原因,好的数字你会直接相信,这是 benchmark 最危险的地方。
预防:2.5 节的 volatile sink。
陷阱四:测量工具的噪声与信号同量级
逐次调用 clock_gettime 会让约 12 纳秒的时钟开销污染每个样本。
一个该养成的习惯:写完 benchmark 先问一句,测量噪声和被测信号差几个数量级?差两个数量级以上才安心。
顺带一提,循环本身也有开销(自增、比较、回跳,约一到两个周期)。测内存延迟(约 240 个周期)时可以忽略;但如果要测 L1 命中延迟(约 4 个周期),循环开销就和被测量同量级了,那时必须手工展开循环。同一个写法在不同量级下的有效性并不一样。
四、测试环境
| 项 | 值 |
|---|---|
| 实例 | 阿里云 ecs.c9i.large |
| CPU | Intel Xeon 6982P-C @ 3.9GHz,2 vCPU(1 物理核 + 超线程) |
| 内存 | 3.5 GiB |
| 系统 | Ubuntu 24.04.4,内核 6.8.0-138 |
| 编译器 | gcc 13.3.0,-O2 |
| 大页 | 512 × 2MB,预留充足 |
| THP | madvise(因此对照组天然纯净,无需依赖 MADV_NOHUGEPAGE) |
工作集 256MB,迭代两千万次,预热两百万次。
两条自检均通过:单环覆盖全部 65536 个槽位,前 16 步步长非恒定。
五、实测结果与预期不符
三次运行:
| 次 | 4KB 页 | 2MB 大页 | 加速比 |
|---|---|---|---|
| 1 | 90.06 ns | 77.04 ns | 1.17x |
| 2 | 85.65 ns | 76.64 ns | 1.12x |
| 3 | 86.74 ns | 78.68 ns | 1.10x |
约 1.1 倍。
按第一节的推算,4KB 组的工作集是 TLB 覆盖范围的 40 多倍,应该几乎每次访问都 miss;大页组则完全装得下。1.1 倍与这个差距不相称。
此时有两种可能:测量有问题,或者理论预期本身有问题。第三节的四个陷阱都已经在设计阶段规避、并且有自检兜底,所以需要一个能直接观测 TLB 本身的手段,而不是继续从延迟去推测。
六、用 perf 直接观测
给程序加一个参数,让两个阶段可以分开计数:
perf stat -e dTLB-loads,dTLB-load-misses ./tlb_bench 128 0 # 只跑 4KB
perf stat -e dTLB-loads,dTLB-load-misses ./tlb_bench 128 1 # 只跑大页
结果(128MB 工作集):
| 4KB 页 | 2MB 大页 | |
|---|---|---|
| 平均延迟 | 47.12 ns | 34.85 ns |
| dTLB-loads | 72,486,304 | 24,745,791 |
| dTLB-load-misses | 22,003,065 | 2,802 |
| miss 率 | 30.35% | 0.01% |
两千两百万次访问中,4KB 组 miss 了 22,003,065 次,几乎每一次访问都 miss;大页组只 miss 了 2,802 次。
「TLB 覆盖范围相差 512 倍」这件事是真的,而且完全生效了。测量没有问题。
那么问题出在理论预期上。
七、结论:单次 TLB miss 的代价约为 12 纳秒
既然 4KB 组几乎每次都 miss、大页组几乎不 miss,两者的延迟差就是 TLB miss 的净代价:
47.12 - 34.85 = 12.27 ns ≈ 48 个周期 @3.9GHz
这与「四次内存访问约 320 纳秒」的估计相差一个数量级。
原本的心智模型是:
四级页表,TLB miss 走一次 page walk,四次内存访问,每次约 80 纳秒,合计约 320 纳秒。
这个模型错在把页表当成了普通的、冷的内存。 实际上:
- 页表本身是可缓存的普通内存。
CR3指向的 PML4 只有一张,PDPT 也没几张,顶层页表被反复访问,几乎常驻 cache。越往下越发散,但至少前两级基本是命中的 - 现代 CPU 有专门的 page walk cache(Intel 称 paging-structure caches),缓存中间级页表项。TLB miss 时如果其中已有 PML4E 和 PDPTE,这次 walk 可能只需要访问最后一到两级
所以「TLB miss 等于四次内存访问」是一个上限,不是常态。实测下来它更接近一次 L2 或 L3 访问的代价。
八、工作集规模对加速比的影响
把工作集从 64MB 扫到 1GB:
| 工作集 | 4KB | 2MB | 差值 | 加速比 |
|---|---|---|---|---|
| 64 MB | 42.98 ns | 33.51 ns | 9.47 | 1.28x |
| 128 MB | 47.64 ns | 34.81 ns | 12.83 | 1.33x |
| 256 MB | 86.50 ns | 78.79 ns | 7.71 | 1.10x |
| 512 MB | 119.71 ns | 110.02 ns | 9.69 | 1.10x |
| 1024 MB | 137.01 ns | 116.91 ns | 20.10 | 1.18x |
差值稳定在 8 到 13 纳秒之间,而基础延迟从 33 纳秒一路涨到 137 纳秒。
加速比 = (base + TLB代价) / base
base 越大,同样的 TLB 代价占比越小,加速比越接近 1。
base 之所以上涨,是因为触及的 cache line 总量在涨:64MB 工作集只摸到 16384 条 cache line(1MB,装得进 L2 或 L3);1GB 时是 262144 条(16MB,溢出 L3 落进 DRAM)。
由此可得一个与直觉相反的结论:工作集越大,大页的「加速比」反而越不显眼,尽管 TLB miss 的绝对次数更多了。因为分母涨得比分子快。
最后一行的差值反弹到 20 纳秒,是一个二阶效应:1GB 用 4KB 页需要 262144 个 PTE,页表本身涨到了 2MB,于是 page walk 自己也开始 cache miss 了。
九、大页的另一个理由
如果只看这个实验,大页带来 10% 到 30% 的延迟改善,不算惊人。但让大页在 DPDK 这类场景里成为必需品的,是另一个理由,而这一条没有替代方案。
设备做 DMA 时用的是物理地址。考虑一个跨页的缓冲区:
虚拟地址空间(连续): [========== 8KB 缓冲区 ==========]
页表映射
物理地址空间(4KB页): [页A: 0x1000] ..... [页B: 0x9F000]
物理上并不连续
网卡视角: 拿到物理地址 0x1000 和长度 8192,
于是 DMA 写 0x1000 到 0x3000,
但 0x2000 那一页不属于这个缓冲区,
结果是内存踩踏
CPU 通过页表看到连续的虚拟地址,但设备看到的是物理地址,它不理解页表。普通 4KB 页由内核按需分配,物理上高度碎片化;一个 2MB 大页在物理上天然是连续的 2MB。
所以「大页是性能优化」这个说法只说了一半。在这个场景下它是正确性要求,没有它程序不能正确工作,而不只是慢。
需要补充的前提是:这一条只在设备直接使用物理地址时成立。有 IOMMU 做地址重映射时,不连续的物理页可以被映射成连续的设备地址,这个要求就能被绕过。
十、这组数字的适用边界
- 测试环境是虚拟机。 guest 里是二维页表(GVA 到 GPA 再到 HPA),遍历 guest 页表时每一级页表项本身也要过 EPT,一次 TLB miss 最坏需要 24 次访存。绝对值不能外推到物理机,物理机上 TLB miss 应该更便宜,加速比可能更小
- 只有一个物理核(两个超线程),没有多核与 NUMA 的干扰。环境干净但不真实
- 这是纯访存延迟的测量,不包含真实数据面中的其它开销
dtlb_load_misses.walk_active这类 page walk 周期事件在这台虚拟机上被 PMU 屏蔽,无法做更细的分解
十一、总结
三条:
一、单次 TLB miss 的代价约为 12 纳秒,而不是四次内存访问。 页表本身可缓存,加上 page walk cache,实际代价更接近一次 L2 或 L3 访问。
二、大页的加速比会随工作集增大而下降。 TLB 代价基本恒定,而基础访存延迟随工作集上涨,分母涨得更快。
三、当数字与预期不符时,两者都要怀疑。 本文的四个陷阱都是「测量错了」的情形,值得先排查;但排查完之后,如果测量站得住,就该回头检验预期本身。分辨这两者需要一个能直接观测被测对象的手段——这里是 perf 的 TLB 计数器,而不是继续从延迟去推理。
代码在 github.com/XingCihang/perf-lab,含自检与 perf 用法。
这是九月 DPDK 学习笔记的一部分。后续会写 UIO / VFIO / no-IOMMU 的安全模型差异、虚拟化下的二维页表,以及 MMIO 的延迟如何逼出了描述符回写这个设计。