一句话 + 关键问题
一句话:在 MMU 里放一个很小但极快的地址转换缓存(TLB),把最近用过的 VPN→PFN 转换存起来; 绝大多数访存都能在 TLB 里命中,从而省掉那次去内存查页表的额外访问——TLB 在真正意义上让虚拟内存变得可行。
如何加速地址转换?如何才能加速地址转换,并避免分页似乎必需的那次额外内存访问?需要什么硬件支持?操作系统又需要做什么?
How can we speed up address translation, and generally avoid the extra memory reference that paging seems to require? What hardware support is required? What OS involvement is needed?
第 18 章留下两个问题:页表太慢、页表太大。本章解决"慢":因为映射信息存放在物理内存中,分页在逻辑上要求每次取指、每次显式的 load/store 之前, 都先访问一次内存去读页表项。要让它变快,操作系统需要老朋友——硬件的帮助。本章之后,第 20 章再解决"大"。
核心概念速览
TLB
地址转换旁路缓冲存储器(translation-lookaside buffer),MMU 的一部分,是热门虚拟到物理地址转换的硬件缓存。更贴切的名字是"地址转换缓存"。
命中 / 未命中
TLB 里有这个 VPN 的转换叫命中(hit),直接得到 PFN;没有叫未命中(miss),必须去查页表。
命中率(hit rate)
命中次数 ÷ 访问总次数。只要命中率接近 100%,程序就几乎像没被虚拟化一样快。
空间 / 时间局部性
访问了 x,很快会访问 x 附近(空间);刚访问过的,很快会再访问(时间)。TLB 依靠两者取得高命中率。
硬件 vs 软件管理
未命中时由硬件遍历页表(x86,CISC)或抛出异常交给 OS 处理(MIPS、SPARC,RISC)。
全相联(fully associative)
一个转换可以放在 TLB 的任意位置,硬件并行比较所有项,所以每项必须同时存 VPN 和 PFN。
ASID
地址空间标识符(address space identifier),让 TLB 能同时存多个进程的转换,切换进程时不必清空 TLB。
TLB 覆盖范围
TLB 项数 × 页大小。程序短时间访问的页数超过它,就会大量未命中(exceeding the TLB coverage)。
TLB 基本算法
假设使用简单的线性页表,并且由硬件处理 TLB 未命中。每次虚拟内存引用,硬件的动作如下(原书图 19.1):
1 VPN = (VirtualAddress & VPN_MASK) >> SHIFT
2 (Success, TlbEntry) = TLB_Lookup(VPN)
3 if (Success == True) // TLB 命中
4 if (CanAccess(TlbEntry.ProtectBits) == True)
5 Offset = VirtualAddress & OFFSET_MASK
6 PhysAddr = (TlbEntry.PFN << SHIFT) | Offset
7 Register = AccessMemory(PhysAddr)
8 else
9 RaiseException(PROTECTION_FAULT)
10 else // TLB 未命中
11 PTEAddr = PTBR + (VPN * sizeof(PTE))
12 PTE = AccessMemory(PTEAddr)
13 if (PTE.Valid == False)
14 RaiseException(SEGMENTATION_FAULT)
15 else if (CanAccess(PTE.ProtectBits) == False)
16 RaiseException(PROTECTION_FAULT)
17 else
18 TLB_Insert(VPN, PTE.PFN, PTE.ProtectBits)
19 RetryInstruction()
- 第 1~2 行:取出 VPN,查 TLB。
- 第 3~7 行(命中):从 TLB 项中取出 PFN,与偏移拼成物理地址,访问内存。全程不碰页表。
- 第 11~12 行(未命中):代价就在这里——额外的一次内存访问去读 PTE(页表更复杂时还不止一次)。
- 第 18~19 行:把转换装进 TLB,然后重新执行这条指令;这一次就会命中。
例子:访问一个数组
原书的例子:一个有 10 个 4 字节整数的数组,从虚拟地址 100 开始。地址空间是很小的 8 位,页大小 16 字节,
于是虚拟地址 = 4 位 VPN(16 个虚拟页)+ 4 位偏移(每页 16 字节)。a[0] 位于(VPN=06,偏移=04),这一页只放得下 3 个整数;
a[3]~a[6] 在 VPN 07;最后三个 a[7]~a[9] 在 VPN 08。循环如下(只考虑对数组的访问,忽略 i、sum 和指令本身):
int i, sum = 0;
for (i = 0; i < 10; i++) {
sum += a[i];
}
结果:miss, hit, hit, miss, hit, hit, hit, miss, hit, hit,命中率 70%。即使这是程序第一次访问这个数组,TLB 也能凭空间局部性提高性能: 元素紧挨着,只有每页的第一次访问才会未命中。在下面的模拟器里亲自走一遍,再试试把页大小改成 32 字节、把数组访问两遍、换一种替换策略。
TLB + 页表:逐次访问
虚拟地址空间
TLB
页表(内存中,所有页有效)
再看两件事:
- 页大小的作用:页大小翻倍到 32 字节,这个数组的未命中会更少。典型页大小是 4KB,这种密集的数组访问性能极好,每页只遇到一次未命中。
- 时间局部性:如果循环结束后程序很快再访问一遍这个数组,只要 TLB 大到能装下所需的转换,第二遍全部命中:hit ×10。
缓存是计算机系统中最基本的性能技术之一,用来"让常见情况更快"。硬件缓存利用指令和数据引用的局部性:时间局部性(循环变量、循环里的指令被反复访问)和空间局部性(顺序扫过一个数组)。 它们是经验法则而不是铁律,取决于程序本身。
既然缓存这么好,为什么不做一个超大的缓存把所有数据都放进去?因为物理定律:想要快,缓存就必须小——光速之类的物理约束会起作用,任何大缓存必然慢,也就失去了意义。 我们只能用小而快的缓存,剩下的问题是如何用好它们。
谁来处理 TLB 未命中?
两种答案:硬件,或软件(OS)。
- 硬件管理的 TLB:在过去,复杂指令集计算机(CISC)的设计者不太信任"那些狡猾的 OS 开发者",于是由硬件全权处理未命中。 硬件必须确切知道页表在内存中的位置(通过页表基址寄存器,图 19.1 第 11 行)以及页表的确切格式;未命中时硬件"遍历"页表,找到正确的 PTE,更新 TLB,重试指令。 例子:Intel x86,使用固定格式的多级页表(见下一章),当前页表由 CR3 寄存器指向。
- 软件管理的 TLB:更现代的精简指令集计算机(RISC),如 MIPS R10k、Sun SPARC v9。未命中时硬件只是抛出异常(图 19.3 第 11 行):暂停当前指令流,提升到内核态,跳转到陷阱处理程序。 这段 OS 代码查页表,用特殊的特权指令更新 TLB,然后从陷阱返回,硬件重试指令(这次命中)。
1 VPN = (VirtualAddress & VPN_MASK) >> SHIFT
2 (Success, TlbEntry) = TLB_Lookup(VPN)
3 if (Success == True) // TLB 命中
4 if (CanAccess(TlbEntry.ProtectBits) == True)
5 Offset = VirtualAddress & OFFSET_MASK
6 PhysAddr = (TlbEntry.PFN << SHIFT) | Offset
7 Register = AccessMemory(PhysAddr)
8 else
9 RaiseException(PROTECTION_FAULT)
10 else // TLB 未命中
11 RaiseException(TLB_MISS)
原书图 19.3:OS 处理未命中时的硬件控制流。和图 19.1 相比,第 11~19 行缩成了一行——硬件在未命中时几乎什么都不做。
硬件管理 vs 软件管理:一次访存的控制流
硬件(MMU)
OS:TLB 未命中处理程序(示意)
软件管理有两个重要细节:
- 从陷阱返回的位置不同。系统调用的"从陷阱返回"应回到陷入 OS 那条指令的下一条,就像过程调用返回到调用点之后; 而 TLB 未命中处理完后,硬件必须回到引发陷阱的那条指令,让它重新执行并命中。所以根据陷阱或异常的原因不同,硬件陷入 OS 时必须保存不同的 PC。
- 避免无限的 TLB 未命中链。如果未命中处理程序本身的代码或数据也需要地址转换且不在 TLB 中,就会再次未命中……解决办法很多:把处理程序放在物理内存中 (不做地址转换),或者在 TLB 中保留一些永久有效的项给处理程序本身用——这些固定(wired)的转换总是命中。
软件管理的主要优点是灵活:OS 可以用任意数据结构实现页表,不需要改硬件。另一个优点是简单:硬件在未命中时几乎不做事,只抛出异常。
20 世纪 80 年代,计算机体系结构界有过一场大战。CISC(复杂指令集)一方认为指令应是高级原语(比如一条指令完成字符串拷贝),让汇编更好用、代码更紧凑; RISC(精简指令集,由伯克利的 David Patterson 和斯坦福的 John Hennessy 引领,John Cocke 因最早的 RISC 工作获得图灵奖)认为指令集其实是编译器的目标, 编译器只需要少数简单原语——那就把硬件(尤其是微代码)尽量剥掉,让剩下的部分简单、统一、快速。
早期 RISC 芯片明显更快;但 Intel 等 CISC 厂商把许多 RISC 技术吸收进处理器核心(比如在流水线前端把复杂指令转换成微指令,再以类似 RISC 的方式处理), 加上每个芯片上的晶体管越来越多,CISC 保持了竞争力。争论最终平息,今天两类处理器都能做得很快。
TLB 的有效位 ≠ 页表的有效位。PTE 被标记为无效,意味着这一页没有被进程分配,正确的程序不应该访问它,访问会陷入 OS、进程通常被杀死。 TLB 的有效位只表示"这个 TLB 项里有没有一个有效的转换":系统启动时所有 TLB 项通常都是无效的,因为还没有缓存任何转换; 上下文切换时把所有 TLB 项设为无效,也能保证即将运行的进程不会误用上一个进程的转换。
TLB 里有什么?
典型的 TLB 有 32、64 或 128 项,并且是全相联(fully associative)的:任何转换都可以放在 TLB 的任何位置,硬件并行搜索所有项。 所以每项必须同时保存 VPN 和 PFN:
"其他位"中常见的有:有效位(这一项是否有有效转换)、保护位(代码页可能是读和执行,堆页是读和写)、 以及地址空间标识符、脏位等。
TLB 与上下文切换
TLB 里的转换只对当前运行的进程有意义。例如:进程 P1 的第 10 个虚拟页映射到物理帧 100;P2 的第 10 个虚拟页映射到物理帧 170。如果两者的转换都在 TLB 里:
| VPN | PFN | valid | prot |
|---|---|---|---|
| 10 | 100 | 1 | rwx |
| — | — | 0 | — |
| 10 | 170 | 1 | rwx |
| — | — | 0 | — |
问题来了:VPN 10 到底转换成 PFN 100(P1)还是 170(P2)?硬件无法区分。
上下文切换时如何管理 TLB 的内容?进程间切换时,上一个进程在 TLB 中的转换对即将运行的进程没有意义。硬件或操作系统应该怎么做?
When context-switching between processes, the translations in the TLB for the last process are not meaningful to the about-to-be-run process. What should the hardware or OS do in order to solve this problem?
- 方案一:切换时清空(flush)TLB。软件管理的系统用一条显式的特权指令完成;硬件管理的系统可以在页表基址寄存器被改写时清空(OS 反正要在切换时改 PTBR)。清空就是把所有有效位置 0。 正确,但有代价:每次进程运行都要重新经历 TLB 未命中;切换频繁时代价很高。
- 方案二:地址空间标识符(ASID)。在 TLB 中增加一个 ASID 字段,可以把它看作 PID,但位数通常更少(例如 8 位,而 PID 是 32 位)。 有了 ASID,TLB 就能同时保存多个进程的转换而不混淆;OS 在切换时把当前进程的 ASID 写入某个特权寄存器,硬件查 TLB 时同时比较 VPN 和 ASID。
上下文切换:不处理 / 清空 / ASID
TLB
页表(每进程一份)
| 进程 | VPN | PFN |
|---|---|---|
| P1(ASID 1) | 10 | 100 |
| P1(ASID 1) | 11 | 101 |
| P2(ASID 2) | 10 | 170 |
| P2(ASID 2) | 11 | 171 |
还有一种情况:两个不同进程的不同 VPN 指向同一个物理页。原书的例子:
| VPN | PFN | valid | prot | ASID |
|---|---|---|---|---|
| 10 | 101 | 1 | r-x | 1 |
| — | — | 0 | — | — |
| 50 | 101 | 1 | r-x | 2 |
| — | — | 0 | — | — |
进程 1 和进程 2 共享物理页 101(比如一个代码页):P1 把它映射到自己地址空间的第 10 页,P2 映射到第 50 页。共享二进制代码或共享库的代码页很有用,可以减少物理页的使用,降低内存开销。
替换策略
如何设计 TLB 替换策略?向 TLB 添加新项时,应该替换哪一个旧项?目标当然是减小未命中率(提高命中率),从而改进性能。
Which TLB entry should be replaced when we add a new TLB entry? The goal, of course, being to minimize the miss rate (or increase hit rate) and thus improve performance.
- LRU(least-recently-used):替换最近最少使用的项,试图利用访问流中的局部性。
- 随机(random):随机挑一项替换。简单,而且能避免一些极端情况。例如:程序循环访问 n + 1 个页,而 TLB 只有 n 项——"合理"的 LRU 每一次都未命中(它总是恰好踢掉马上要用的那一页),随机反而好得多。
在上面的 TLB 模拟器中点"LRU 的坏情况",分别用 LRU 和随机跑一遍,比较命中率。第 22 章会详细研究这类替换策略。
真实的 TLB 表项:MIPS R4000
MIPS R4000 使用软件管理的 TLB,支持 32 位地址空间和 4KB 页。按理 VPN 应有 20 位,但表项里只有 19 位:用户地址只占地址空间的一半(另一半留给内核),所以 19 位就够了。 VPN 转换成最多 24 位的 PFN,因此可以支持最多 64GB 的物理内存(224 个 4KB 页)。
| 字段 | 作用 |
|---|---|
| G(global) | 全局共享的页(所有进程都能用,比如内核页)。G 置位时忽略 ASID。 |
| ASID(8 位) | 区分地址空间。只有 8 位,最多 256 个——如果同时运行的进程超过 256 个,OS 该怎么办?(提示:回收 ASID 时必须清掉 TLB 中带这个 ASID 的项。) |
| C(3 位) | 一致性(coherence)位,决定硬件如何缓存这一页(超出本书范围)。 |
| D / V | 脏位:页被写过时置位;有效位:这一项里有没有有效的转换。 |
| page mask(图中未画) | 支持多种页大小——后面会看到大页为什么有用。 |
MIPS 的 TLB 通常有 32 或 64 项,大部分给用户进程使用,少数保留给 OS:OS 通过 wired 寄存器告诉硬件保留几个槽,用于关键时刻(例如 TLB 未命中处理程序中)绝不能未命中的代码和数据。
因为是软件管理,MIPS 提供 4 条更新 TLB 的指令:TLBP(探测某个转换是否在 TLB 中)、TLBR(把某项读到寄存器)、TLBWI(替换指定项)、TLBWR(替换随机一项)。
这些指令当然必须是特权指令——想想用户进程如果能改 TLB 会发生什么:几乎可以做任何事,包括接管机器、运行自己的恶意"OS"。
"随机存取存储器"这个名字暗示访问 RAM 的任何位置都一样快。通常这样想没错,但由于 TLB 这类软硬件特性,访问某一页可能很昂贵——尤其是该页当前没有被 TLB 映射时。 所以请记住:RAM 不总是 RAM。随机访问地址空间,特别是访问的页数超过了 TLB 覆盖范围时,可能导致严重的性能损失。原书作者以他们的导师 David Culler 的名字命名这条定律,因为他总把 TLB 指为许多性能问题的根源。
TLB 覆盖范围计算器
动手实验:测量 TLB 的大小和未命中代价
原书的作业借鉴了 Saavedra-Barrera 的方法:在一个大数组中,每页只访问一个整数,访问 NUMPAGES 个页,并把这个循环重复很多次来计时:
int jump = PAGESIZE / sizeof(int);
for (i = 0; i < NUMPAGES * jump; i += jump)
a[i] += 1;
页数不超过 TLB 项数时,每次访问都命中,很快;一旦超过,每次访问的代价会突然跳高到 TLB 未命中的水平。原书图 19.5 中:8 页以内约 5ns,16 页以上跳到约 20ns,1024 页左右再跳到约 70ns—— 说明有两级 TLB:第一级很小(8~16 项),第二级更大但更慢(约 512 项)。
labs/19-tlb/ 里有三个文件:timer.c 回答作业第 1 题(计时器精度),tlb.c 是测量程序,run.sh 把页数从 1 扫到 24576。
tlb.c 处理了作业里提到的所有陷阱:用 volatile 防止编译器删掉循环(第 5 题)、用 sched_setaffinity 绑定到一个 CPU(第 6 题)、
计时前先把每页写一遍避免按需清零的开销(第 7 题);此外还用 madvise(MADV_NOHUGEPAGE) 禁止透明大页,并让每页访问的位置错开一个缓存行,避免 L1 缓存组冲突干扰测量。
cd labs/19-tlb && make
./timer
./run.sh # 原书的 a[i] += 1 循环
./run.sh -p # 指针追逐模式:测延迟
示例输出(AMD Ryzen 7 8745H,WSL2,gcc 14)。你的机器上数字会不同;WSL2 运行在虚拟机里,还有其他进程争抢 CPU,数字有噪声,所以每个点重复测 5 次并取最小值。
$ ./timer
clock_getres(CLOCK_MONOTONIC) = 1 ns
clock_gettime 连续两次读数:最小差 10 ns,平均差 17.3 ns
gettimeofday 能分辨的最小变化:1 us(= 1000 ns)
结论:单次页访问只有几纳秒,必须把它重复成百万次,让总时间达到毫秒级再求平均。
a[i] += 1 循环;橙色:指针追逐(-p)。数据来自本机实际运行。$ ./run.sh -k 7 # a[i] += 1
$ ./run.sh -p -k 7 # 指针追逐
页数 a[i]+=1 最小/中位(ns) 指针追逐 最小/中位(ns)
1 0.47 / 0.50 1.26 / 1.27
2 0.82 / 0.84 1.26 / 1.29
3 0.56 / 0.57 1.25 / 1.29
4 0.41 / 0.42 1.27 / 1.31
6 0.37 / 0.41 1.28 / 1.30
8 0.37 / 0.38 1.27 / 1.34
12 0.36 / 0.37 1.28 / 1.30
16 0.35 / 0.36 1.26 / 1.27
24 0.33 / 0.33 1.28 / 1.28
32 0.43 / 0.44 1.27 / 1.30
48 0.32 / 0.32 1.26 / 1.31
64 0.32 / 0.32 1.25 / 1.31
96 0.43 / 0.44 2.74 / 2.78
128 0.43 / 0.45 2.77 / 2.82
192 0.43 / 0.44 2.77 / 2.83
256 0.44 / 0.45 2.73 / 2.79
384 0.45 / 0.46 2.81 / 2.87
512 0.57 / 0.59 2.82 / 2.96
768 0.56 / 0.60 5.14 / 5.28
1024 0.56 / 0.58 5.18 / 5.31
1536 0.56 / 0.57 5.17 / 5.24
2048 0.53 / 0.55 5.17 / 5.32
3072 2.50 / 2.57 11.61 / 11.81
4096 4.04 / 4.10 16.95 / 18.24
6144 4.91 / 4.99 20.70 / 36.37
8192 5.22 / 5.35 26.75 / 40.20
12288 5.38 / 5.54 31.83 / 33.13
16384 5.46 / 5.83 33.92 / 41.80
24576 5.45 / 5.75 37.62 / 43.11
怎么读这些数字
- 指针追逐(橙线)有清楚的台阶。
- ≤ 64 页:约 1.26 ns,在约 4.5GHz 下是 5~6 个时钟周期:L1 TLB 命中 + L1 数据缓存命中。
- 96~512 页:约 2.8 ns。拐点在 64 和 96 之间,与这颗 Zen 4 处理器的 L1 数据 TLB 为 72 项相吻合。多出来的约 1.5 ns(约 7 个周期)就是 L1 TLB 未命中、在第二级 TLB 命中的代价。
- 768~2048 页:约 5.2 ns。这一跳不是 TLB 造成的:每页访问一个缓存行,512 页 × 64 字节 = 32KB,正好是 L1 数据缓存的大小,超过后数据要从 L2 缓存取。测量中混着缓存效应,解读时要小心。
- ≥ 3072 页:12 → 38 ns。第二级 TLB(3072 项)也装不下了,每次访问都要遍历页表(page walk);页数越多,页表项和数据本身也越难留在缓存中。 另外 WSL2 运行在 Hyper-V 虚拟机里,TLB 未命中时硬件要同时遍历客户机页表和宿主机的嵌套页表,比裸机更贵。
- 原书的
a[i] += 1循环(蓝线)几乎看不出第一个拐点:64 → 96 页只从 0.32 涨到 0.43 ns。各次访问之间没有数据依赖,乱序执行的 CPU 可以同时处理多个 TLB 查找,把第二级 TLB 的延迟重叠掩盖掉了; 只有第二级 TLB 也未命中、要真正遍历页表时(3072 页以后)才明显变慢,从约 0.5 ns 涨到约 5 ns,慢了 10 倍。这个循环测的是吞吐,指针追逐测的是延迟。 - 1~3 页时
a[i] += 1反而更慢(2 页时 0.82 ns):同一个整数在相邻两趟中被连续加 1,下一次读必须等上一次写完成,页数少时这条依赖链成了瓶颈,和 TLB 无关。 - 和原书图 19.5 对比:结构相同,都是一个小而快的第一级 TLB 加一个大而慢的第二级 TLB;只是今天的 TLB 大得多(72 / 3072 项,原书的机器约 8~16 / 512 项)。 L1 TLB 命中和遍历页表之间差 10~30 倍——TLB 性能确实很重要。
- 噪声:WSL2 里还有别的进程在抢 CPU,中位数有时比最小值大很多(例如 6144 页:20.7 vs 36.4 ns),所以每个点测 7 次取最小值。
另外
tlb.c计时前会先热身 0.2 秒:最初没加热身时,前几个测量点都在 5 ns 左右,是正常值的 4 倍——CPU 刚从空闲的低频状态醒来。
先预测再验证
看答案
看答案
看答案
看答案
./run.sh 时不绑定 CPU(加 -c -1),结果会怎样?为什么要绑定?看答案
sched_setaffinity、taskset)才能测到一个 TLB 的行为。自测题
常见误区与本章小结
- "TLB 缓存的是数据"——TLB 只缓存地址转换。数据和指令由 CPU 的 L1/L2 缓存负责。
- "TLB 未命中 = 缺页"——TLB 未命中只是转换不在缓存里,页表里可能有有效的转换(很常见);缺页是页不在物理内存中(第 21 章)。
- "TLB 的有效位和 PTE 的有效位一样"——前者表示这个槽有没有内容,后者表示这一页是否被分配。
- "有了 TLB 页表就不用了"——TLB 很小(几十到几千项),页表仍是转换的权威来源,每次未命中都要查它。
- "ASID 就是 PID"——ASID 通常只有几位(MIPS 是 8 位),数量有限,OS 必须回收重用。
- TLB 是 MMU 中很小的地址转换缓存;命中时转换几乎没有额外开销,未命中才去查页表,然后装入 TLB 并重试指令。
- TLB 的高命中率来自程序的空间局部性和时间局部性;页越大,同一页里的访问越多,命中率越高。
- 未命中可由硬件处理(x86,需知道页表位置和格式)或软件处理(MIPS/SPARC,抛出异常,由 OS 用特权指令更新 TLB;返回时重试原指令,并要避免无限未命中链)。
- 上下文切换时要么清空 TLB,要么用 ASID 区分地址空间;替换策略常见 LRU 和随机。
- TLB 不能拯救所有程序:短时间内访问的页数超过 TLB 覆盖范围会导致大量未命中。一个办法是支持更大的页,数据库之类大而随机访问的数据结构常这样做。
- TLB 访问还可能成为 CPU 流水线的瓶颈,特别是物理索引缓存(physically-indexed cache):必须先转换地址才能访问缓存。虚拟索引缓存(virtually-indexed cache)可以避开这一步,但会给硬件设计带来新问题。
- 下一章:页表太大的问题——多级页表、反向页表等。