阅读笔记主要来自原书第 19 章。该章对快速地址转换进行了详细的介绍。
0、前言
使用分页作为核心机制来实现虚拟内存,可能会带来较高的性能开销。因为要使用分页,就要将内存地址空间切分成大量固定大小的单元(页),并且需要记录这些单元的地址映射信息。因为这些映射信息一般存储在物理内存中,所以在转换虚拟地址时,分页逻辑上需要一次额外的内存访问。每次指令获取、显式加载或保存,都要额外读一次内存以得到转换信息,这慢得无法接受。因此我们面临如下问题:
如何才能加速虚拟地址转换,尽量避免额外的内存访问?需要什么样的硬件支持?操作系统该如何支持?
想让某些东西更快,操作系统通常需要一些帮助。帮助常常来自操作系统的老朋友:硬件。我们要增加所谓的地址转换旁路缓冲存储器(translation-lookaside buffer,TLB),它就是频繁发生的虚拟地址到物理地址转换的硬件缓存。因此,更好的名称应该是地址转换缓存(address-translation cache)。对每次内存访问,硬件先检查 TLB,看看其中是否有期望的转换映射,如果有,就完成转换(很快),不用访问页表。TLB 带来了巨大的性能提升,实际上,因此它使得虚拟内存成为可能。
1、TLB 的基本算法
下面的代码展示了一个大体框架,说明硬件如何处理虚拟地址转换,假定使用简单的线性页表和硬件管理的 TLB。
VPN = (VirtualAddress & VPN_MASK) >> SHIFT
(Success, TlbEntry) = TLB_Lookup(VPN)
if (Success == True) // TLB Hit
if (CanAccess(TlbEntry.ProtectBits) == True)
Offset = VirtualAddress & OFFSET_MASK
PhysAddr = (TlbEntry.PFN << SHIFT) | Offset
AccessMemory(PhysAddr)
else
RaiseException(PROTECTION_FAULT)
else // TLB Miss
PTEAddr = PTBR + (VPN * sizeof(PTE))
PTE = AccessMemory(PTEAddr)
if (PTE.Valid == False)
RaiseException(SEGMENTATION_FAULT)
else if (CanAccess(PTE.ProtectBits) == False)
RaiseException(PROTECTION_FAULT)
else
TLB_Insert(VPN, PTE.PFN, PTE.ProtectBits)
RetryInstruction()
硬件算法的大体流程如下:首先从虚拟地址中提取页号 VPN,然后检查 TLB 是否有该 VPN 的转换映射。如果有,那么 TLB 命中,这意味着 TLB 有该页的转换映射。成功!假定保护检查没有失败,接下来就可以从相关的 TLB项中取出页帧号 PFN,与原来虚拟地址中的偏移量组合形成期望的物理地址 PA,并访问内存。
如果 CPU 没有在 TLB 中找到转换映射(TLB 未命中),那么硬件访问页表来寻找转换映射。假设该虚拟地址有效,而且有相关的访问权限,那么用该转换映射更新 TLB。上述系列操作开销较大,主要是因为访问页表需要额外的内存引用。最后,当 TLB 更新成功后,系统会重新尝试该指令,这时 TLB 中有了这个转换映射,内存引用得到很快处理。
TLB 和其他缓存相似,前提是在一般情况下,转换映射会在缓存中。如果是这样,只增加了很少的开销,因为 TLB 位于处理器核心附近,访问速度很快。如果 TLB 未命中,就会带来很大的分页开销。必须访问页表来查找转换映射,导致一次额外的内存引用(或者更多,如果页表更复杂)。如果这经常发生,程序的运行就会显著变慢。相对于大多数 CPU 指令,内存访问开销很大,TLB 未命中导致更多内存访问。因此,尽可能避免 TLB 未命中。
2、示例:访问数组
为了弄清楚 TLB 的操作,来看一个简单的虚拟地址追踪,看看 TLB 如何提高它的性能。在本例中,假设有一个由 10 个 4 字节整型数组成的数组,起始虚地址是 100。进一步假定,有一个 8 位的小虚地址空间,页大小为 16B。可以把虚地址划分为 4 位的 VPN(有 16 个虚拟内存页)和 4 位的偏移量(每个页中有 16 个字节)。
上图展示了该数组的布局,在系统的 16 个 16 字节的页上。数组的第一项(a[0])开始于(VPN=06,offset=04),只有 3 个 4 字节整型数存放在该页。数组在下一页(VPN=07)继续,其中有接下来 4 项(a[3] … a[6])。数组的最后 3 项(a[7] … a[9])位于地址空间的下一页(VPN=08)。
现在考虑一个简单的循环,访问数组中的每个元素,类似下面的 C 程序:
int sum = 0;
for (i = 0; i < 10; i++) {
sum += a[i];
}
简单起见,假装循环产生的内存访问只是针对数组(忽略变量 i 和 sum,以及指令本身)。当访问第一个数组元素(a[0])时,CPU 会看到载入虚拟内存地址 100。硬件从中提取 VPN(VPN=06),然后用它来检查 TLB,寻找有效的转换映射。假设这里是程序第一次访问该数组,结果是 TLB 未命中。
接下来访问 a[1],这里有好消息:TLB 命中!因为数组的第二个元素在第一个元素之后,它们在同一页。因为我们之前访问数组的第一个元素时,已经访问了这一页,所以 TLB 中缓存了该页的转换映射。因此成功命中。访问 a[2] 同样成功(再次命中),因为它和 a[0]、a[1] 位于同一页。
遗憾的是,当程序访问 a[3] 时,会导致 TLB 未命中。但同样,接下来几项(a[4] … a[6])都会命中 TLB,因为它们位于内存中的同一页。
最后,访问 a[7] 会导致最后一次 TLB 未命中。系统会再次查找页表,弄清楚这个虚拟页在物理内存中的位置,并相应地更新 TLB。最后两次访问(a[8]、a[9])受益于这次 TLB 更新,当硬件在 TLB 中查找它们的转换映射时,两次都命中。
总结一下这 10 次数组访问操作中 TLB 的行为表现:未命中、命中、命中、未命中、命中、命中、命中、未命中、命中、命中。命中的次数除以总的访问次数,得到 TLB 命中率为 70%。尽管这不是很高(实际上希望命中率接近 100%),但也不是零,是零我们就要奇怪了。即使这是程序首次访问该数组,但得益于空间局部性,TLB 还是提高了性能。数组的元素被紧密存放在几页中(即它们在空间中紧密相邻),因此只有对页中第一个元素的访问才会导致 TLB 未命中。
关于 TLB 性能还有最后一点:如果在这次循环后不久,该程序再次访问该数组,我们会看到更好的结果,假设 TLB 足够大,能缓存所需的转换映射:命中、命中、命中、命中、命中、命中、命中、命中、命中、命中。在这种情况下,由于时间局部性,即在短时间内对内存项再次引用,所以 TLB 的命中率会很高。类似其他缓存,TLB 的成功依赖于空间和时间局部性。如果某个程序表现出这样的局部性(许多程序是这样),TLB 的命中率可能很高。
提示:尽可能利用缓存
缓存是计算机系统中最基本的性能改进技术之一,一次又一次地用于让“常见的情况更快”。硬件缓存背后的思想是利用指令和数据引用的局部性。通常有两种局部性:时间局部性(temporal locality)和空间局部性(spatial locality)。时间局部性是指,最近访问过的指令或数据项可能很快会再次访问。想想循环中的循环变量或指令,它们被多次反复访问。空间局部性是指,当程序访问内存地址x时,可能很快会访问邻近x的内存。想想遍历某种数组,访问一个接一个的元素。当然,这些性质取决于程序的特点,并不是绝对的定律,而更像是一种经验法则。
硬件缓存,无论是指令、数据还是地址转换,都利用了局部性,在小而快的芯片内存储器中保存一份内存副本。处理器可以先检查缓存中是否存在就近的副本,而不是必须访问(缓慢的)内存来满足请求。如果存在,处理器就可以很快地访问它(例如在几个CPU时钟内),避免花很多时间来访问内存(好多纳秒)。
你可能会疑惑:既然像 TLB 这样的缓存这么好,为什么不做更大的缓存,装下所有的数据?可惜的是,这里我们遇到了更基本的定律,就像物理定律那样。如果想要快速地缓存,它就必须小,因为光速和其他物理限制会起作用。大的缓存注定慢,因此无法实现目的。所以,我们只能用小而快的缓存。剩下的问题就是如何利用好缓存来提升性能。
3、谁来处理 TLB 未命中
有一个问题必须回答:谁来处理 TLB 未命中?可能有两个答案:硬件或软件(操作系统)。以前的硬件有复杂的指令集(Complex-Instruction Set Computer,CISC),造硬件的人不太相信那些搞操作系统的人。因此,硬件全权处理 TLB 未命中。为了做到这一点,硬件必须知道页表在内存中的确切位置(通过页表基址寄存器),以及页表的确切格式。发生未命中时,硬件会“遍历”页表,找到正确的页表项,取出想要的转换映射,用它更新TLB,并重试该指令。这种“旧”体系结构有硬件管理的 TLB,一个例子是 x86 架构,它采用固定的多级页表(multi-level page table),当前页表由 CR3 寄存器指出。
更现代的体系结构(例如,MIPS R10k、Sun公司的SPARC,都是精简指令集计算机,Reduced-Instruction Set Computer,RISC),有所谓的软件管理 TLB。发生 TLB 未命中时,硬件系统会抛出一个异常,这会暂停当前的指令流,将特权级提升至内核模式,跳转至陷阱处理程序。这个陷阱处理程序是操作系统的一段代码,用于处 理TLB 未命中。这段代码在运行时,会查找页表中的转换映射,然后用特别的“特权”指令更新 TLB,并从陷阱返回。此时,硬件会重试该指令(导致 TLB 命中)。
VPN = (VirtualAddress & VPN_MASK) >> SHIFT
(Success, TlbEntry) = TLB_Lookup(VPN)
if (Success == True) // TLB Hit
if (CanAccess(TlbEntry.ProtectBits) == True)
Offset = VirtualAddress & OFFSET_MASK
PhysAddr = (TlbEntry.PFN << SHIFT) | Offset
Register = AccessMemory(PhysAddr)
else
RaiseException(PROTECTION_FAULT)
else // TLB Miss
RaiseException(TLB_MISS)
接下来讨论几个重要的细节。
首先,这里的从陷阱返回指令稍稍不同于之前提到的服务于系统调用的从陷阱返回。在后一种情况下,从陷阱返回应该继续执行陷入操作系统之后那条指令,就像从函数调用返回后,会继续执行此次调用之后的语句。在前一种情况下,在从 TLB 未命中的陷阱返回后,硬件必须从导致陷阱的指令继续执行。这次重试因此导致该指令再次执行,但这次会命中 TLB。因此,根据陷阱或异常的原因,系统在陷入内核时必须保存不同的程序计数器,以便将来能够正确地继续执行。
第二,在运行 TLB 未命中处理代码时,操作系统需要格外小心避免引起 TLB 未命中的无限递归。有很多解决方案,例如,可以把 TLB 未命中陷阱处理程序直接放到物理内存中[它们没有映射过,不用经过地址转换]。或者在 TLB 中保留一些项,记录永久有效的地址转换,并将其中一些永久地址转换槽块留给处理代码本身,这些被监听的地址转换总是会命中 TLB。
软件管理的方法,主要优势是灵活性:操作系统可以用任意数据结构来实现页表,不需要改变硬件。另一个优势是简单性。从 TLB 控制流中可以看出,硬件不需要对未命中做太多工作,它抛出异常,操作系统的未命中处理程序会负责剩下的工作。
4、TLB 的内容
现在来详细看一下硬件 TLB 中的内容。典型的 TLB 有 32 项、64 项或 128 项,并且是全相联的。基本上,这就意味着一条地址映射可能存在 TLB 中的任意位置,硬件会并行地查找 TLB,找到期望的转换映射。一条TLB项内容可能像下面这样:
VPN | PFN | 其他位
注意,VPN 和 PFN 同时存在于 TLB 中,因为一条地址映射可能出现在任意位置(用硬件的术语,TLB被称为全相联的(fully-associative)缓存)。硬件并行地查找这些项,看看是否有匹配。
更有趣的是“其他位”。例如,TLB 通常有一个有效位,用来标识该项是不是有效地转换映射。通常还有一些保护位,用来标识该页是否有访问权限。例如,代码页被标识为可读和可执行,而堆的页被标识为可读和可写。还有其他一些位,包括地址空间标识符、脏位等。
补充:TLB 的有效位 != 页表的有效位
常见的错误是混淆 TLB 的有效位和页表的有效位。在页表中,如果一个页表项(PTE)被标记为无效,就意味着该页并没有被进程申请使用,正常运行的程序不应该访问该地址。当程序试图访问这样的页时,就会陷入操作系统,操作系统会杀掉该进程。
TLB 的有效位不同,只是指出 TLB 项是不是有效的地址映射。例如,系统启动时,所有的TLB项通常被初始化为无效状态,因为还没有地址转换映射被缓存在这里。一旦启用虚拟内存,当程序开始运行,访问自己的虚拟地址,TLB 就会慢慢地被填满,因此有效的项很快会充满 TLB。
TLB 有效位在系统上下文切换时起到了很重要的作用,后面我们会进一步讨论。通过将所有 TLB项设置为无效,系统可以确保将要运行的进程不会错误地使用前一个进程的虚拟到物理地址转换映射。
5、上下文切换时对 TLB 的处理
有了 TLB,在进程间切换时(因此有地址空间切换),会面临一些新问题。具体来说,TLB 中包含的虚拟到物理的地址映射只对当前进程有效,对其他进程是没有意义的。所以在发生进程切换时,硬件或操作系统必须注意确保即将运行的进程不要误读了之前进程的地址映射。
为了更好地理解这种情况,来看一个例子。当一个进程(P1)正在运行时,假设 TLB 缓存了对它有效的地址映射,即来自 P1 的页表。对这个例子,假设 P1 的 10 号虚拟页映射到了 100 号物理帧。在这个例子中,假设还有一个进程(P2),操作系统不久后决定进行一次上下文切换,运行 P2。这里假定 P2 的 10 号虚拟页映射到 170 号物理帧。如果这两个进程的地址映射都在 TLB 中,TLB 的内容如下表所示。
VPN | PFN | 有效位 | 保护位 |
---|---|---|---|
10 | 100 | 1 | rwx |
- | - | 0 | - |
10 | 170 | 1 | rwx |
- | - | 0 | - |
在上面的 TLB 中,很明显有一个问题:VPN 10 被转换成了 PFN 100(P1)和 PFN 170(P2),但硬件分不清哪个项属于哪个进程。所以还需要做一些工作,让 TLB 正确而高效地支持跨多进程的虚拟化。因此,关键问题是:
如果发生进程间上下文切换,上一个进程在 TLB 中的地址映射对于即将运行的进程是无意义的。硬件或操作系统应该做些什么来解决这个问题呢?
这个问题有一些可能的解决方案。一种方法是在上下文切换时,简单地清空 TLB,这样在新进程运行前 TLB 就变成了空的。如果是软件管理TLB的系统,可以在发生上下文切换时,通过一条显式(特权)指令来完成。如果是硬件管理 TLB,则可以在页表基址寄存器内容发生变化时清空 TLB(注意,在上下文切换时,操作系统必须改变页表基址寄存器的值)。不论哪种情况,清空操作都是把全部有效位置为0,本质上清空了 TLB。
上下文切换的时候清空 TLB,这是一个可行的解决方案,进程不会再读到错误的地址映射。但是,有一定开销:每次进程运行,当它访问数据和代码页时,都会触发 TLB 未命中。如果操作系统频繁地切换进程,这种开销会很高。
为了减少这种开销,一些系统增加了硬件支持,实现跨上下文切换的 TLB 共享。比如有的系统在 TLB 中添加了一个地址空间标识符(Address Space Identifier,ASID)。可以把 ASID 看作是进程标识符(Process Identifier,PID),但通常比 PID 位数少(PID 一般 32 位,ASID 一般是8位)。如果仍以上面的 TLB 为例,加上 ASID,显然不同进程就可以共享 TLB 了:只要 ASID 字段来区分原来无法区分的地址映射。
VPN | PFN | 有效位 | 保护位 | ASID |
---|---|---|---|---|
10 | 100 | 1 | rwx | 1 |
- | - | 0 | - | - |
10 | 170 | 1 | rwx | 2 |
- | - | 0 | - | - |
因此,有了地址空间标识符,TLB 可以同时缓存不同进程的地址空间映射,没有任何冲突。当然,硬件也需要知道当前是哪个进程正在运行,以便进行地址转换,因此操作系统在上下文切换时,必须将某个特权寄存器设置为当前进程的 ASID。
补充一下,你可能想到了另一种情况,TLB 中某两项非常相似。在下表中,属于两个不同进程的两项,将两个不同的 VPN 指向了相同的物理页。
VPN | PFN | 有效位 | 保护位 | ASID |
---|---|---|---|---|
10 | 101 | 1 | rwx | 1 |
- | - | 0 | - | - |
50 | 101 | 1 | rwx | 2 |
- | - | 0 | - | - |
如果两个进程共享同一物理页(例如代码段的页),就可能出现这种情况。在上面的例子中,进程 P1 和进程 P2 共享 101 号物理页,但是 P1 将自己的 10 号虚拟页映射到该物理页,而 P2 将自己的 50 号虚拟页映射到该物理页。共享代码页(以二进制或共享库的方式)是有用的,因为它减少了物理页的使用,从而减少了内存开销。
6、TLB 替换策略
TLB 和其他缓存一样,还有一个问题要考虑,即缓存替换。具体来说,向 TLB 中插入新项时,会替换一个旧项,这样问题就来了:应该替换哪一个?目标当然是或提高 TLB 命中率,从而改进性能。
这里我们先简单指出几个典型的策略。一种常见的策略是替换最近最少使用(least-recently-used,LRU)的项。LRU 尝试利用内存引用流中的局部性,假定最近没有用过的项,可能是好的换出候选项。另一种典型策略就是随机策略,即随机选择一项换出去。这种策略很简单,并且可以避免一种极端情况。例如,一个程序循环访问 n+1 个页,但 TLB 大小只能存放 n 个页。这时之前看似“合理”的 LRU 策略就会表现得不可理喻,因为每次访问内存都会触发 TLB 未命中,而随机策略在这种情况下就好很多。
7、实际系统的 TLB 表项
最后,简单看一下真实的 TLB。这个例子来自 MIPS R4000,它是一种现代系统,采用软件管理 TLB。下图展示了稍微简化的 MIPS TLB 项。
MIPS R4000 支持 32 位的地址空间,页大小为 4KB。所以在典型的虚拟地址中,预期会看到 20 位的 VPN 和 12 位的偏移量。但是,在 TLB 中看到,只有 19 位的 VPN。事实上,用户地址只占地址空间的一半(剩下的留给内核),所以只需要 19 位的 VPN。VPN 转换成最大 24 位的物理帧号 PFN,因此可以支持最多有 64GB 物理内存(2^24个 4KB 内存页)的系统。
MIPS TLB还 有一些有趣的标识位。比如全局位(Global,G),用来指示这个页是不是所有进程全局共享的。因此,如果全局位置为 1,就会忽略 ASID。我们也看到了 8 位的 ASID,操作系统用它来区分进程空间(像上面介绍的一样)。这里有一个问题:如果正在运行的进程数超过 256(2^8)个怎么办?最后,我们看到 3 个一致性位(Coherence,C),决定硬件如何缓存该页;脏位(dirty,D),表示该页是否被写入新数据;有效位(valid,V),告诉硬件该项的地址映射是否有效。还有没在图中展示的页掩码(page mask)字段,用来支持不同的页大小。最后,64 位中有一些未使用(灰色部分)。
MIPS的 TLB 通常有 32 项或 64 项,大多数提供给用户进程使用,也有一小部分留给操作系统使用。操作系统可以设置一个被监听的寄存器,告诉硬件需要为自己预留多少 TLB 槽。这些保留的转换映射,被操作系统用于关键时候它要使用的代码和数据,在这些时候,TLB 未命中可能会导致问题(例如,在 TLB 未命中处理程序中)。
由于 MIPS 的 TLB 是软件管理的,所以系统需要提供一些更新 TLB 的指令。MIPS 提供了 4 个这样的指令:TLBP,用来查找指定的转换映射是否在 TLB 中;TLBR,用来将 TLB 中的内容读取到指定寄存器中;TLBWI,用来替换指定的 TLB 项;TLBWR,用来随机替换一个 TLB 项。操作系统可以用这些指令管理 TLB 的内容。当然这些指令是特权指令,这很关键。
提示:RAM不总是RAM(Culler定律)
随机存取存储器(Random-Access Memory,RAM)暗示你访问RAM的任意部分都一样快。虽然一般这样想 RAM 没错,但因为 TLB 这样的硬件/操作系统功能,访问某些内存页的开销较大,尤其是没有被 TLB 缓存的页。因此,最好记住这个实现的窍门:RAM不总是RAM。有时候随机访问地址空间,尤其是TLB没有缓存的页,可能导致严重的性能损失。
8、小结
我们了解了硬件如何让地址转换更快的方法。通过增加一个小的、芯片内的 TLB 作为地址转换的缓存,大多数内存引用就不用访问内存中的页表了。因此,在大多数情况下,程序的性能就像内存没有虚拟化一样,这是操作系统的杰出成就,当然对现代操作系统中的分页非常必要。
但是,TLB 也不能满足所有的程序需求。具体来说,如果一个程序短时间内访问的页数超过了TLB中的页数,就会产生大量的 TLB 未命中,运行速度就会变慢。这种现象被称为超出 TLB 覆盖范围(TLB coverage),这对某些程序可能是相当严重的问题。解决这个问题的一种方案是支持更大的页,把关键数据结构放在程序地址空间的某些区域,这些区域被映射到更大的页,使 TLB 的有效覆盖率增加。对更大页的支持通常被数据库管理系统这样的程序利用,它们的数据结构比较大,而且是随机访问。
另一个 TLB 问题值得一提:访问 TLB 很容易成为 CPU 流水线的瓶颈,尤其是有所谓的物理地址索引缓存(physically-indexed cache)。有了这种缓存,地址转换必须发生在访问该缓存之前,这会让操作变慢。为了解决这个潜在的问题,人们研究了各种巧妙的方法,用虚拟地址直接访问缓存,从而在缓存命中时避免昂贵的地址转换步骤。像这种虚拟地址索引缓存(virtually-indexed cache)解决了一些性能问题,但也为硬件设计带来了新问题。
赏