图形渲染内存系统分析
图形渲染内存系统分析
到目前为止,我们已经将内存系统视为一个大的字节阵列,这种抽象对于设计指令集、学习汇编语言,甚至对于设计具有复杂流水线的基本处理器来说,都已经足够好了。然而,从实际角度来看,这种抽象需要进一步重新定义,以设计一个快速内存系统。在前面章节介绍的基础流水线中,假设访问数据和指令内存需要1个周期,在本章并不总是正确的。事实上,需要对内存系统进行重大优化,以接近1个周期的理想延迟,需要引入“缓存”和分层内存系统的概念,以解决具有大内存容量和低延迟的双重问题。
其次,到目前为止,一直假设只有一个程序在系统上运行,但大多数处理器通常在分时基础上运行多个程序,例如,如果有两个程序A和B,现代台式机或笔记本电脑通常会运行程序A几毫秒,执行程序B几毫秒,然后来回切换。事实上,系统运行着许多其他程序,比如网页浏览器、音频播放器和日历应用程序。一般来说,用户不会感知到任何中断,因为中断发生的时间尺度远低于人脑所能感知的时间尺度。例如,一个典型的视频每秒显示30次新图片,或者每隔33毫秒显示一张新图片。人脑通过将图片拼接在一起,产生一个平稳移动的物体的错觉。如果处理器在33毫秒之前完成处理视频序列中下一张图片的工作,那么它可以执行另一个程序的一部分。人脑将无法分辨差异。这里的重点是,在我们不知情的情况下,处理器与操作系统合作,在多个程序之间每秒切换多次。操作系统本身就是一个专门的程序,可以帮助处理器管理自己和其他程序。Windows和Linux是流行操作系统的示例。
我们需要内存系统中的特殊支持来支持多个程序,如果没有这种支持,那么多个程序会覆盖彼此的数据,这是不希望的行为。第二,我们一直假设拥有无限的内存,这也不是事实,我们拥有的内存量是零,而且它可能会被大型内存密集型程序耗尽。因此,我们应该有一个机制来继续运行这样大的程序,将引入虚拟内存的概念来解决这两个问题:运行多个程序和处理大型内存密集型程序。
计算机系统中的内存层次结构。
如果根据内存系统的关键特性对其进行分类,那么计算机内存这一复杂的主题就更容易管理。其中最重要的列于下表。
表中的术语位置是指内存是计算机内部还是外部,内部内存通常等同于主内存,但也有其他形式的内部内存。处理器需要自己的局部存储器,以寄存器的形式,处理器的控制单元部分也可能需要其自己的内部存储器,缓存是内存的另一种形式。外部内存由外围存储设备(如磁盘和磁带)组成,处理器可以通过I/O控制器访问这些设备。
内存的一个明显特征是它的容量。对于内部内存,通常以字节(1字节=8位)或字表示,常见的字长为8、16和32位。外部内存容量通常以字节表示。
一个相关的概念是传输单位(unit of transfer)。对于内部内存,传输单位等于进出存储模块的电线数量,可能等于字长,但通常更大,例如64、128或256位。为了阐明这一点,请考虑内部内存的三个相关概念:
- 字(Word):内存组织的“自然”单位,字的大小通常等于用于表示整数的位数和指令长度。不幸的是,有很多例外,例如CRAY C90(一种老式CRAY超级计算机)具有64位的字长,但使用46位整数表示。Intel x86体系结构具有多种指令长度,以字节的倍数表示,字大小为32位。
- 可寻址单元(Addressable unit):在某些系统中,可寻址单元是字,但许多系统允许在字节级别寻址。在任何情况下,地址的位A长度与可寻址单元的数量N之间的关系是。
- 传输单位(Unit of transfer):对于主内存,是一次从内存中读出或写入的位数,传输单位不必等于字或可寻址单位。对于外部内存,数据通常以比字大得多的单位传输,这些单位被称为块。
内存类型的另一个区别是访问数据单元的方法,其中包括以下内容:
- 顺序存取:内存被组织成数据单元,称为记录,访问必须按照特定的线性顺序进行,存储的寻址信息用于分离记录并协助检索过程。使用共享读写机制,必须将其从当前位置移动到所需位置,传递和拒绝每个中间记录,因此访问任意记录的时间是高度可变的。
- 直接访问:与顺序访问一样,直接访问涉及共享的读写机制,但单个块或记录具有基于物理位置的唯一地址。访问是通过直接访问来实现的,以到达一般邻近位置,再加上顺序搜索、计数或等待到达最终位置。同样,访问时间是可变的。
- 随机访问:内存中的每个可寻址位置都有一个独特的物理连线寻址机制,访问给定位置的时间与先前访问的顺序无关,并且是恒定的。因此,可以随机选择任何位置,并直接寻址和访问。主内存和一些缓存系统是随机访问的。
- 关联(Associative):是一种随机存取类型的内存,使我们能够对一个字内的所需位位置进行比较,以获得指定的匹配,并同时对所有字进行比较。因此,一个字是基于其内容的一部分而不是其地址来检索的。与普通随机存取内存一样,每个位置都有自己的寻址机制,检索时间与位置或先前的存取模式无关。高速缓存内存可以采用关联访问。
从用户的角度来看,内存的两个最重要的特性是容量和性能,性能涉及三个参数:
- 访问时间(延迟):对于随机存取内存,是执行读取或写入操作所需的时间,即从地址呈现到内存的那一刻到数据被存储或可供使用的那一瞬间的时间。对于非随机存取内存,存取时间是将读写机制定位在所需位置所需的时间。
- 内存周期时间:主要应用于随机存取内存,包括存取时间加上第二次存取开始前所需的任何额外时间。如果信号线上的瞬变消失(die out)或数据被破坏性读取,则可能需要额外的时间来重新生成数据。注意,内存周期时间与系统总线有关,与处理器无关。
- 传输速率:是数据可以传输到内存单元或从内存单元传输出去的速率。对于随机-访问内存,它等于1/(循环时间),对于非随机存取内存,以下关系成立:
其中:
- =读取或写入n位的平均时间。
- =平均访问时间。
- n=位数。
- R=传输速率,单位为比特/秒(bps)。
已经阐述了各种物理类型的内存。当今最常见的是半导体内存、用于磁盘和磁带的磁表面存储器、光学和磁光存储器。
1 内存系统概论
1.1 快速内存系统需求
现在看看构建快速存储系统的技术要求。我们可以用四种基本电路设计内存元件:锁存器、SRAM单元、CAM单元和DRAM单元。这里有一个权衡,锁存器和SRAM单元比DRAM或CAM单元快得多,但与DRAM单元相比,锁存器、CAM或SRAM单元的面积要大一个数量级,而且功耗也要大得多。锁存器被设计为在负时钟边沿读取和读出数据,是一个快速电路,可以在时钟周期的一小部分内存储和检索数据。另一方面,SRAM单元通常被设计为与解码器和感测放大器一起用作SRAM单元的大阵列的一部分。由于这种额外的开销,SRAM单元通常比典型的边缘触发锁存器慢。相比之下,CAM单元最适合与内容相关的存储器,而DRAM单元最适合容量非常大的存储器。
现在,管线假定内存访问需要1个周期,为了满足这一要求,需要用锁存器或SRAM单元的小阵列构建整个内存。下表显示了截至2012年的典型锁存器、SRAM单元和DRAM单元的尺寸。
单元类型 |
面积 |
典型延迟 |
主从D触发器 |
0.8 |
一个时钟周期内的分数 |
SRAM单元 |
0.08 |
1-5时钟周期 |
DRAM单元 |
0.005 |
50-200时钟周期 |
典型的锁存器(主从D触发器)比SRAM单元大10倍,而SRAM单元又比DRAM单元大约16倍,意味着,给定一定量的硅,如果使用DRAM单元,可以保存160倍的数据,但也慢200倍(如果考虑DRAM单元的代表性阵列)。显然,容量和速度之间存在权衡,但我们实际上需要两者。
让我们首先考虑存储能力问题。由于技术和可制造性方面的若干限制,截至2012年,无法制造面积超过400-500平方毫米的芯片,因此在芯片上拥有的内存总量是有限的,但用专门包含存储单元的附加芯片来补充可用存储器的数量是完全可能的。请记住,片外存储器速度较慢,处理器访问此类内存模块需要数十个周期。为了实现1周期内存访问的目标,我们需要在大多数时间使用相对更快的片上内存,但选择也是有限的,无法承受只由锁存器组成的存储系统。对于大量程序,无法将所有数据存储在内存中,例如,现代程序通常需要数百兆字节的内存,一些大型科学程序需要千兆字节的内存。其次,由于技术限制,很难在同一芯片上集成大型DRAM阵列和处理器,设计者不得不将大型SRAM阵列用于片上存储器。如上表所示,SRAM单元(阵列)比DRAM单元(数组)大得多,因此容量小得多。
但是,延迟要求存在冲突。假设我们决定最大化存储,并使内存完全由DRAM单元组成,访问DRAM的等待时间为100个周期。如果假设三分之一的指令是内存指令,那么完美的5阶段处理器管线的有效CPI计算为:1+1/3×(100−1)=341+1/3×(100−1)=34。需要注意的是,CPI增加了34倍,完全不能接受!
因此,我们需要在延迟和存储之间进行公平的权衡,希望存储尽可能多的数据,但不能以非常低的IPC为代价。不幸的是,如果假设内存访问是完全随机的,那么就没有办法摆脱这种情况。如果内存访问中存在某种模式,那么会做得更好,这样就可以做到两全其美:高存储容量和低延迟。
1.2 内存访问模式
内存访问模式有两种:
- 时间局部性(Temporal Locality)。如果某个资源在某个时间点被访问,那么很可能会在很短的时间间隔内再次被访问。
- 空间局部性(Spatial Locality)。如果某个资源在某个时间点被访问,那么很可能在不久的将来也会访问类似的资源。
内存访问中是否存在时间和空间局部性?
如果存在一定程度的时间和空间局部性,那么可以进行一些关键的优化,以帮助解决大内存需求和低延迟这两个问题。在计算机架构中,通常利用诸如时间和空间局部性之类的特性来解决问题。
1.3 指令访问的时空局部性
解决这一问题的标准方法是在一组具有代表性的项目中测量和描述局部性,如SPEC基准。可将将内存访问分为两大类:指令和数据,指令访问更容易进行非正式分析,因此先来看看它。
一个典型的程序有赋值语句、分支语句(if、else)和循环,大型程序中的大部分代码都是循环的一部分或一些通用代码。计算机架构中有一个标准的经验法则,它表明90%的代码运行10%的时间,10%的代码运行90%的时间。对于一个文字处理器,处理用户输入并在屏幕上显示结果的代码比显示帮助屏幕的代码运行得更频繁。同样,对于科学应用,大部分时间都花在程序中的几个循环中。事实上,对于大多数常见的应用程序,我们使用这种模式。因此,计算机架构师得出结论,指令访问的时间局部性适用于绝大多数程序。
现在考虑指令访问的空间局部性。如果没有分支语句,那么下一个程序计数器是当前程序计数器加上ISA的4个字节(常规ISA而言)。如果两个访问的内存地址彼此接近,我们认为这两个访问“相似”,很明显,此处具有空间局部性。程序中的大多数指令是非分支的,空间局部性成立。此外,在大多数程序中,分支的一个很好的模式是,分支目标实际上并不远。如果我们考虑一个简单的If-else语句或for循环,那么分支目标的距离等于循环的长度或语句的If部分。在大多数程序中,通常是10到100条指令长,而不是数千条指令长。因此,架构师得出结论,指令内存访问也表现出大量的空间局部性。
数据访问的情况稍微复杂一些,但差别不大。对于数据访问,我们也倾向于重用相同的数据,并访问类似的数据项。
1.4 时间局部性特征
让我们描述一种称为堆栈距离(stack distance)方法的方法,以表征程序中的时间局部性。
我们维护一个访问的数据地址堆栈。对于每个内存指令(加载/存储),在堆栈中搜索相应的地址,找到条目的位置(如果找到)称为“堆栈距离”。距离是从堆栈顶部开始测量的,堆栈顶部的距离等于零,而第100个条目的堆栈距离等于99。每当我们检测到堆栈中的条目时,我们就会将其移除,并将其推到堆栈顶部。
如果找不到内存地址,那么创建一个新条目并将其推到堆栈的顶部。通常,堆栈的深度是有界的,它的长度为L。如果由于添加了一个新条目,堆栈中的条目数超过了L,那么需要删除堆栈底部的条目。其次,在添加新条目时,堆栈距离没有定义。注意,由于我们考虑有界堆栈,因此无法区分新条目和堆栈中的条目,但必须将其删除,因为它位于堆栈的底部。因此,在这种情况下,我们将堆栈距离设为等于L(以堆栈深度为界)。
请注意,堆栈距离的概念为我们提供了时间局部性的指示。如果访问具有高的时间局部性,那么平均堆栈距离预计会更低。相反,如果内存访问具有低的时间局部性,那么平均堆栈距离将很高,因此可以使用堆栈距离的分布来衡量程序中的时间局部性。
我们可以使用SPEC2006基准测试Perlbench进行了一个简单的实验,它运行不同的Perl程序,我们维护计数器以跟踪堆栈距离。第一百万次内存访问是一个预热期(warm-up period),在此期间,堆栈保持不变,但计数器不递增。对于接下来的一百万次内存访问,堆栈将保持不变,计数器也将递增。下图显示了堆栈距离的直方图,堆栈的大小限制为1000个条目,足以捕获绝大多数的内存访问。
堆栈距离分布图。
以上可知,大多数访问具有非常低的堆栈距离,0-9之间的堆栈距离是最常见的值,大约27%的所有访问都在这个箱中。事实上,超过三分之二的内存访问的堆栈距离小于100。超过100,分布逐渐减少,但仍然相当稳定。堆栈距离的分布通常被称为遵循重尾分布(heavy tailed distribution),意味着分布严重偏向于较小的堆栈距离,但大的堆栈距离并不少见。对于大的堆栈距离,分布的尾部仍然是非零的,上图显示了类似的行为。
研究人员试图使用对数正态分布来近似堆栈距离:
1.5 空间局部性特征
关于堆栈距离,我们定义了术语地址距离,第i个地址距离是第i次内存访问的存储器地址与最后K次内存访问集合中最近的地址之间的差,内存访问可以是加载或存储。以这种方式消除不良地址距离有一个直观的原因,程序通常在同一时间间隔内访问主内存的不同区域,例如,对数组执行操作,访问数组项,然后访问一些常量,执行操作,保存结果,然后使用For循环移动到下一个数组条目。这里显然存在空间局部性,即循环访问的连续迭代接近数组中的地址。但为了量化它,需要搜索最近K次访问中最近的访问(以内存地址表示),其中K是封闭循环每次迭代中的内存访问数。在这种情况下,地址距离被证明是一个小值,并且表示高空间局部性。但K需要精心选择,不应该太小,也不应该太大,根据经验,K=10对于一组大型程序来说是一个合适的值。
总之,如果平均地址距离很小,意味着程序具有较高的空间局部性,该程序倾向于在相同的时间间隔内以高可能性访问附近的内存地址。相反,如果地址距离很高,则访问彼此相距很远,程序不会表现出空间局部性。
使用SPEC2006基准Perlbench重复前面描述的实验,为前100万次访问提供了地址距离分布,见下图。
四分之一以上的访问的地址距离在-5和+5之间,三分之二以上的访问地址距离在-25和+25之间。超过±50±50,地址距离分布逐渐减小。从经验上看,这种分布也具有重尾性质。
1.6 利用时空局部性
前面小节展示了示例程序的堆栈和地址距离分布。用户在日常生活中使用的数千个程序也进行了类似的实验,包括计算机游戏、文字处理器、数据库、电子表格应用程序、天气模拟程序、金融应用程序和在移动计算机上运行的软件应用程序。几乎所有这些都表现出非常高的时间和空间局部性,换句话说,时间和空间的局部性是人类的基本特征,无论我们做什么(如拿取书本或编写程序)都会保持不变。请注意,这些只是经验观察,依然可以编写一个不显示任何形式的时间和空间局部性的程序,在商业程序中也可以找到不显示这些特征的代码区域。但这些是例外,不是常态。我们需要为常规而不是特例设计计算机系统,这就是我们如何提高大多数用户期望运行的程序的性能。
从现在起,将时间和空间的局部性视为理所当然,看看可以做些什么来提高内存系统的性能,而不影响存储容量。让我们先看看时间局部性。
利用时间局部性:分层内存系统
我们可以为内存设计一个存储位置,称之为缓存,缓存中的每个条目在概念上都包含两个字段:内存地址和值,并定义一个缓存层次结构,如下图所示。
内存层次。
主内存(物理内存)是一个大型DRAM阵列,包含处理器使用的所有内存位置的值。
L1缓存通常是一个小型SRAM阵列(8 - 64KB),L2缓存是一个更大的SRAM阵列(128KB - 4 MB)。一些处理器(如Intel Sandybridge处理器)有另一级缓存,称为L3缓存(4MB+)。在L2/L3高速缓存下面,有一个包含所有内存位置的大型DRAM阵列,被称为主内存或物理内存。在L1缓存中维护L2缓存的值子集更容易,以此类推,称为具有包含式缓存(inclusive cache)的系统。因此,对于包含性缓存层次结构,我们有: 。或者,我们可以使用独占缓存(exclusive cache),其中较高级别的缓存不一定包含较低级别缓存中的值子集。到目前为止,所有处理器都普遍使用非独占缓存,因为其设计的简单性、简单性和一些微妙的正确性问题。然而,截至2012年,通用处理器的实用性尚未确定。
第n级缓存中包含的一组存储器值是第(n+1)级缓存中所有值的子集的内存系统称为包含式缓存(inclusive cache)层次结构。不遵循严格包含的内存系统称为独占缓存(exclusive cache)层次结构。
现在再次查看上图所示的缓存层次结构。由于L1缓存较小,所以访问速度更快,访问时间通常为1-2个周期。L2缓存更大,通常需要5-15个周期才能访问。主内存由于其大尺寸和使用DRAM单元,速度要慢得多,访问时间通常非常高,在100-300个周期之间。内存访问协议类似于作者访问书籍的方式。
内存访问协议如下。每当有内存访问(加载或存储)时,处理器都会首先检查一级缓存。请注意,缓存中的每个条目在概念上都包含内存地址和值。如果数据项存在于一级缓存中,则缓存命中(cache hit),否则缓存未命中(cache miss)。如果存在缓存命中,并且内存请求是读取,那么只需将值返回给处理器,如果内存请求是写入,则处理器将新值写入缓存条目。然后,它可以将更改传播到较低级别,或恢复处理。后面章节会讨论不同的写入策略和执行缓存写入的不同方法。但是,如果存在缓存未命中,则需要进一步处理。
缓存命中(cache hit):当缓存中存在内存位置时,该事件称为缓存命中。
缓存未命中(cache miss):当缓存中不存在内存位置时,该事件称为缓存未命中。
在一级缓存未命中的情况下,处理器需要访问二级缓存并搜索数据项。如果找到项目(缓存命中),则协议与一级缓存相同,由于本文考虑了包含性缓存,所以有必要将数据项提取到一级缓存。如果存在L2未命中,则需要访问较低级别,较低级别可以是另一个L3缓存,也可以是主内存。在最低级别(即主内存),我们保证不会发生未命中,因为我们假设主内存包含所有内存位置的条目。
处理器使用分层内存系统来最大化性能,而不是使用单一的平面存储系统,分层存储系统旨在提供具有理想单周期延迟的大内存的错觉。
举个具体的例子,查找以下配置的平均内存访问延迟。
级别 |
未命中率(%) |
延迟 |
L1 |
10 |
1 |
L2 |
10 |
10 |
主内存 |
0 |
100 |
内存系统配置1。
级别 |
未命中率(%) |
延迟 |
主内存 |
0 |
100 |
内存系统配置2。
答案:让我们先考虑配置1,90%的访问都发生在一级缓存中,这些命中的内存访问时间是1个周期。请注意,即使在一级缓存中未命中的访问仍会导致1个周期的延迟,因为我们不知道访问是否会在缓存中命中或未命中。随后,90%到二级缓存的访问都会在缓存中命中,它们会产生10个周期的延迟。最后,剩余的访问(1%)命中了主内存,并导致了额外的延迟。因此,平均内存存取时间(T)为:
T=1+0.1×(10+0.1×100)=1+1+1=3
因此,配置1的分层内存系统的平均内存延迟是3个周期。
配置2是一个平面层次结构,使用主内存进行所有访问,平均内存访问时间为100个周期。因此,使用分层内存系统可以将速度提高100/3=33.3100/3=33.3倍。
上面的示例表明,使用分层内存系统的性能增益是具有单层分层结构的平面内存系统的33.33倍,性能改进是不同缓存的命中率及其延迟的函数。此外,缓存的命中率取决于程序的堆栈距离分布和缓存管理策略,同样,缓存访问延迟取决于缓存制造技术、缓存设计和缓存管理方案。在过去二十年中,优化缓存访问一直是计算机体系结构研究中的一个非常重要的主题,研究人员在这方面发表了数千篇论文。本文只讨论其中的一些基本机制。
利用空间局部性:缓存块
现在考虑空间局部性,上上图揭示了大多数访问的地址距离在±25±25字节内。地址距离分布表明,如果将一组内存位置分组到一个块中,并从较低级别一次性获取,那么可以增加缓存命中数,因为访问中存在高度的空间局部性。
因此,几乎所有处理器都创建连续地址块,缓存将每个块视为一个原子单元。一次从较低级别获取整个块,如果需要,也会从缓存中逐出整个块。缓存块也称为缓存行,一个典型的缓存块或一行是32-128字节长,为了便于寻址,它的大小必须是2的严格幂。
缓存块(cache block)或缓存行(cache line)是一组连续的内存位置,被视为缓存中的原子数据单元。
因此,我们需要稍微重新定义缓存条目(cache entry)的概念,没有为每个内存地址创建一个条目,而是为每个缓存行创建一个单独的条目。请注意,本文将同义地使用术语缓存行和块,还要注意,L1高速缓存和L2高速缓存中不必具有相同的高速缓存行大小,它们可以不同。然而,为了保持高速缓存的包容性,并最小化额外的内存访问,通常需要在L2使用与L1相同或更大的块大小。
迄今所学到的要点:
- 时间和空间局部性是大多数人类行为固有的特性,它们同样适用于阅读书籍和编写计算机程序。
- 时间局部性可以由堆栈距离量化,空间局部性可以通过地址距离量化。
- 我们需要设计内存系统,以利用时间和空间的局部性。
- 为了利用时间局部性,我们使用由一组缓存组成的分层内存系统。L1高速缓存通常是一种小而快的结构,旨在快速满足大多数存储器访问。较低级别的缓存存储的数据量较大,访问频率较低,访问时间较长。
- 为了利用空间局部性,我们将连续的存储器位置集合分组为块(也称为行),块被视为缓存中的原子数据单元。
前面已经定性地研究了缓存的需求,后面将继续讨论缓存的设计。
2 缓存
2.1 缓存综述
高速缓存的设计是为了将昂贵的高速内存的内存访问时间与较便宜的低速内存的大内存大小相结合,如下图a所示。下图b描述了多级缓存的使用,L2高速缓存比L1高速缓存慢且通常更大,而L3高速缓存比L2高速缓存慢并且通常更大。
下图描述了缓存/主内存系统的结构。主内存由多达个可寻址字组成,每个字具有唯一的n位地址。出于映射的目的,该内存被认为由多个固定长度的块组成,每个块包含K个字,也就是说,主内存中有个块。缓存由m个块组成,称为行(line),每行包含K个字,加上几个位的标记。每一行还包括控制位(未示出),例如指示该行自从被加载到缓存中以来是否已被修改的位,行的长度(不包括标记和控制位)是行大小。
下图说明了读取操作。处理器生成要读取的字的读取地址(RA),如果该单词包含在缓存中,则将其传递给处理器。否则,包含该字的块被加载到缓存中,并且该字被传递到处理器。图中显示了并行发生的最后两个操作,并反映了下下图所示的当代缓存组织的典型。在这种组织中,缓存通过数据、控制和地址线连接到处理器。数据和地址线还连接到数据和地址缓冲区,这些缓冲区连接到系统总线,从该总线可以访问主内存。当缓存命中时,数据和地址缓冲区将被禁用,并且只有处理器和缓存之间的通信,没有系统总线通信。当发生缓存未命中时,所需的地址被加载到系统总线上,数据通过数据缓冲区返回到缓存和处理器。在其他组织中,缓存物理地插入处理器和主内存之间,用于所有数据、地址和控制线。在后一种情况下,对于缓存未命中,所需的字首先被读取到缓存中,然后从缓存传输到处理器。
缓存读取操作。
典型的缓存组织。
当使用虚拟地址时,系统设计者可以选择在处理器和MMU之间或MMU和主内存之间放置缓存(下图)。逻辑缓存(也称为虚拟缓存)使用虚拟地址存储数据,处理器直接访问缓存,而不经过MMU,物理缓存则使用主内存物理地址存储数据。
下图a显示了缓存的直接映射方式,其中前m个主内存块的映射,每个主内存块映射到缓存的一个唯一行中,接下来的m个主内存块以相同的方式映射到缓存中。
缓存的直接和关联映射。
直接映射缓存组织。
完全的关联映射缓存组织。
此处之外,还存在K路(如2、4、8、16路)的缓存映射方式,参见下图。
k路集关联缓存组织。
不同映射方式随着缓存大小改变关联性的曲线如下:
下图是曾经风靡一时具有代表性的奔腾4的结构图,清晰地展示了缓存的结构:
2.2 基本缓存操作
让我们将缓存视为一个黑盒子,如下图所示。在加载操作的情况下,输入是内存地址,如果缓存命中,输出是内存位置的值。我们设想缓存有一个状态行,指示请求是命中还是未命中。如果操作是存储,则缓存接受两个输入:内存地址和值。如果缓存命中,则缓存将值存储在与内存位置相对应的条目中,否则,表示缓存未命中。
作为黑盒子的缓存。
现在看看实现这个黑盒子的方法,将使用SRAM阵列作为构建块。
为了启发设计,考虑一个例子,有一个块大小为64字节的32位机器,在这台机器中,有个块。一级缓存的大小为8 KB,包含128个块。因此,可以在任何时间点将一级缓存视为整个内存地址空间的非常小的子集,最多包含个块中的128个。为了确定一级缓存中是否存在给定的块,需要查看128个条目中是否有任何一个包含该块。
假设一级缓存是内存层次结构的一部分,内存层次结构作为一个整体支持两个基本请求:读和写。然而,在缓存级别需要许多基本操作来实现这两个高级操作。
类似于内存地址,将块地址定义为内存地址的26个MSB位。第一个问题是判断缓存中是否存在具有给定块地址的块,需要执行一个查找(lookup)操作。如果块存在于缓存中(即缓存命中),则返回指向该块的指针,需要两个基本操作来服务请求,即数据读取(data read)和数据写入(data write)——读取或写入块的内容,并需要指向块的指针作为参数。
如果有缓存未命中,那么需要从内存层次结构的较低级别获取块并将其插入缓存。从内存层次结构的较低级别获取块并将其插入缓存的过程称为填充(fill)操作,填充操作是一个复杂的操作,并使用许多原子子操作。需要首先向较低级别的缓存发送加载请求以获取块,然后插入L1缓存。
插入过程也是一个复杂的过程。首先需要检查在一组给定的块中是否有空间插入一个新块,如果有足够的空间,那么可以使用插入(insert)操作填充其中一个条目。但是,如果要在缓存中插入块的所有位置都已经被占用,那么需要从缓存中移除一个已经存在的块。因此,需要调用一个替换(replace)操作来结束需要收回的缓存块。一旦找到了合适的替换候选块,需要使用逐出(evict)操作将其从缓存中逐出。
总之,实现缓存广泛地需要这些基本操作:查找、数据读取、数据写入、插入、替换和逐出。填充操作只是内存层次结构不同级别的查找、插入和替换操作的序列,同样,读取操作主要是查找操作,或者是查找和填充操作的组合。
2.3 缓存查找和缓存设计
假设为32位系统设计一个块大小为64字节的8KB缓存。为了进行高效的高速缓存查找,需要找到一种高效的方法来查找高速缓存中128个条目中是否存在26位块地址。存在两个问题:第一个问题是快速找到给定的条目,第二个问题是执行读/写操作。与其使用单个SRAM阵列来解决这两个问题,不如将其拆分为两个阵列,如下图所示。
缓存结构。
完全关联(FA)缓存
在典型的设计中,缓存条目保存在两个基于SRAM的阵列中,一个称为标记阵列的SRAM阵列包含与块地址有关的信息,另一个称称为数据阵列的SRRAM阵列包含块的数据。标记数组包含唯一标识块的标记,标记通常是块地址的一部分,并取决于缓存的类型。除了标签和数据阵列之外,还有一个专用的高速缓存控制器,用于执行高速缓存访问算法。
首先考虑一种非常简单的定位块的方法。我们可以同时检查缓存中128个条目中的每一个,以查看块地址是否等于缓存条目中的块地址。此缓存称为完全关联缓存(fully associative cache)或内容可寻址缓存(content addressable cache),“完全关联”表示给定块可以与缓存中的任何条目关联。
因此,全关联(FA)缓存中的每个缓存条目都需要包含两个字段:标签(tag)和数据(data)。在这种情况下,可以将标记设置为等于块地址,由于块地址对于每个块都是唯一的,因此它适合标记的定义。块数据指的是块的内容(在这种情况中为64字节)在我们的示例中,块地址需要26位,块数据需要64字节。搜索操作需要跨越整个缓存,一旦找到条目,我们需要读取数据或写入新值。
先看看标签数组。在这种情况下,每个标签都等于26位块地址,内存请求到达缓存后,第一步是通过提取26个最重要的位来计算标签,然后,需要使用一组比较器将提取的标签与标签数组中的每个条目进行匹配。如果没有匹配,可以声明缓存未命中并进一步处理,如果有缓存命中,需要使用与标记匹配的条目编号来访问数据条目,例如,在包含128个条目的8KB缓存中,标签数组中的第53个条目可能与标签匹配。在这种情况下,高速缓存控制器需要在读取访问的情况下从数据阵列中取出第53个条目,或者在写入访问的情况中写入第53个条目的。
有两种方法可以在完全关联缓存中实现标记数组,或者可以将其设计为一个普通的SRAM阵列,其中缓存控制器迭代每个条目,并将其与给定的标记进行比较,或者可以使用每行都有比较器的CAM阵列。它们可以将标签的值与存储在行中的数据进行比较,并根据比较结果生成输出(1或0)。CAM阵列通常使用编码器来计算与结果匹配的行数,完全关联缓存的标记数组的CAM实现更为常见,主要是因为顺序迭代数组非常耗时。
下图说明了这一概念。通过将对应的字线设置为1来启用CAM阵列的每一行,随后,CAM单元中的嵌入式比较器将每一行的内容与标签进行比较,并生成输出。我们使用OR门来确定是否有任何输出等于1,如果有任何输出为1,则表示缓存命中,否则表示缓存未命中。这些输出线中的每一条还连接到编码器,该编码器生成匹配行的索引,我们使用此索引访问数据数组并读取块的数据。在写入的情况下,我们写入块,而不是读取它。
完全关联缓存。
全关联缓存对于小型结构(通常为2-32)条目非常有用,但不可能将CAM阵列用于更大的结构,比较和编码的面积和功率开销非常高。也不可能顺序地遍历标签阵列的SRAM实现的每个条目,非常耗时。因此,我们需要找到一种更好的方法来定位更大结构中的数据。
直接映射(DM)缓存
在完全关联的缓存中,可以将任何块存储在缓存中的任何位置。这个方案非常灵活,但是,当缓存有大量条目时,它不能使用,主要是因为面积和电源开销太大。我们不允许将块存储在缓存中的任何位置,而是只为给定块指定一个固定位置。可以按如下方式进行。
在示例中,有一个8 KB的缓存,包含128个条目,不妨限制64字节块在缓存中的位置。对于每个块,在标记数组中指定一个唯一的位置,在该位置可以存储与其地址对应的标记,可以生成这样一个独特的位置,如下所示。让我们考虑块a的地址和缓存中的条目数(128),并计算a%128,%运算符计算a除以128的余数,由于a是二进制值,128是2的幂,因此计算余数非常容易。我们只需要从26位块地址中提取7个LSB位,这7位可用于访问标签阵列,就可以将保存在标记数组中的标记值与根据块地址计算的标记值进行比较,以确定是否命中或未命中。
还可以稍微优化它的设计,而不是像完全关联缓存那样将块地址保存在标记数组中。块地址中的26位中有7位用于访问标签阵列中的标签,意味着所有可能被映射到标签数组中给定条目的块都将有其最后7位共用,因此这7位不需要明确地保存为标签的一部分,只需要保存块地址的剩余19位,这些位可以在块之间变化。因此,高速缓存的直接映射实现中的标签只需要包含19位。
下图以图形方式描述了这一概念。将32位地址分成三部分,最重要的19位包括标记,接下来的7位称为索引(标记数组中的索引),其余6位指向块中字节的偏移,访问协议的其余部分在概念上类似于完全关联缓存。在这种情况下,我们使用索引来访问标记数组中的相应位置,读取内容并将其与计算标记进行比较。如果它们相等,则我们声明缓存命中,否则,缓存未命中。在缓存命中的情况下,使用索引访问数据数组,使用缓存命中/未命中结果来启用/禁用数据阵列。
直接映射缓存。
到目前为止,我们已经研究了完全关联和直接映射缓存:
- 全关联缓存是一种非常灵活的结构,因为块可以保存在缓存中的任何条目中,但具有更高的延迟和功耗。由于给定的块可能被分配到缓存的更多条目中,因此它的命中率高于直接映射缓存。
- 直接映射缓存是一种速度更快、功耗更低的结构。一个块只能驻留在缓存中的一个条目中,此缓存的预期命中率小于完全关联缓存的命中率。
在完全关联和直接映射缓存之间,在功率、延迟和命中率之间存在折衷。
集合相联缓存
全关联缓存更耗电,因为需要在缓存的所有条目中搜索块。相比之下,直接映射缓存更快、更高效,因为只需要检查一个条目,但命中率较低,也是不可接受的。因此,尝试将这两种范式结合起来。
让我们设计一个缓存,其中一个块可以潜在地驻留在缓存中多个条目的集合中的任何一个条目中,将缓存中的一组条目与块地址相关联,像完全关联缓存一样,必须在声明命中或未命中之前检查集合中的所有条目,这种方法结合了完全关联和直接映射方案的优点。如果一个集合包含4或8个条目,那么就不必使用昂贵的CAM结构,也不必依次迭代所有条目,可以简单地从标记数组中并行读取集合的所有条目,并将所有条目与块地址的标记部分进行并行比较。如果存在匹配,那么我们可以从数据数组中读取相应的条目。由于多个块可以与一个集合相关联,因此将此设计称为集合关联(set associative)缓存。集合中的块数称为缓存的关联性,集合中的每个条目都被称为一个路(way)。
关联性(Associativity):集合中包含的块数定义为缓存的关联性。
路(Way):集合中的每个条目都被称为路。
让我们描述一个简单的方法,将缓存项分组为集合,考虑了一个32位内存系统,其中有一个8 KB的缓存和64字节的块。如下图所示,首先从32位地址中删除最低的6位,因为它们指定了块中字节的地址,剩余的26位指定块地址,8-KB缓存总共有128个条目。如果想创建每个包含4个条目的集合,那么我们需要将所有缓存条目分成4个条目,将有32()个这样的集合。
在直接映射缓存中,我们使用26位块地址中的最低7位来指定缓存中条目的索引,现在可以将这7个比特分成两部分,如上图所示。一部分包含5个比特并指示集合的地址,而第二部分包含2个比特则被忽略,指示集合地址的5位组称为集合索引。
在计算集合索引i之后,需要访问标签数组中属于集合的所有元素。可以按有以下方式排列标签数组,如果一个集合中的块数是S,那么可以对属于一个集合的所有条目进行连续分组。对于第i个集合,我们需要访问元素iS、(iS+1)... (iS+S−1)在标签数组中。
对于标记数组中的每个条目,需要将条目中保存的标记与块地址的标记部分进行比较。如果有匹配,那么我们可以宣布命中。集合关联缓存中标记的概念相当棘手。如上图所示,它由不属于索引的位组成,是块地址的(21=26−5)(21=26−5)MSB位,用于确定标签比特数的逻辑如下。
每个集合由5位集合索引指定,这5个比特对于可能映射到给定集合的所有块都是公用的,需要使用其余的比特(21=26−5)(21=26−5)来区分映射到同一集合的不同块。因此,集合关联高速缓存中的标签的大小介于直接映射高速缓存(19)和完全关联高速缓存(26)的大小之间。
下图显示了集合关联缓存的设计。首先从块的地址计算集合索引,使用位7-11,使用集合索引来使用标签数组索引生成器生成标签数组中相应四个条目的索引。然后,并行访问标记数组中的所有四个条目,并读取它们的值。此处无需使用CAM阵列,可以使用单个多端口(多输入、多输出)SRAM阵列。接下来,将每个元素与标记进行比较,并生成一个输出(0或1)。如果任何一个输出等于1(由或门确定),缓存命中,否则缓存未命中。我们使用编码器对匹配的集合中的标记进行索引,因为假设4路关联缓存,编码器的输出在00到11之间。随后,使用多路复用器来选择标签数组中匹配条目的索引,这个索引可以用来访问数据数组,数据数组中的相应条目包含块的数据,可以读或写它。
可以对读取操作进行一个小优化。请注意,在读取操作的情况下,对数据数组和标记数组的访问可以并行进行。如果一个集合有4路,那么当计算标签匹配时,可以读取与该集合的4路对应的4个数据块。随后,在缓存命中的情况下,在计算了标记数组中的匹配条目之后,我们可以使用多路复用器选择正确的数据块。在这种情况下,实际上将从数据数组读取块所需的部分或全部时间与标记计算、标记数组访问和匹配操作重叠。
集合关联缓存。
总之,集合关联缓存是目前最常见的缓存设计,即使是非常大的缓存,它也具有可接受的功耗值和延迟。集合关联缓存的关联性通常为2、4或8,关联性为K的集合也称为K路关联缓存。
在设计集合关联缓存时,我们需要回答一个深刻的问题。设置索引位和忽略位的相对顺序应该是什么?被忽略的位应该朝向索引位的左侧(MSB),还是朝向索引位右侧(LSB)?上上图选择了MSB,背后的逻辑是什么?
答案:
如果索引位的左边(MSB)有被忽略的位,那么相邻的块映射到不同的集合。然而,对于被忽略的位在索引位的右侧(LSB)的相反情况,连续块映射到同一集合。前一种方案称为非CONT,后一种方案为CONT,在设计中选择了非CONT。
考虑两个数组A和B,让A和B的大小明显小于缓存的大小,让它们的一些组成块映射到同一组集合。下图显示了存储CONT和NON-CONT方案数组的缓存区域的概念图。我们观察到,即使缓存中有足够的空间,也不可能使用CONT方案同时保存缓存中的两个数组。它们的内存占用在缓存的一个区域中重叠,因此不可能在缓存中同时保存两个程序的数据。然而,NON-CONT方案试图将块均匀分布在所有集合上。因此,可以同时将两个阵列保存在缓存中。
这是程序中经常出现的模式。CONT方案保留缓存的整个区域,因此不可能容纳映射到冲突集的其他数据结构。然而,如果将数据分布在缓存中,那么可以容纳更多的数据结构并减少冲突。
举个具体的示例,在32位系统中,缓存具有以下参数:
参数 |
值 |
尺寸 |
N |
关联性 |
K |
块尺寸 |
B |
那么,标签的尺寸是多数?答案:
- 指定块内的字节所需的位数为log(B)。
- 块数等于N=B,集合数等于N/(BK)。
- 设置的索引位数等于:log(N)−log(B)−log(K)。
- 剩余的位数是标签位,等于:32−(log(N)−log(B)−log(K)+log(B))=32−log(N)+log(K)32。
2.4 数据读取和写入操作
一旦我们确定给定的块存在于缓存中,我们就使用基本的读取操作从数据数组中获取内存位置的值。如果查找操作返回缓存命中,将确定缓存中是否存在块。如果缓存中存在未命中,则缓存控制器需要向较低级别的缓存发出读取请求,并获取块。数据读取操作可以在数据可用时立即开始。
第一步是读取数据数组中对应于匹配标记条目的块,然后从块中的所有字节中选择合适的字节集,可以使用一组多路复用器来实现之。在查找操作之后,不必严格开始数据读取操作,可以在两次行动之间有明显的重叠,例如,可以并行读取标记数组和数据数组。在计算出匹配标记之后,可以使用多路复用器选择正确的值集合。
在写入值之前,需要确保整个块已经存在于缓存中,这点非常重要。请注意,我们不能断言,因为正在创建新数据,所以不需要块的前一个值。原因是:对于单个内存访问,通常写入4个字节或最多8个字节,但一个块至少有32或64字节长,块是缓存中的原子单元,因此不能在不同的地方拥有它的不同部分。例如,不能将一个块的4个字节保存在一级缓存中,其余的字节保存在二级缓存中。其次,为了做到这一点,需要维护额外的状态,以跟踪已被写入更新的字节。因此,为了简单起见,即使希望只写入1个字节,也需要用整个块填充缓存。
之后,需要通过启用适当的一组字行(word line)和位行(bit line)将新值写入数据阵列,可以使用一组解复用器的电路来简单实现。
执行数据写入有两种方法:
- 直写(write-through)。直写是一种相对简单的方案,在这种方法中,每当将值写入数据数组时,也会向较低级别的缓存发送写操作。这种方法增加了缓存流量,但实现缓存更简单,因为不必在将块放入缓存后跟踪已修改的块。因此,如果需要,可以无缝地从缓存中逐出一行。缓存收回和替换也很简单,但以写入为代价。如果L1缓存遵循直写协议,那么很容易为多处理器实现缓存。
- 回写(write-back)。此方案明确地跟踪已使用写操作修改的块,可以通过在标记数组中使用额外的位来维护此信息,该位通常称为修改位,每当从内存层次结构的较低级别获得一个块时,修改位为0,然而,当进行数据写入并更新数据数组时,将标记数组中的修改位设置为1。逐出一行需要额外的处理。对于写回协议,写入成本较低,逐出操作成本较高。这里的折衷与直写缓存中的折衷相反。
带有附加修改位的标签数组中的条目结构如下图所示。
带有修改位的标签数组中的条目。
2.5 插入操作
本节将讨论在缓存中插入块的协议。当块从较低级别到达时,将调用此操作。需要首先查看给定块映射到的集合的所有方式,并查看是否有空条目。如果存在空条目,那么可以任意选择其中一个条目,并用给定块的内容填充它。如果没有结束任何空条目,需要调用替换和逐出操作来从集合中选择和删除一个已经存在的块。
需要维护一些额外的状态信息,以确定给定条目是空的还是非空的。在计算机体系结构中,这些状态也分别被称为无效(invalid)和有效(valid)。只需要在标记数组中存储一个额外的位,以指示块的状态,被称为有效位(valid bit)。使用标签数组来保存关于条目的附加信息,因为它比数据数组更小,通常更快。添加了有效位的标签数组中的条目结构如下图所示。
标签数组中的一个条目,包含修改后的有效位。
缓存控制器需要在搜索无效条目时检查每个标签的有效位。缓存的所有条目最初都是无效的,如果发现无效条目,则可以用块的内容填充数据数组中的相应条目,该条目随后生效。但是,如果没有无效条目,那么需要用需要插入缓存的给定块替换一个条目。
2.6 替换操作
这里的任务是在集合中查找一个可以被新条目替换的条目。我们不希望替换频繁访问的元素,因为会增加缓存未命中的数量。理想情况下,希望替换将来被访问的可能性最小的元素,但是,很难预测未来的事件。需要根据过去的行为做出合理的猜测,可以有不同的策略来替换缓存中的块。这些被称为替换方案或替换策略。
缓存替换方案(replacement scheme)或替换策略(replacement policy)是用新条目替换集合中的条目的方法。
常用的替换策略有:
- 随机替换策略。此策略最简单和普通,随机选取一个块并替换它。但是,它在性能方面并不是很理想,因为没有考虑程序的行为和内存访问模式的性质。该方案最终经常替换非常频繁访问的块。
- FIFO替换策略。FIFO(先入先出)替换策略的假设是,在最早的时间点被带入缓存的块在将来被访问的可能性最小。为了实现FIFO替换策略,需要在标记数组中添加一个计数器。每当引入一个块时,都会给它分配一个等于0的计数器值,为其余的块增加计数器值。计数器越大,块越早进入缓存。
此外,要查找替换的候选项,我们需要查找计数器值最大的条目,一定是最早的区块。不幸的是,FIFO方案并不严格符合时间局部性原则。它会惩罚缓存中长期存在的块,而实际上,这些块也可能是非常频繁访问的块,不应该首先被逐出。
现在考虑实施FIFO替换策略的实际方面。计数器的最大大小需要等于集合中元素的数量,即缓存的关联性。例如,如果缓存的关联性为8,则需要有一个3位计数器,需要替换的条目应具有最大的计数器值。
请注意,在这种情况下,将新值引入缓存的过程相当昂贵,我们需要增加集合中除一个元素外的所有元素的计数器。然而,与缓存命中相比,缓存未命中更为罕见。因此,开销实践上并不显著,并且该方案可以在没有较大性能开销的情况下实现。
- LRU替换策略。LRU(最近最少使用的)更换策略被认为是最有效的方案之一。LRU方案直接从堆栈距离的定义开始,理想情况下,我们希望替换将来被访问的机会最低的块,根据堆栈距离的概念,未来被访问的概率与最近访问的概率有关。如果处理器在n次(n不是很大的数目)访问的最后一个窗口中频繁地访问一个块,那么该块很有可能在不久的将来被访问。然而,如果上一次访问一个块是很久以前的事了,那么它很快就不太可能被访问了。
在LRU更换策略中,我们保留块最后一次访问的时间,选择在最早的时间点最后访问的块作为替换的候选。此策略为每个块维护一个时间戳,每当访问一个块时,它的时间戳都会被更新以匹配当前时间。要查找合适的替换候选项,需要查找集合中时间戳最小的条目。
实现LRU策略最大的问题是需要为对缓存的每次读写访问做额外的工作,对性能产生重大影响,因为通常三分之一的指令是内存访问。其次,需要专用比特来保存足够大的时间戳,否则需要频繁地重置集合中每个块的时间戳,此过程导致缓存控制器的进一步减速和额外复杂性。因此,实现尽可能接近理想的LRU方案且没有显著的开销是一项艰巨的任务。
可以尝试设计使用小时间戳(通常为1-3位)并大致遵循LRU策略的LRU方案,称为伪LRU(pseudo-LRU)方案。下面概述实现基本伪LRU的简单方法。与其尝试显式标记最近最少使用的元素,不如尝试标记最近使用的元素。未标记的元素将自动分类为最近最少使用的元素。
让我们从将计数器与标记数组中的每个块相关联开始。每当访问一个块(读/写)时,都递增计数器,一旦计数器达到最大值,就停止递增。例如,如果使用一个2位计数器,那么避免将计数器递增到3以上。为了实现与每个块关联的计数器将最终达到3并保持值,可以周期性地将集合中每个块的计数器递减1,甚至可以将它们重置为0。随后,一些计数器将再次开始增加。
此举确保大多数情况下,可以通过查看计数器的值来识别最近最少使用的块。与计数器的最低值相关联的块是最近最少使用的块之一,最有可能、最近最少使用的块。请注意,这种方法确实涉及每次访问的一定活动量,但是递增一个小计数器几乎没有额外开销。其次,它在计时方面不在关键路径上,可以并行执行,也可以稍后执行。寻找替换的候选项包括查看一组中的所有计数器,并查找计数器值最低的块。用新块替换块后,大多数处理器通常会将新块的计数器设置为最大可能值。这向高速缓存控制器指示,相对于替换的候选,新块应该具有最低优先级。
2.7 逐出操作
如果缓存遵循直写策略,则无需执行任何操作,该块可以简单地丢弃。然而,如果缓存遵循写回策略,那么需要查看修改后的位。如果数据没有被修改,那么它可以被无缝地收回。但是,如果数据已被修改,则需要将其写回较低级别的缓存。
2.8 综合操作
缓存读取操作的步骤序列如下图所示,从查找操作开始,可以在查找和数据读取操作之间有部分重叠。如果有缓存命中,则缓存将值返回给处理器或更高级别的缓存(无论是哪种情况)。但是,如果缓存未命中,则需要取消数据读取操作,并向较低级别的缓存发送请求。较低级别的缓存将执行相同的访问序列,并返回整个缓存块(不仅仅是4个字节)。然后,高速缓存控制器可以从块中提取所请求的数据,并将其发送到处理器,同时缓存控制器调用插入操作将块插入缓存。如果集合中有一个无效的条目,那么可以用给定的块替换它,但如果集合中的所有方法都有效,则需要调用替换操作来查找替换的候选。该图为该操作附加了一个问号,因为该操作并非一直被调用(仅当集合的所有路径都包含有效数据时)。然后,需要收回块,如果修改了行,可能会将其写入较低级别的缓存,并且正在使用写回缓存。然后缓存控制器调用插入操作,这次肯定会成功。
读取操作。
下图显示了回写缓存的缓存写入操作的操作序列,操作顺序大致类似于缓存读取。如果缓存命中,将调用数据写入操作,并将修改后的位设置为1,否则将向较低级别的缓存发出块的读取请求。块到达后,大多数缓存控制器通常将其存储在一个小的临时缓冲区中,此时将4个字节写入缓冲区,然后返回。在某些处理器中,缓存控制器可能会等待所有子操作完成。写入临时缓冲区后(上图中的写入块操作),调用插入操作来写入块的内容(修改后),如果此操作不成功(因为所有方法都有效),那么将遵循与读取操作相同的步骤顺序(替换、逐出和插入)。
写操作(回写缓存)。
下图显示了直写缓存的操作序列。第一个不同点是,即使请求在缓存中命中,也会将块写入较低级别。第二个不同点是,在将值写入临时缓冲区之后(在未命中之后),还将块的新内容写回较低级别的缓存。其余步骤类似于为回写缓存所遵循的步骤序列。
写入操作(直写缓存)。
3 内存系统机制
我们已经对缓存的工作及其所有组成操作有了一个合理的理解,使用缓存层次结构构建内存系统。内存系统作为一个整体支持两种基本操作:读取和写入,或者加载和存储。
有两个最高级别的缓存:数据缓存(也称为一级缓存)和指令缓存(也称I缓存)。几乎所有时候,它们都包含不同的内存位置集。用于访问I缓存和L1缓存的协议相同,为了避免重复,后面只关注一级缓存。我们只需要记住,对指令缓存的访问遵循相同的步骤顺序。
处理器通过访问一级缓存启动。如果存在L1命中,则它通常在1-2个周期内接收该值。否则,请求需要转到二级缓存,或者甚至更低级别的缓存,如主内存。在这种情况下,请求可能需要数十或数百个周期。本节将从整体上看缓存系统,并将它们视为一个称为内存系统的黑盒子。
如果考虑包含性缓存,那么内存系统的总大小等于主内存的大小。例如,如果一个系统有1 GB的主内存,那么内存系统的大小等于1 GB。内存系统内部可能有一个用于提高性能的缓存层次结构,但不会增加总存储容量,因为只包含主内存中包含的数据子集。此外,处理器的存储器访问逻辑还将整个存储器系统视为单个单元,概念上被建模为一个大的字节阵列。这也称为物理内存系统或物理地址空间。
物理地址空间包括高速缓存和主内存中包含的所有存储器位置的集合。
3.1 内存系统的数学模型
性能
内存系统可以被认为是一个只服务于读写请求的黑盒子,请求所用的时间是可变的,取决于请求到达的内存系统的级别。管线在内存访问(MA)阶段连接到内存系统,并向其发出请求。如果回复不在一个周期内,那么需要在5阶段顺序流水线中引入额外的气泡。
设平均内存访问时间为AMAT(以周期测量),加载/存储指令的分数为,那么CPI可以表示为:
是假定完美的存储器系统对所有访问具有1个周期延迟的CPI。请注意,在5阶段顺序流水线中,理想的指令吞吐量是每个周期1条指令,内存级分配1个周期。在实践中,如果一次内存访问需要n个周期,那么我们有n-1个暂停周期,它们需要上述公式来解释。在此公式中,我们隐式地假设每次内存访问都会经历AMAT-1个周期的暂停。实际上,情况并非如此,因为大多数指令将在一级缓存中命中,并且一级缓存通常具有1个周期的延迟。因此,命中一级缓存的访问不会停止。但是,L1和L2缓存中的访问失败会导致长的暂停周期。
尽管如此,上述公式仍然成立,因为我们只对大量指令的平均CPI感兴趣,可以通过考虑大量指令,对所有内存暂停周期求和,并计算每条指令的平均周期数来推导出这个方程。
平均内存访问时间
在上述公式中,由程序的性质和管线的其他阶段(MA除外)的性质决定,也是处理器上运行的程序的固有属性。我们需要一个公式来计算AMAT,可以用类似于上面公式的方法计算它。假设一个具有L1和L2缓存的内存系统,有:
所有内存访问都需要访问L1缓存,而不管命中还是未命中,因此它们需要产生等于L1命中时间的延迟。一部分访问()将在L1缓存中丢失,并移动到L2缓存。此外,无论命中还是未命中,都需要产生周期的延迟。如果L2缓存中有一部分访问()未命中,则需要继续访问主存储器。我们假设所有的访问都发生在主内存中。因此,惩罚等于主存储器访问时间。
假设有一个n级存储器系统,其中第一级是L1缓存,最后一级是主存储器,那么可以使用类似的公式:
需要注意的是,这些等式中针对某一级别i使用的未命中率等于该级别未命中的访问数除以该级别的访问总数,被称为局部未命中率(local miss rate)。相比之下,我们可以定义第i级的全局未命中率(global miss rate),它等于第i级未命中数除以内存访问总数。
局部未命中率(local miss rate):它等于第i级缓存中的未命中数除以第i级的访问总数。
全局未命中率(global miss rate):它等于i级缓存中的未命中数除以内存访问总数。
可以通过降低未命中率、未命中惩罚或减少命中时间来提高系统的性能。后面先看看未命中率。
3.2 缓存未命中
缓存未命中分类
让我们首先尝试对缓存中不同类型的未命中进行分类。
- 强制未命中或冷未命中。当数据第一次加载到缓存中时,由于数据值不在缓存中,必然会发生此类未命中。
- 容量未命中。当一个程序所需的内存量大于缓存大小时,会发生容量未命中。例如,假设一个程序重复访问数组的所有元素,数组的大小等于1 MB,二级缓存的大小为512 KB。在这种情况下,二级缓存中会有容量未命中,因为它太小,无法容纳所有数据。
- 冲突未命中。程序在一个典型的时间间隔内访问的一组块称为其工作集,也可以说,当缓存的大小小于程序的工作集时,会发生冲突未命中。请注意,工作集的定义有点不精确,因为间隔的长度是主观考虑的。然而,时间间隔的内涵是,与程序执行的总时间相比,它是一个很小的间隔。只要它足够大,以确保系统的行为达到稳定状态,此类未命中发生在直接映射和集关联缓存中。例如,考虑一个4路集合关联缓存,如果一个程序的工作集中有5个块映射到同一个集,必然会有缓存未命中,因为访问的块的数量大于可以作为集合一部分的最大条目数。
程序在短时间间隔内访问的内存位置包括程序在该时间点的工作集。
由此,可将未命中分为三类:强制、容量和冲突,也称为三“C”。
降低未命中率
为了维持高IPC,有必要降低缓存未命中率。我们需要采取不同的策略来减少不同类型的缓存未命中。
让我们从强制性失误开始。我们需要一种方法来预测未来将访问的块,并提前获取这些块。通常,利用空间局部性的方案是有效的预测因素。因此,增加块大小对于减少强制未命中的数量应该是有益的。然而,将块大小增加到超过某个限制也会产生负面后果。它减少了可以保存在缓存中的块的数量,其次,额外的好处可能是微不足道的。最后,从内存系统的较低级别读取和传输较大的块将需要更多的时间。因此,设计师避免过大的块尺寸。32-128字节之间的任何值都是合理的。
现代处理器通常具有复杂的预测器,这些预测器试图根据当前的访问模式预测将来可能访问的块的地址。他们随后从内存层次结构的较低级别获取预测的块,试图降低未命中率。例如,如果我们按顺序访问一个大数组的元素,那么可以根据访问模式预测未来的访问。有时我们访问数组中的元素,其中的索引相差固定值。例如,我们可能有一个访问数组中每四个元素的算法。在这种情况下,也可以分析模式并预测未来的访问,因为连续访问的地址相差相同的值。这种单元被称为硬件预取器。它存在于大多数现代处理器中,并使用复杂的算法来“预取”块,从而降低未命中率。请注意,硬件预取器不应非常激进。否则,它将倾向于从缓存中移出比它带来的更有用的数据。
硬件预取器(hardware prefetcher)是一个专用的硬件单元,它预测在不久将来的内存访问,并从内存系统的较低级别获取它们。
先阐述容量未命中。唯一有效的解决方案是增加缓存的大小,不幸的是,本文介绍的缓存设计要求缓存的大小等于2的幂(以字节为单位),使用一些高级技术可能会违反这一规则。然而,大体上,商业处理器中的大多数缓存的大小都是2的幂。因此,增加缓存的大小等于至少使其大小加倍,将缓存的大小加倍需要两倍的面积,使其速度减慢,并增加功耗。如果明智地使用预取,也会有所帮助。
减少冲突未命中数的经典解决方案是增加缓存的关联性,但也会增加缓存的延迟和功耗,设计者有必要仔细平衡集合关联缓存的额外命中率和额外延迟。有时,缓存中的一些集合中会出现冲突未命中,在这种情况下,可以用一个小型的全关联缓存,称为牺牲缓存(victim cache)和主缓存。从主缓存移位的任何块都可以写入牺牲缓存,缓存控制器需要首先检查主缓存,如果有未命中,则需要检查牺牲缓存,然后再继续进行下一级。因此,级别i的牺牲缓存可以过滤掉一些到达级别(i+1)的请求。
注意,与硬件技术一起,以“缓存友好”的方式编写程序是可行的,可以最大化时间和空间的局部性,编译器也可以优化给定内存系统的代码。其次,编译器可以插入预取代码,以便在实际使用之前将块预取到缓存中。
现在快速提及两条经验法则,这些规则被发现在经验上大致成立,并且在理论上并非完全正确。
第一个被称为平方根规则(Square Root Rule),它表示未命中率与缓存大小的平方根成正比:
哈特斯坦等人[Hartstein等人,2006]试图为该规则找到理论依据,并利用概率论的结果解释该规则的基础。根据他们的实验结果,得出了该规则的通用版本,该规则表示平方根规则中缓存大小的指数从-0.3到-0.7不等。
第二个规则被称为关联性规则(Associativity Rule),它表明将关联性加倍的效果几乎与将缓存大小与原始关联性加倍相同,例如64 KB 4路关联缓存的未命中率几乎与128 KB 2路关联缓存相同。
注意,关联性规则和平方根规则只是经验规则,并不完全成立,仅仅用作概念辅助工具,我们总是可以构造违反这些规则的示例。
减少命中时间和未命中不利
还可以通过减少命中时间和未命中不利(Miss Penalty)来减少平均内存访问时间。为了减少命中时间,需要使用小而简单的缓存,但也增加了未命中率。
现在讨论一下减少不利的方法。请注意,级别i处的未命中不利等于从级别(i+1)开始的存储器系统的存储器延迟。传统的减少命中时间和未命中率的方法总是可以用于在给定的水平上减少未命中不利,现在研究专门针对减少未命中不利的方法。首先看看一级缓存中的写入未命中。在这种情况下,必须将整个块从L2高速缓存带入高速缓存,需要时间(>10个周期),其次,除非写入完成,否则管线无法恢复。处理器设计人员使用一个称为写缓冲区的小集合关联缓存,如下图所示。处理器可以将值写入写缓冲区,然后恢复,或者,只有在L1缓存中未命中时(假设),它才可以写入写缓冲。任何后续读取都需要在访问一级缓存的同时检查写入缓冲区,该结构通常非常小且快速(4-8个条目)。
一旦数据到达一级缓存,就可以从写缓冲器中删除相应的条目。注意,如果写缓冲区中没有可用的空闲条目,则管线需要暂停。其次,在从较低级别的高速缓存服务写入未命中之前,可能存在对同一地址的另一次写入,可以通过写入写入缓冲区中给定地址的分配条目来无缝处理。
现在看看读取未命中。处理器通常只对每次内存访问最多4个字节感兴趣,如果提供了这些关键的4字节,管线可以恢复。然而,在操作完成之前,内存系统需要填充整个块,块的大小通常在32-128字节之间。因此,如果内存系统知道处理器所需的确切字节集,则可以在此引入优化。在这种情况下,内存系统可以首先获取所需的内存字(4字节),随后或者并行地获取块的其余部分。这种优化被称为关键词优先(critical word first)。然后,可以将这些数据快速发送到管线,以便恢复其操作。这种优化被称为提前重启(early restart)。实现这两种优化增加了内存系统的复杂性。然而,关键词语优先和提前重启在减少未命中不利方面相当有效。
内存系统优化技术概述
下表总结了我们为优化存储系统而引入的不同技术。请注意,每种技术都有一些负面影响,如果一种技术在一个方面改进了内存系统,那么在另一个方面是有害的。例如,通过增加缓存大小,我们可以减少容量未命中的数量,但也增加了面积、延迟和功率。
技术 |
应用 |
劣势 |
大块尺寸 |
强制未命中 |
减少缓存的块数 |
预获取 |
强制未命中、容量未命中 |
额外的复杂性和从缓存中替换有用数据的风险 |
大缓存大小 |
容量未命中 |
高延迟、高功率、更大面积 |
关联性增强 |
冲突未命中 |
高延迟、高功率 |
牺牲缓存 |
冲突未命中 |
额外复杂性 |
基于编译器的技术 |
所有类型的未命中 |
不是很通用 |
小而简单的缓存 |
命中时间 |
高未命中率 |
写入缓冲器 |
未命中不利 |
额外复杂性 |
关键词优先 |
未命中不利 |
额外的复杂性和状态 |
提前重启 |
未命中不利 |
额外复杂性 |
总而言之,必须非常仔细地设计内存系统。目标工作量的要求必须与设计师设置的限制和制造技术的限制仔细平衡,需要最大化性能,同时注意功率、面积和复杂性限制。
4 虚拟内存
一个处理器可以通过在不同的程序之间快速切换来运行多个程序。例如,当用户玩游戏时,他的处理器可能正在接收电子邮件,之所以感觉不到任何中断,是因为处理器在程序之间来回切换的时间尺度(通常为几毫秒)比人类所能感知的要小得多。
到目前为止,我们假设程序所需的所有数据都驻留在主内存中,这种假设是不正确的。在过去,主内存的大小曾经是几兆字节,而用户可以运行需要数百兆字节数据的非常大的程序。即使现在,也可以处理比主内存量大得多的数据。用户可以通过编写一个C程序来轻松验证这一语句,该程序创建的数据结构大于机器中包含的物理内存量。在大多数系统中,此C程序将成功编译并运行。
本节通过对内存系统进行少量更改,可以满足以上要求。阅读本节需要一定的操作系统知识(如进程、线程、内存等),可参阅剖析虚幻渲染体系(18)- 操作系统。
4.1 内存的虚拟视图
因为多个进程在同一时间点处于活动状态,有必要在进程之间划分内存,如果不这样做,那么进程可能最终会修改彼此的值,同时也不希望程序员或编译器知道多个进程的存在。否则,会引入不必要的复杂性,其次,如果给定的程序是用某个内存映射编译的,那么它可能不会在另一台具有重叠内存映射的进程的机器上运行,更糟糕的是,不可能运行同一程序的两个副本。因此,每个程序都必须看到内存的虚拟视图,在该视图中,它假定自己拥有整个内存系统。
这两个要求出现了相互的矛盾——内存系统和操作系统希望不同的进程访问不同的内存地址,而程序员和编译器不希望知道这一要求。此外,程序员希望根据自己的意愿布局内存映射。事实证明,有一种方法可以让程序员和操作系统都感到满意。
我们需要定义内存的虚拟和物理视图。在内存的物理视图中,不同的进程在内存空间的非重叠区域中操作。然而,在虚拟视图中,每个进程都访问它希望访问的任何地址,并且不同进程的虚拟视图可以重叠。解决方案是分页。内存的虚拟视图也称为虚拟内存,它被定义为一个假设的内存系统,其中一个进程假定它拥有整个内存空间,并且没有任何其它进程的干扰。
虚拟内存系统被定义为一个假设的内存系统,其中一个进程假定它拥有整个内存空间,并且没有任何其他进程的干扰。内存的大小与系统的总可寻址内存一样大。例如,在32位系统中,虚拟内存的大小为232232字节(4 GB)。虚拟内存中所有内存位置的集合称为虚拟地址空间。
下图显示了32位Linux操作系统中进程的内存映射的简化视图。让我们从底部(最低地址)开始。第一段包含标头,从进程、格式和目标机器的详细信息开始。标头包含内存映射中每段的详细信息,例如,它包含包含程序代码的文本部分的详细信息,包括其大小、起始地址和其他属性。文本段从标头之后开始,加载程序时,操作系统将程序计数器设置为文本段的开始。程序中的所有指令通常都包含在文本段中。
Linux操作系统中进程的内存映射(32位)。
文本段后面是另外两个段,用于包含静态变量和全局变量。可选地,一些操作系统也有一个额外的区域来包含只读数据,如常量。文本段之后通常是数据部分,包含所有由程序员初始化的静态/全局变量。让我们考虑以下形式的声明(在C或C++中):
static int val = 5;
与变量val对应的4个字节保存在数据段中。
数据段后面是bss段,bss段保存程序员未明确初始化的静态变量和全局变量。大多数操作系统,所有与bss段对应的内存区域都为零。为了安全起见,必须这样做。让我们假设程序A运行并将其值写入bss段,随后程序B运行。在写入bss段中的变量之前,B总是可以尝试读取其值。在这种情况下,它将获得程序A写入的值,但这不是理想的行为,程序A可能在bss段保存了一些敏感数据,例如密码或信用卡号。因此,程序B可以在程序A不知情的情况下访问这些敏感数据,并可能滥用这些数据。因此,有必要用零填充bss段,这样就不会发生此类安全错误。
bss段后面是一个称为堆的内存区域,堆区域用于在程序中保存动态分配的变量。C程序通常使用malloc调用分配新数据,Java和C++使用new运算符。让我们看看一些例子:
int *intarray = (int*) malloc(10 * sizeof(int)); // [C]
int *intarray = new int[10]; // [C++]
int[] intarray = new int[10]; // [Java]
请注意,在这些语言中,动态分配数组非常有用,因为它们的大小在编译时是未知的。堆中有数据的另一个优点是它们可以跨函数调用生存。堆栈中的数据仅在函数调用期间保持有效,随后被删除。然而,堆中的数据会在程序的整个生命周期中保留,它可以由程序中的所有函数使用,指向堆中不同数据结构的指针可以跨函数共享。请注意,堆向上增长(朝向更高的地址)。其次,在堆中管理内存是一项相当困难的任务,因为在高级语言中,堆的区域动态地被分配了malloc/new调用,并被释放了free/delete调用。一旦释放了分配的内存区域,就会在内存映射中形成一个孔洞。如果孔的大小小于孔的大小,则可以在孔中分配一些其他数据结构。在这种情况下,将在内存映射中创建另一个较小的孔。随着时间的推移,随着越来越多的数据结构被分配和取消分配,孔洞的数量往往会增加,这就是所谓的碎片化。因此,有必要拥有一个高效的内存管理器,以减少堆中的孔数。下图显示了带有孔和分配内存的堆的视图。
堆的内存映射。
下一段用于存储与内存映射的文件和动态链接的库相对应的数据。大多数情况下,操作系统将文件的内容(如音乐、文本或视频文件)传输到内存区域,并将文件的属性视为常规数组,该存内存区域被称为内存映射文件。其次,程序可能偶尔会动态读取其他程序(称为库)的内容,并将其文本段的内容传输到内存映射中,这种库称为动态链接库(dll)。这种内存映射结构的内容存储在进程的内存映射中的专用段中。
下一段是堆栈,它从内存映射的顶部开始向下增长(朝向更小的地址),堆栈根据程序的行为不断增长和收缩。请注意,上上图未按比例绘制。如果我们考虑32位内存系统,那么虚拟内存的总量是4 GB,但程序可能使用的内存总量通常限制在数百兆字节。因此,在堆的开始部分和堆栈部分之间的映射中有一个巨大的空区域。
请注意,操作系统需要非常频繁地运行,需要为设备请求提供服务,并执行进程管理。从一个进程到另一个进程更改内存的虚拟视图稍微有些昂贵,因此,大多数操作系统在用户进程和内核之间划分虚拟内存,例如,Linux为用户进程提供了较低的3GB,为内核保留了较高的1GB。类似地,Windows为内核保留较高的2GB,为用户进程保留较低的2GB。当处理器从用户进程转换到内核时,不需要更改内存视图。其次,这种小的修改不会极大地降低程序的性能,因为2GB或3GB远远超过程序的典型内存占用量。此外,这个技巧也与虚拟内存概念不冲突,一个程序只需要假设它的内存空间减少了(在Linux的情况下,从4GB减少到3GB),参考下图。
Linux和Windows的用户、内核的内存映射。
4.2 重叠和尺寸问题
我们需要解决两个虚拟内存的问题:
- 重叠问题。程序员和编译器编写程序时假定他们拥有整个内存空间,并且可以随意写入任何位置。不幸的是,所有同时处于活动状态的进程都做出了相同的假设。除非采取措施,否则它们可能会在不经意间写入对方的内存空间并破坏对方的数据。事实上,考虑到它们使用相同的内存映射,在粗略的系统中发生这种情况的可能性非常高,硬件需要确保不同的进程相互隔离。这就是重叠问题。
- 尺寸问题。有时我们需要运行需要比可用物理内存更多内存的进程。如果其他存储介质(例如硬盘)中的一些空间可以重新用于存储进程的内存占用,便是理想的,这就是所谓的尺寸问题。
任何虚拟内存的实现都需要有效地解决尺寸和重叠问题。
4.3 分页
本小节涉及到的概念解析如下:
- 虚拟地址:程序在虚拟地址空间中指定的地址。
- 物理地址:地址转换后呈现给内存系统的地址。
- 页(page):是虚拟地址空间中的一块内存。
- 帧(frame):是物理地址空间中的一块内存,页和帧大小相同。
- 页表(page table):是一个映射表,将每个页面的地址映射到帧的地址。每个进程都有自己的页表。
为了平衡处理器、操作系统、编译器和程序员的需求,需要设计一个转译系统,将进程生成的地址转译成内存系统可以使用的地址。通过使用转译器,可以满足需要虚拟内存的程序员/编译器和需要物理内存的处理器/内存系统的需求。转译系统类似于现实生活中的翻译人员,例如,如果我们有一个俄罗斯代表团访问迪拜,那么我们需要一个能将俄语翻译成阿拉伯语的翻译。然后双方都可以说自己的语言,从而感到高兴。转译系统的概念图如下图所示。
考虑一个32位内存地址,现在可以把它分成两部分。如果考虑一个4KB的页面,那么低12位指定页面中字节的地址(原因:212212=4096=4KB),称为偏移,高20位指定页码(下图)。同样,可以将物理地址分为两部分:帧号和偏移。下图的转换过程首先将20位页码替换为等效的20位帧号,然后将12位偏移量附加到物理帧号。
将虚拟地址转换为物理地址。
在实现的方案中,按照页表的层级,有1级和2级页表,它们的示意图如下:
上:1级页表;下:2级页表。
一些处理器,如英特尔安腾和PowerPC 603,对页表使用不同的设计。它们不是使用页码来寻址页表,而是使用帧号来寻址页。在这种情况下,整个系统只有一个页面表。由于一个帧通常被唯一地映射到进程中的一个页面,所以这个反向页面表中的每个条目都包含进程id和页码。下图(a)显示了反转页表的结构,其主要优点是不需要为每个进程保留单独的页表。如果有很多进程,并且物理内存的大小很小,可以节省空间。
反转页表。
反向页表的主要困难在于查找虚拟地址。扫描所有条目是一个非常缓慢的过程,因此不实用。因此,需要一个哈希函数,将(进程id,页码)对映射到哈希表中的索引,哈希表中的这个索引需要指向反向页表中的一个条目。由于多个虚拟地址可以指向哈希表中的同一条目,因此有必要验证(进程id、页码)与存储在反向页表中的条目中的匹配。
上图(b)中展示了一种使用反向页表的方案。在计算页码和进程id对的哈希之后,访问一个由哈希内容索引的哈希表,哈希表条目的内容指向可能映射到给定页面的帧f。然而,我们需要验证,因为哈希函数可能将多个页面映射到同一帧。随后访问反向页表,并访问条目f。反向页表的一个条目包含映射到给定条目(或给定帧)的页码、进程id对。如果发现内容不匹配,就继续在随后的K个条目中搜索页码、进程id对。这种方法被称为线性探测(linear probing),在目标数据结构中不断搜索,直到找到匹配。如果没有在K个条目中找到匹配项,就可能会得出页面没有映射的结论。然后,需要创建一个映射,方法是逐出一个条目(类似于缓存),并将其写入主内存中的一个专用区域,该区域存储从反向页表中逐出的所有条目,需要始终确保哈希表指向的条目和包含映射的实际条目之间的差异不超过K个条目。如果没有找到任何空闲插槽,那么就需要收回一个条目。
有人可能认为,可以直接使用哈希引擎的输出来访问反向页表。通常,我们添加访问哈希表作为中间步骤,因为它允许更好地控制实际使用的帧集。使用此过程,可以禁止某些帧的映射,这些帧可用于其他目的。最后需要注意的是,维护和更新哈希表的开销大于拥有全系统页面表的收益。因此,反转页表通常不用于商业系统。
虚拟内存还涉及TLB、空间替换、MMU、页面错误等概念和机制,这些可在虚拟内存中寻得支持,本文不再累述。
地址转译过程。
4.4 分页系统高级功能
事实证明,我们可以用页表机制做一些有趣的事情,下面看看几个例子。
- 共享内存
假设两个进程希望在彼此之间共享一些内存,以便它们交换数据,每个进程都需要让内核知道这一点。内核可以将两个虚拟地址空间中的两个页面映射到同一帧,之后,每个进程都可以在自己的虚拟地址空间中写入页面,神奇的是,数据将反映在另一个进程的虚拟地址中。有时需要几个进程相互通信,共享内存机制是最快的方法之一。
- 保护
计算机病毒通常会更改正在运行的进程的代码,以便它们可以执行自己的代码,通常通过向程序提供一个特定的错误输入序列来实现。如果没有进行适当的检查,那么程序中特定变量的值将被覆盖。一些变量可以更改为指向文本段的指针,并且可以利用此机制更改文本部分段中的指令。可以通过将文本段中的所有页面标记为只读来解决此问题,无法在运行时修改它们的内容。
- 分段
我们一直假设程序员可以根据自己的意愿自由布局内存映射,例如,程序员可能决定在非常高的地址(例如0xFFFFFFF8)启动堆栈,然而,即使程序的内存占用非常小,该代码也可能无法在使用16位地址的机器上运行。其次,某个系统可能保留了虚拟内存的某些段,并使其无法用于进程。例如,操作系统通常为内核保留较高的1或2 GB。为了解决这些问题,我们需要在虚拟内存之上创建另一个虚拟层。
在分段内存(用于x86系统)中,有用于文本、数据和堆栈段的特定段寄存器。每个虚拟地址指定为特定段寄存器的偏移量。默认情况下,指令使用代码段寄存器,数据使用数据段寄存器。管线的内存访问(MA)阶段将偏移量添加到存储在段寄存器中的值以生成虚拟地址。随后,MMU使用该虚拟地址来生成物理地址。