← 技术博客
· 约 9 分钟 ZH

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 KB1536 × 4KB = 6 MB
2 MB1536 × 2MB = 3 GB

相差 512 倍。

一个 256MB 的工作集,用 4KB 页时远超 TLB 覆盖范围,随机访问几乎每次都要 miss;换成 2MB 页则轻松装下。按这个差距推算,大页带来的改善应该相当可观。


二、测量方案

要让这个测量成立,有几件事必须做对。

2.1 指针追逐,而不是顺序遍历

最直接的写法测不出任何东西:

for (size_t i = 0; i < n; i++)
    sum += buf[i * STRIDE];

两个原因,都是硬件在「帮忙」:

  1. 硬件预取器会识别固定步长,提前把数据和页表项都加载好
  2. 访存可以并行。地址互相独立,现代 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 次,结果丢弃。四个作用,按重要性排列:

  1. CPU 频率爬升。现代 CPU 空闲时降频,负载上来才升频。不预热的话测量区间的前一部分跑在低频上,这往往是最大的误差源
  2. 让 TLB 和 cache 进入稳态。注意不是「变快」,工作集远大于 TLB,预热后仍然是高 miss 率。预热的意义是让 miss 率达到长期运行下的代表性水平,而不是测到一个还没开始 miss 的乐观值
  3. 训练分支预测器
  4. 把起点推到链上一个随机位置

预热必须走和测量完全相同的代码路径,否则预热的是错的东西。

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
CPUIntel 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,预留充足
THPmadvise(因此对照组天然纯净,无需依赖 MADV_NOHUGEPAGE

工作集 256MB,迭代两千万次,预热两百万次。

两条自检均通过:单环覆盖全部 65536 个槽位,前 16 步步长非恒定。


五、实测结果与预期不符

三次运行:

4KB 页2MB 大页加速比
190.06 ns77.04 ns1.17x
285.65 ns76.64 ns1.12x
386.74 ns78.68 ns1.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 ns34.85 ns
dTLB-loads72,486,30424,745,791
dTLB-load-misses22,003,0652,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 纳秒。

这个模型错在把页表当成了普通的、冷的内存。 实际上:

  1. 页表本身是可缓存的普通内存。 CR3 指向的 PML4 只有一张,PDPT 也没几张,顶层页表被反复访问,几乎常驻 cache。越往下越发散,但至少前两级基本是命中的
  2. 现代 CPU 有专门的 page walk cache(Intel 称 paging-structure caches),缓存中间级页表项。TLB miss 时如果其中已有 PML4E 和 PDPTE,这次 walk 可能只需要访问最后一到两级

所以「TLB miss 等于四次内存访问」是一个上限,不是常态。实测下来它更接近一次 L2 或 L3 访问的代价。


八、工作集规模对加速比的影响

把工作集从 64MB 扫到 1GB:

工作集4KB2MB差值加速比
64 MB42.98 ns33.51 ns9.471.28x
128 MB47.64 ns34.81 ns12.831.33x
256 MB86.50 ns78.79 ns7.711.10x
512 MB119.71 ns110.02 ns9.691.10x
1024 MB137.01 ns116.91 ns20.101.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 的延迟如何逼出了描述符回写这个设计。