《What every programmer should know about memory》-Virtual Memory译
Posted fanchenxinok
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《What every programmer should know about memory》-Virtual Memory译相关的知识,希望对你有一定的参考价值。
原文PDF: http://futuretech.blinkenlights.nl/misc/cpumemory.pdf
一二章参考博文:https://www.oschina.net/translate/what-every-programmer-should-know-about-memory-part1?lang=chs&p=6
目的在边学习边翻译让自己理解的更加深刻。
4. 虚拟内存
处理器的虚拟内存(VM)子系统实现为每个进程提供虚拟地址空间,这样每个进程自己在系统中是独立的。 虚拟内存的优点在其他地方有详细的描述,因此在这里不再复述。本节集中讨论虚拟内存子系统的实际实现细节和相关开销。
虚拟地址空间是由CPU的内存管理单元(MMU)实现的,操作系统必须填写页表数据结构,但是大多数cpu自己完成其余的工作。这是一个非常复杂的机制,理解它的最佳方法是引入用于描述虚拟地址空间的数据结构。
输入给MMU进行地址转换的是虚拟地址,地址的数值很少有限制,如果有就是在32位系统上,虚拟地址是32位的数值,64位系统就是64位的数值。在某些系统上,例如x86和x86-64,所使用的地址实际上涉及到间接寻址,这些体系结构使用段,段就是简单的加到每个逻辑地址的偏移量。对于程序员需要关注的内存处理的性能,我们可以忽略这一部分的地址生成。
4.1 简单的地址转换
有意思的部分是虚拟地址到物理地址的转换,MMU可以逐页的重新映射地址,正如寻址缓存行一样,一个虚拟地址被划分为不同的区段。这些区段用于索引到各种表中,这些表用于构造最终物理地址。对于只有一级表的最简单模型:
图4.1 单级页表的地址转换
图4.1展示了一个虚拟地址 不同区段的使用,第一区段被用于选择页目录里的条目;目录里的每个条目可以由OS单独设置。页目录条目决定了物理内存页的地址;页目录的多个条目可以指向同一个物理地址。完整的物理地址是页目录条目的页地址和虚拟地址的低位共同决定的。页目录的条目也包含了页的一些额外信息,例如访问的权限。
页目录的结构体数据保存在内存中,操作系统必须分配连续的物理地址并将这块内存的基地址保存在特殊的寄存器中。虚拟地址中的几个位用来作为页目录的索引,页目录其实就是存储物理内存页地址的数组。
举个具体的例子,x86机器一个页4M大小,那么Offset区段用22位足够用来表示4M大小的页内寻址,虚拟地址剩下的10位可以在页目录中寻址1024个页条目,每个条目是表示一个4M页的10位的基地址。
4.2 多级页表
一个页4M并不通用,这将造成内存的浪费,因为操作系统执行的很多操作都要求页对齐。对于4KB字节的页(32位和64位机器都是通用的)虚拟地址Offset区段仅需12位来表示。剩下的20位作为页目录的索引。一个页表拥有2^20个条目是不切实际的。即使每个条目只有4字节,那么整个表也有4M大小。由于每个进程可能拥有自己不同的页表目录,因此大量的系统物理内存被这些页表目录所占用。
解决方案是使用多级页表,每个级形成了大的稀疏的页目录;未实际使用的地址空间区域不需要分配内存。因此,这种表示法更加紧凑,使得在内存中存储许多进程的页表而不会对性能产生太大影响成为可能。
图4.2 4级页表地址转换
如今最紧凑的页表结构由4个级别组成。图4.2展示了4级页表的一种实现。这个例子中虚拟地址被划分为至少5个区段。其中前4个区段是各个目录的索引。Level 4目录的地址是存储在CPU专有寄存器上的。Level4到Level2分别指向下一级目录的。如果目录条目被标记为空很明显不指向任何低一级的目录。这种方式的页表树是既稀疏又紧凑的。一级页表目录的条目是部分的物理地址附加上额外的信息如访问权限。
为了确定与虚拟地址对应的物理地址,处理器首先确定最高级别目录的地址。最高级页表目录(也就是图中的Level 4)的地址是保存在一个寄存器中的,然后CPU从虚拟地址中的相应区段的值作为这个目录的索引,用这个索引找到相应的条目。这个条目就作为下一级目录的地址,下一级目录的索引使用虚拟地址的下一区段。按这样一级一级下去直到一级页表目录,这级页表条目存储的是物理地址的高位部分,完整的物理地址是加上虚拟地址的Offset区段的内容。这个处理的过程叫做页表树遍历。一些处理器(如X86,x86-64)在硬件上执行这个操作,其他处理器需要OS的协助下完成。
系统上运行的每个进程可能都需要自己的页表树。部分进程页可能共享页表树,但这只是一个例外。因此,页表树所需的内存应该尽可能小,这对性能和可伸缩性都有好处。最理想的情况是将使用过的内存紧密地放在虚拟地址空间中; 实际使用的物理地址并不重要。一个小程序可能只使用第2、3、4级中的一个目录和几个第1级目录。在页大小为4KB的x86-64上,每个目录有512个条目,总共有4个目录(每个级别一个)允许寻址2M的空间。1GB的连续内存可以用一个2级,3级,4级和512个一级目录来寻址。
不过,假设所有内存都可以连续分配未免想得过于简单。为了灵活性,在大多数情况下,进程的栈和堆区域几乎是在地址空间的两端分配的。这使得任何一个区域都可以在需要的时候尽可能的生长。这意味着很可能需要两个2级页表目录和更多的级别相对较低的目录。
但即便如此,也不总是符合当前的做法。出于安全原因,可执行文件的各个部分(代码、数据、堆、栈、动态共享对象(DSOs)、又名共享库)被映射到随机地址。这意味着进程中使用的各种内存区域广泛分布在整个虚拟地址空间中。通过对随机分配地址的某些位施加一些限制,可以限制地址的范围,但是在大多数情况下,肯定不允许一个进程只有一个或两个二三级别的目录。
如果性能比安全性更重要的话,随机化可以关闭掉。操作系统通常将在虚拟内存上连续的加载DSOs。
4.3 优化页表的访问
所有的页表数据结构都存储在主存中;OS在主存构造和更新这些表。在创建一个进程或页表的改变都要通知CPU。页表使用上面描述的页表遍历方式将每个虚拟地址解析为物理地址。更重要的是: 在解析虚拟地址的过程中,每个level至少使用一个目录。这需要四次内存访问(对于运行进程的一次访问)这是很慢的。可以将这些目录表项视为普通数据,将它们缓存到L1d、L2等中,但这仍然太慢了。
从最早的虚拟内存开始,CPU设计人员就使用了一种不同的优化方法。一个简单的计算可以表明,只将目录表项保存在L1d和更高的缓存中会导致糟糕的性能。每个绝对地址计算都需要进行与页表深度对应的数次L1d的访问。这些访问不能并行化,因为它们依赖于前面查阅的结果。仅这一点,在具有4级别页表的机器上,就至少需要12个周期。再加上L1d缓存脱靶的概率,导致指令管道无法抵消任何开销。额外的L1d访问也会占用宝贵的带宽。因此,不仅需要缓存整个页表目录,而且需要缓存物理页面地址的完整计算。就像代码和数据缓存一样,这种缓存的地址计算也是同样有效的。由于虚拟地址的页偏移部分在物理页地址的计算中不起任何作用,所以只有虚拟地址的其余部分用作缓存的标记。根据页面大小的不同,这意味着数百或数千条指令或数据对象共享相同的标记,因此具有相同的物理地址前缀。
存储计算值的缓存被称为Translation Look-Aside Buffer(TLB)。它通常非常小并且非常快。现代CPU提供了多级TLB缓存,正如其他缓存一样,高级缓存也更大更慢。小尺寸的一级TLB缓存通常是全相关的缓存,并且使用LRU驱逐策略。最近,这个缓存的大小一直在增长,在这个过程中,它被变更为组关联的。因此,当一条新条目被添加进来,不一定是最老的条目被驱逐和替换。
正如前面提到的,用来访问TLB的标签是虚拟地址的一部分。如果标签在缓存中命中,最后的物理地址是虚拟地址的页偏移加上缓存中的值,这样的处理非常快。因为物理地址必须对使用绝对地址的指令可用,在一些场合使用物理地址作为查阅二级缓存的关键字。如果TLB缓存脱靶,处理器就得遍历页表树,那将是很耗时的。
如果地址在另一个页上,通过软件或硬件预取指令或数据可以隐式地预取TLB的条目。这对于硬件预取是不允许的,因为硬件可能初始化无效的页表遍历。因此,程序员不能依靠硬件预取来预取TLB项。它必须使用预取指令显式地完成。与数据和指令缓存一样,TLB可以出现多级缓存。就数据缓存而言,TLB通常有两种形式: 指令TLB (ITLB)和数据TLB (DTLB)。正如其他缓存一样,像L2TLB这样的高级TLB缓存通常是统一的。
4.3.1 使用TLB缓存的注意事项
TLB是处理器核心的全局数据。所有在处理器上执行的线程和进程使用相同的TLB。由于虚拟地址到物理地址的转换依赖于页表树,因此如果页表被修改,CPU不能盲目的重用缓存条目。每个进程有不同的页表树(同一进程中的线程不是),如果Kernel和VMM(hypervisor)存在的话也是如此。一个进程的地址空间排布是可能改变的。这有两种方式来处理这种问题:
- 当页表树被修改的时候刷新TLB缓存。
- 对TLB项的标记进行扩展,以附加地、惟一地标识它们所引用的页表树。
第一种情况下,上下文切换TLB都会刷新。因为在大多数操作系统中,从一个线程/进程切换到另一个线程/进程需要执行一些内核代码,所以TLB刷新受限制于离开(或进入)内核地址空间。在虚拟系统中,当内核必须调用VMM(虚拟机管理器)以及在返回的过程中也会发生这种情况。如果内核或VMM不需要使用虚拟地址,进程或调用系统/VMM的内核能够重用相同的虚拟地址(也就是说地址空间重叠)时TLB必须刷新,离开内核或VMM时,处理器重新执行另一个进程或内核。
刷新TLB有效但需要花费的代价比较高。例如,在执行一个系统调用时,内核代码可能被限制在几千条指令中,这些指令可能会涉及几个新页(或者一个大页面,就像某些架构上的Linux那样)。这项工作将只替换所涉及页的所有TLB条目。对于拥有128个ITLB和256个DTLB条目的Intel的Core2架构,一次完全刷新意味着各自有100多个条目和200多个条目被不必要地刷新。当系统调用返回到相同的进程时,可以再次使用所有已刷新的TLB项,但是它们却消失了。在内核或VMM中经常使用的代码也是如此。在进入内核的每个条目上,TLB必须从头开始填充,即使内核和VMM的页表通常不会更改,因此,理论上,TLB条目可以保存很长时间。这也解释了为什么今天的处理器中的TLB缓存不会更大: 程序很可能没有足够长的运行时间来填充所有这些条目。
当然,这个情况并没有逃过CPU架构师的注意。优化缓存刷新的一种可能性是逐个地使TLB项无效。例如,如果内核代码和数据落在一个特定的地址范围内,只有落在这个地址范围内的页从TLB中清除。只需要比较标签,因此代价不是很高。这个方法在部分地址空间被更改时也很有用,例如,通过调用munmap。
一个更好的解决方案是扩展用于TBL访问的标签,除了部分虚拟地址,如果为每个页表树添加唯一标识,这样TLB就不需要完全刷新。内核,VMM和单独的进程都有唯一的标识 。此方案唯一的问题是TLB标签可用的位数受到限制,而地址空间则没有限制。这意味着一些标识有必要重用。当这种情况发生,必须对TLB进行部分刷新,所有重用的标识必须被刷新,只能希望这是一个小的集合。
当系统上运行多个进程时,这种扩展的TLB标签在虚拟化领域之外具有优势。如果每个可运行进程的内存使用是有限的,那么当再次调度某个进程时,该进程最近使用的TLB项很可能仍然在TLB中。还有两个额外的优势:
- 特殊地址空间,例如内核和VMM使用的地址空间,通常进入的时间很短; 事后通常返回到发起该条目的地址空间。如果没有标记,则执行一次或两次TLB刷新。通过标记,调用地址空间缓存的转换被保留下来,而且,由于内核和VMM地址空间根本不经常更改TLB项,因此上一次系统调用的转换仍然可以使用。
- 当在同一进程的两个线程间切换时根本不需要刷新TLB。但是,如果没有扩展TLB标签的话,进入内核的条目将会破坏第一个线程的TLB条目。
有些处理器在一段时间内实现了这些扩展标记。AMD在Pacifica虚拟化扩展中引入了一个1位标签扩展。在虚拟化环境中,这个1位地址空间ID (ASID)用于区分VMM的地址空间和Guest域的地址空间。这允许操作系统避免在每次进入VMM时刷新Guest的TLB条目(例如,处理页面错误),或者在返回到Guest时刷新VMM的TLB条目。该架构将允许在未来使用更多的位来作为标记。其他主流处理器可能会效仿并支持该特性。
4.3.2 影响TLB性能
影响TLB性能的因素有很多。首先就是页的大小。很明显,页越大包含越多的指令和数据对象。因此越大的页减少了需要地址转换的次数,这意味着需要的TLB缓存项减少了。现如今的大多数体系架构允许使用多种不同大小的页,一些不同大小的页可以并存。例如,x86/x86-64处理器通常页大小是4KB,但是它们也可以分别有4MB和2MB大小的页。IA-64和PowerPC允许64KB作为页大小。但是大页的使用也会带来一系列的问题。被大页使用的内存区域必须在物理内存上是连续的。如果用于管理物理内存的单元大小和虚拟页一样大,那么浪费的内存将会增加。任何形式的内存操作(像加载可执行文件)要求按页对齐进行调整。这意味着平均来看每次映射都有半页大小的物理内存被浪费。这种内存浪费很容易累加,因此需要对物理内存分配的单元大小施加合理的上限。
将单元大小增加到2MB以适应x86-64上的大页面当然是不实际的。2M太大了,但这又意味着每个大页面必须由许多小页面组成。这些小页面必须在物理内存中是连续的。分配具有4kB单元页大小的2MB连续物理内存具有挑战性。它需要找到一个包含512个连续页的空闲区域。在系统运行一段时间并且物理内存变得碎片化之后,这可能是非常困难的(或者不可能的)。
因此,在Linux上有必要在系统启动时使用特殊的hugetlbfs文件系统来分配这些大页面。固定数量的物理页面被预留作为专用的大型虚拟页面。这些限制的资源不经常用到。它也是一个有限的池子; 增加它通常意味着重新启动系统。尽管如此,在性能优越、资源丰富、安装不太麻烦的情况下,巨大的页面仍然是未来的方向。数据库服务器就是一个例子。
图3.4 ELF程序头表示的调整要求
增加最小虚拟页大小(相对于可选的大页)也有它的问题。内存映射操作(例如,加载应用程序)必须符合这些页面大小。不可能有更小的映射。对于大多数架构来说,可执行文件各个部分的位置具有固定的关系。如果页大小的增加超出了可执行文件或DSO构建时所考虑的范围,则无法执行加载操作。记住这一点很重要。图4.3显示了如何确定ELF二进制文件的对齐要求。它被编码在ELF程序头中。在本例中,一个x86-64二进制文件的值是0x200000 = 2,097,152 = 2MB,是处理器支持的最大页面大小。
使用更大的页面大小还有第二个效果: 减少了页表树层级的数量。因为与页面偏移量对应的虚拟地址部分增加了,所以剩下的需要通过页面目录处理的位就不多了。这意味着,在TLB脱靶的情况下,需要做的工作量减少了。
除了使用大页,可以通过将同时使用的数据移动到更少的页上来减少访问TLB条目的数量。这和我们以前讨论的其他缓存优化策略相似。只是现在的调整要求比较大,当TLB条目的数量相当小的时候是一个比较重大的优化。
4.4 虚拟化的影响
操作系统映像的虚拟化将越来越普遍;这意味着又增加了一层对内存的处理。进程虚拟化或操作系统容器不属于这一类,因为只涉及一个操作系统。像Xen或KVM这样的技术,不管有没有处理器的帮助,都可以执行独立的OS映像。在这种情况下,只有一个软件可以直接控制对物理内存的访问。
图4.4 Xen虚拟化模型
对于Xen(见图4.4),Xen VMM就是软件的一部分。不过,VMM并不实现硬件控制器本身。与其他较早的系统(以及Xen VMM的第一个版本)上的VMMs不同,内存和处理器之外的硬件由特权Dom0域控制。目前,这基本上与无特权的DomU内核相同,而且就内存处理而言,它们没有区别。这里很重要的一点是,VMM将物理内存分配给Dom0和DomU内核,它们自己实现通常的内存处理,就好像它们直接在处理器上运行一样。
为了实现虚拟化所需要的域分离,在Dom0和DomU内核上的内存处理不能无限的访问物理内存。VMM不会通过分发单独的物理页面和让Guest操作系统处理寻址来分配内存;这将对错误或流氓Guest域没有任何防御。相反,VMM为每个Guest域创建自己的页表树,并使用这些数据结构分发内存。好在可以控制对页表树的管理信息的访问。如果代码没有适当的特权,它就不能做任何事情。
在Xen提供的虚拟化中利用了这种访问控制,不管使用的是准虚拟化还是硬件虚拟化(即全虚拟化)。Guest域为每个进程构建页表树的方式与准虚拟化和硬件虚拟化非常相似。每当Guest操作系统修改其页表时,就会调用VMM。然后,VMM使用Guest域中更新的信息来更新自己的影子页表。这些是硬件实际使用的页表。显然,这个过程非常耗时: 页表树的每次修改都需要调用VMM。虽然在没有虚拟化的情况下,内存映射变更的开销并不小,但是现在开销变得更大了。
考虑到从Guest操作系统到VMM和相反过程的更改已经相当耗时,额外的成本可能非常大。这就是为什么处理器开始使用额外的功能来避免创建影子页表。这不仅速度问题得到解决,还减少了VMM的内存消耗。Intel已经扩展了页表(EPTs), AMD称之为嵌套页表(NPTs)。基本上,这两种技术都让Guest操作系统的页表从“guest虚拟地址”生成“host 虚拟地址”。然后必须使用perdomain EPT/NPT树将主机虚拟地址进一步转换为实际的物理地址。这将允许以几乎和非虚拟化情况相同的速度进行内存处理,因为大多数用于内存处理的VMM条目都被删除了。它还减少了VMM的内存使用,因为现在每个域只需要维护一个页表树。
额外的地址转换步骤的结果也存储在TLB中。这意味着TLB不存储虚拟物理地址,而是存储查找的完整结果。已经解释过,AMD的 Pacifica扩展引入了ASID,以避免TLB刷新每个条目。ASID的比特位在处理器扩展的初始版本中为1;这足以区分VMM和guest OS。Intel有用于相同目的的虚拟处理器id (vpid),只是数量更多。但是,VPID是固定用来标识每个guest域的,因此它不能用于标记单独的进程和避免TLB在该级别刷新。
每个地址空间修改所需的工作量是虚拟操作系统的一个问题。但是,基于VMM的虚拟化还存在另一个固有的问题:无法避免使用两层内存处理,而且内存处理很困难(尤其是考虑到并发症如NUMA,见第五节)。Xen使用一个独立的VMM的方案使优化处理困难,因为所有内存管理实现的并复杂性,包括一些琐碎的工作,例如探索内存区域,必须在VMM中复制。操作系统已经完善和优化。
图4.5 KVM虚拟化模型
这就是为什么将VMM/Dom0模型作为最后选择是如此有吸引力。图4.5显示了KVM Linux内核扩展如何解决这个问题。没有单独的VMM直接运行在硬件上并控制所有的Guest;相反,一个普通的Linux内核将接管此功能。这意味着Linux内核中完整而复杂的内存处理功能用于管理系统的内存。Guest域与普通用户级进程一起运行在Guest模式下。虚拟化功能(准虚拟化或完全虚拟化)由KVM VMM控制。这只是另一个用户级别的进程,它使用内核实现的特殊KVM设备控制一个Guest域。
与Xen模型单独的VMM相比,此模型的优点在于,即使在使用Guest操作系统时仍然有两个内存处理程序在工作,但只需要一个实现,即在Linux内核中。没有必要在另一段代码(如Xen VMM)中复制相同的功能。这会导致更少的工作、更少的bug,而且,可能还会减少两个内存处理程序之间的摩擦,因为Linux客户机中的内存处理程序与运行在裸机上的外部Linux内核中的内存处理程序所做的假设是相同的。
总的来说,程序员必须意识到,使用虚拟化时,缓存脱靶(指令、数据或TLB)的成本甚至比不使用虚拟化时还要高。任何减少这种成本的优化在虚拟化环境中都将获得更大的回报。随着时间的推移,处理器设计者将通过EPT和NPT等技术越来越多地减少这种差异,但这种差异永远不会完全消失。
翻译的不对的地方请指出。谢谢。
以上是关于《What every programmer should know about memory》-Virtual Memory译的主要内容,如果未能解决你的问题,请参考以下文章
《What every programmer should know about memory》-What Programmers Can Do译
《What every programmer should know about memory》-What Programmers Can Do译
《What every programmer should know about memory》-Virtual Memory译
《What every programmer should know about memory》-NUMA Support译
《What every programmer should know about memory》-CPU Caches译
8 Traits of an Experienced Programmer that every beginner programmer should know