What Every Programmer Should Know About Memory (1)

本文翻译自 What Every Programmer Should Know About Memory的第1,2,3章

随着CPU内核变得更快、核数更多,目前或者说将来一段时间内大多数程序员的限制因素是内存访问。硬件设计人员想出了很多更复杂的内存处理和加速技术–例如CPU缓存。但是,如果没有程序员的帮助,这些技术没法以最佳方式生效。不幸的是,不论对计算机内存子系统或CPU缓存的结构,或是使用它们的花销,都没有被大多数程序员很好理解。本文解释了现代商品硬件上使用的内存子系统的结构,说明了为什么开发 CPU 缓存,它们如何工作,以及程序员应该做什么才能通过利用它们来实现最佳性能。

引言

早期的计算机更为简单,系统的各种组件例如例如CPU,内存,大容量存储器和网口,由于被一同开发出来因而有非常平衡的表现。例如,内存和网口并不比CPU在提供数据的时候更(特别的)快。

曾今计算机稳定的基本结构悄然改变,硬件开发人员开始致力于优化单个子系统。于是电脑一些组件的性能大大的落后因而成为了瓶颈。由于开销的原因,大容量存储器和内存子系统相对于其他组件来说改善得更为缓慢

大容量存储的性能问题往往靠软件来改善: 操作系统将常用(且最有可能被用)的数据放在主存中,因为后者的速度要快上几个数量级。或者将缓存加入存储设备中,这样就可以在不修改操作系统的前提下提升性能。(然而,为了在使用缓存时保证数据的完整性,仍然要作出一些修改。) 这些内容不在本文的谈论范围之内,就不作赘述了。

解决内存的瓶颈更为困难,它与大容量存储不同,几乎每种方案都需要对硬件作出修改。目前,这些变更主要有以下这些方式:

  • RAM的硬件设计(速度与并发度)
  • 内存控制器的设计
  • CPU缓存
  • 设备的直接内存访问(DMA)

本文主要关心的是CPU缓存和内存控制器的设计。在讨论这些主题的过程中,我们还会研究DMA。不过,我们首先会从当今商用硬件的设计谈起。这有助于我们理解目前在使用内存子系统时可能遇到的问题和限制。我们还会详细介绍RAM的分类,说明为什么会存在这么多不同类型的内存。

本文绝非最终且完整的。我们的讨论范围仅止于商用硬件,而且只限于其中的一小部分。另外,本文中的许多论题,我们只会点到为止,以达到本文目标为标准。对于这些论题,大家可以阅读其它文档,获得更详细的说明。

当本文提到操作系统特定的细节和解决方案时,针对的都是Linux。无论何时都不会包含别的操作系统的任何信息,作者无意讨论其他操作系统的情况。如果读者认为他/她不得不使用别的操作系统,那么必须去要求供应商提供其操作系统类似于本文的文档。

在开始之前最后的一点说明,本文包含大量出现的术语“经常”和别的类似的限定词。这里讨论的技术在现实中存在于很多不同的实现,所以本文只阐述使用得最广泛最主流的版本。在阐述中很少有地方能用到绝对的限定词。

文档结构

这个文档主要视为软件开发者而写的。本文不会涉及太多硬件细节,所以喜欢硬件的读者也许不会觉得有用。但是在我们讨论一些有用的细节之前,我们先要描述足够多的背景。

在这个基础上,本文的第二部分将描述RAM(随机寄存器)。懂得这个部分的内容很好,但是此部分的内容并不是懂得其后内容必须部分。我们会在之后引用不少之前的部分,所以心急的读者可以跳过本节的大部分内容。

第三部分会谈到不少关于CPU缓存行为模式的内容。我们会列出一些图表,这样你们不至于觉得太枯燥。第三部分对于理解整个文章非常重要。第四部分将简短的描述虚拟内存是怎么被实现的。这也是你们需要理解全文其他部分的必要基础。

第五部分会提到许多关于非均匀内存访问(NUMA)系统的细节。

第六部分是本文的中心部分。在这个部分里面,我们将回顾其他许多部分中的信息,并且我们将给阅读本文的程序员许多在各种情况下的编程建议。如果你真的很心急,那么你可以直接阅读第六部分,并且我们建议你在必要的时候回到之前的章节回顾一下必要的背景知识。

本文的第七部分将介绍一些能够帮助程序员更好的完成任务的工具。即便在彻底理解了某一项技术的情况下,距离彻底理解在非测试环境下的程序还是很遥远的。我们需要借助一些工具。

第八部分,我们将展望一些在未来我们可能认为好用的科技。

反馈问题

作者会不定期更新本文档。这些更新既包括伴随技术进步而来的更新也包含更改错误。非常欢迎有志于反馈问题的读者发送电子邮件。

致谢

我首先需要感谢Johnray Fuller尤其是Jonathan Corbet,感谢他们将作者的英语转化成为更为规范的形式。Markus Armbruster提供大量本文中对于问题和缩写有价值的建议。

关于本文

本文题目对David Goldberg的经典文献《What Every Computer Scientist Should Know About Floating-Point Arithmetic》表示致敬。Goldberg的论文虽然不普及,但是对于任何有志于严格编程的人都会是一个先决条件。

现代商业硬件

由于专业硬件正在逐渐淡出,理解商用硬件是很重要的。现今,水平扩展比垂直扩展更为常见,也就是说,用大量的小型,联网的商用电脑代替大型且超快的系统。原因是,快速且廉价的网络硬件已经崛起。虽然那些大型系统仍有一席之地,但已被商用硬件后来居上。2007年,Red Hat认为,未来构成数据中心的“积木”将会是拥有最多4个插槽的计算机,每个插槽插入一个四核CPU,这些CPU都是超线程的。「超线程使单个处理器核心能同时处理两个以上的任务,只需加入一点点额外硬件」。也就是说,这些数据中心中的标准系统拥有最多64个虚拟处理器。当然可以支持更大的系统,但人们认为4插槽、4核CPU是最佳配置,绝大多数的优化都针对这样的配置。

在不同商用计算机之间,也存在着巨大的差异。不过,我们关注在主要的差异上,可以涵盖到超过90%以上的硬件。需要注意的是,这些技术上的细节往往日新月异,变化极快,因此大家在阅读的时候也需要注意本文的写作时间。

这么多年来,个人计算机和小型服务器被标准化到了一个芯片组上,它由两部分组成: 北桥和南桥,见图2.1。

图2.1 北桥和南桥组成的结构
图2.1 北桥和南桥组成的结构

CPU通过一条通用总线(前端总线,FSB)连接到北桥。北桥主要包括内存控制器和其它一些组件,内存控制器决定了RAM芯片的类型。不同的类型,包括DRAM、Rambus和SDRAM等等,要求不同的内存控制器。

为了连通其它系统设备,北桥需要与南桥通信。南桥又叫I/O桥,通过多条不同总线与设备们通信。目前,比较重要的总线有PCI、PCI Express、SATA和USB总线,除此以外,南桥还支持PATA、IEEE 1394、串行口和并行口等。比较老的系统上有连接北桥的AGP槽。那是由于南北桥间缺乏高速连接而采取的措施。现在的PCI-E都是直接连到南桥的。

这种结构有一些需要注意的地方:

  • 从某个CPU到另一个CPU的数据需要走它与北桥通信的同一条总线。
  • 与RAM的通信需要经过北桥
  • RAM只有一个端口。「本文不会介绍多端口RAM,因为商用硬件不采用这种内存,至少程序员无法访问到。这种内存一般在路由器等专用硬件中采用」
  • CPU与南桥设备间的通信需要经过北桥

在上面这种设计中,瓶颈马上出现了。第一个瓶颈与设备对RAM的访问有关。早期,所有设备之间的通信都需要经过CPU,结果严重影响了整个系统的性能。为了解决这个问题,有些设备加入了直接内存访问(DMA)的能力。DMA允许设备在北桥的帮助下,无需CPU的干涉,直接读写RAM。到了今天,所有高性能的设备都可以使用DMA。虽然DMA大大降低了CPU的负担,却占用了北桥的带宽,与CPU形成了争用

第二个瓶颈来自北桥与RAM间的总线。总线的具体情况与内存的类型有关。在早期的系统上,只有一条总线,因此不能实现并行访问。近期的RAM需要两条独立总线(或者说通道,DDR2就是这么叫的,见图2.8),可以实现带宽加倍。北桥将内存访问交错地分配到两个通道上。更新的内存技术(如FB-DRAM)甚至加入了更多的通道。

由于带宽有限,我们需要以一种使延迟最小化的方式来对内存访问进行调度。我们将会看到,处理器的速度比内存要快得多,需要等待内存。如果有多个超线程核心或CPU同时访问内存,等待时间则会更长。对于DMA也是同样。

除了并发以外,访问模式也会极大地影响内存子系统、特别是多通道内存子系统的性能。关于访问模式,可参见2.2节。

在一些比较昂贵的系统上,北桥自己不含内存控制器,而是连接到外部的多个内存控制器上(在下例中,共有4个)。

图2.2 拥有外部控制器的北桥
图2.2 拥有外部控制器的北桥

这种架构的好处在于,多条内存总线的存在,使得总带宽也随之增加了。而且也可以支持更多的内存。通过同时访问不同内存区,还可以降低延时。对于像图2.2中这种多处理器直连北桥的设计来说,尤其有效。而这种架构的局限在于北桥的内部带宽,非常巨大(来自Intel)。「出于完整性的考虑,还需要补充一下,这样的内存控制器布局还可以用于其它用途,比如说「内存RAID」,它可以与热插拔技术一起使用。」

使用外部内存控制器并不是唯一的办法,另一个最近比较流行的方法是将控制器集成到CPU内部,将内存直连到每个CPU。这种架构的走红归功于基于AMD Opteron处理器的SMP系统。图2.3展示了这种架构。Intel则会从Nehalem处理器开始支持通用系统接口(CSI),基本上也是类似的思路——集成内存控制器,为每个处理器提供本地内存。

图2.3 集成的内存控制器
图2.3 集成的内存控制器

通过采用这样的架构,系统里有几个处理器,就可以有几个内存库(memory bank)。比如,在4 CPU的计算机上,不需要一个拥有巨大带宽的复杂北桥,就可以实现4倍的内存带宽。另外,将内存控制器集成到CPU内部还有其它一些优点,这里就不赘述了。

同样也有缺点。首先,系统仍然要让所有内存能被所有处理器所访问,导致内存不再是均匀的资源(NUMA即得名于此)。处理器能以正常的速度访问本地内存(连接到该处理器的内存)。但它访问其它处理器的内存时,却需要使用处理器之间的互联通道。比如说,CPU 1如果要访问CPU 2的内存,则需要使用它们之间的互联通道。如果它需要访问CPU 4的内存,那么需要跨越两条互联通道。

使用互联通道是有代价的。在讨论访问远端内存的代价时,我们用「NUMA因子」这个词。在图2.3中,每个CPU有两个层级: 相邻的CPU,以及两个互联通道外的CPU。在更加复杂的系统中,层级也更多。甚至有些机器有不止一种连接,比如说IBM的x445和SGI的Altix系列。CPU被归入节点,节点内的内存访问时间是一致的,或者只有很小的NUMA因子。而在节点之间的连接代价很大,而且有巨大的NUMA因子。

目前,已经有商用的NUMA计算机,而且它们在未来应该会扮演更加重要的角色。人们预计,从2008年底开始,每台SMP机器都会使用NUMA。每个在NUMA上运行的程序都应该认识到NUMA的代价。在第5节中,我们将讨论更多的架构,以及Linux内核为这些程序提供的一些技术。

除了本节中所介绍的技术之外,还有其它一些影响RAM性能的因素。它们无法被软件所左右,所以没有放在这里。如果大家有兴趣,可以在第2.1节中看一下。介绍这些技术,仅仅是因为它们能让我们绘制的RAM技术全图更为完整,或者是可能在大家购买计算机时能够提供一些帮助。

以下的两节主要介绍一些入门级的硬件知识,同时讨论内存控制器与DRAM芯片间的访问协议。这些知识解释了内存访问的原理,程序员可能会得到一些启发。不过,这部分并不是必读的,心急的读者可以直接跳到第2.2.5节。

RAM类型

这些年来,出现了许多不同类型的RAM,各有差异,有些甚至有非常巨大的不同。那些很古老的类型已经乏人问津,我们就不仔细研究了。我们主要专注于几类现代RAM,剖开它们的表面,研究一下内核和应用开发人员们可以看到的一些细节。

第一个有趣的细节是,为什么在同一台机器中有不同的RAM?或者说得更详细一点,为什么既有静态RAM「SRAM,Static RAM,根据不同的上下文,SRAM还可以表示为同步内存」,又有动态RAM「DRAM,Dynamic RAM」。它们功能相同吗,但是前者快得多。那么为什么不全部使用SRAM。答案正是你所预期的-成本。无论是在生产还是使用上,SRAM都比DRAM贵得多。这两个成本因素都很重要,且后者越来越重要。为了理解这一点,我们分别看一下SRAM和DRAM存储的实现方式。

在本节的余下部分,我们将讨论RAM实现的底层细节。我们将尽量控制细节的层面,比如,在「逻辑的层面」讨论信号,而不是硬件设计师那种层面,因为那毫无必要。

静态RAM

图2.4 6-T静态RAM
图2.4 6-T静态RAM

图2.4展示了6晶体管(transistor)SRAM的一个单元结构。核心是4个晶体管M1-M4,它们组成两个交叉耦合的反相器。它们有两个稳定的状态,分别代表0和1。只要保持Vdd有电,状态就是稳定的。

当需要访问单元的状态时,就提高字访问线路(word access line)WL的电位(开启内存写入)。BLBLBL\overline{BL} 上就可以读取状态。如果需要覆盖状态,先将 BLBLBL\overline{BL} 设置为期望的值,然后提高WL。由于外部的驱动强于内部的4个晶体管,所以旧状态会被覆盖。

更多详情,可以参考20。为了下文的讨论,需要注意以下问题:

  • 一个单元需要6个晶体管。也有采用4个晶体管的SRAM,但有缺陷。
  • 维持状态需要恒定的电源。
  • 提高WL后立即可以读取状态。信号与其它晶体管控制的信号一样,是直角(rectangular)的(快速在两个状态间变化)。
  • 状态稳定,不需要刷新循环。

SRAM也有其它形式,不那么费电,但比较慢。由于我们需要的是快速RAM,因此不在关注范围内。这些较慢的SRAM的主要优点在于接口简单,比动态RAM更容易使用。

动态RAM

动态RAM比静态RAM要简单得多。图2.5展示了一种普通DRAM的结构。它只含有一个晶体管和一个电容器。显然,这种复杂性上的巨大差异意味着功能上的迥异。

图2.5 1-T动态RAM
图2.5 1-T动态RAM

动态RAM的状态是保持在电容器C中。晶体管M用来控制访问。如果要读取状态,提高访问线路AL的电位,这时,可能会有电流流到数据线DL上,也可能没有,取决于电容器是否有电。如果要写入状态,先设置DL,然后提高AL的电位,直到电容器充电或放电完毕。

动态RAM的设计有几个复杂的地方。由于读取状态时需要对电容器放电,所以这一过程不能无限重复,不得不在某个点上对它重新充电。

更糟糕的是,为了容纳大量单元(注:现在一般在单个芯片上容纳10的9次方以上的RAM单元),电容器的容量必须很小(0.000000000000001法拉以下)。这样,完整充电后大约持有几万个电子。即使电容器的电阻很大(若干兆欧姆),仍然只需很短的时间就会耗光电荷,称为「泄漏」。

这种泄露就是现在的大部分DRAM芯片每隔64ms就必须进行一次刷新的原因。在刷新期间,对于该芯片的访问是不可能的,因为刷新基本上就是直接丢弃结果的读取操作,这甚至会造成半数任务的延宕。(相关内容请看3)

微小电量导致的第二个问题是无法直接读取芯片单元中的信息,而必须通过读出放大器将0和1两种信号间的电势差增大。

第三个问题是读取会导致电容器的电量耗尽。这意味着每次读取操作之后都必须进行一次给电容器充电的操作。这是通过将读出放大器的输出送回电容器来自动完成的。

第四个问题是电容器的充电和放电不是瞬时的。读出放大器接收到的信号不是矩形的,因此必须保守估计电池输出何时可用,所以放大器输出信号的时候就需要一个小小的延宕,相关公式如下:

QCharge(t)=Q0(1etRC)QDischarge(t)=Q0etRC\begin{aligned} Q_{\text{Charge}}(t) &= Q_{0}(1 - e^{-\frac{t}{RC}}) \\ Q_{\text{Discharge}}(t) &= Q_{0} e^{-\frac{t}{RC}} \end{aligned}

这就意味着需要一些时间(时间长短取决于电容C和电阻R)来对电容进行冲放电。另一个负面作用是,信号放大器的输出电流不能立即就作为信号载体使用。图2.6显示了冲放电的曲线,x轴表示的是单位时间下的RC(电阻乘上电量)。

图 2.6:电容充电与放电时间
图 2.6:电容充电与放电时间

与静态RAM可以即刻读取数据不同的是,当要读取动态RAM的时候,必须花一点时间来等待电容的冲放电完全。这一点点的时间最终限制了DRAM的速度。

当然了,这种读取方式也是有好处的。最大的好处在于缩小了规模。一个动态RAM的尺寸是小于静态RAM的。这种规模的减小不单单建立在动态RAM的简单结构之上,也是由于减少了静态RAM的各个单元独立的供电部分。以上也同时导致了动态RAM模具的简单化。

综上所述,由于不可思议的成本差异,除了一些特殊的硬件(包括网络路由器什么的)之外,我们的硬件大多是使用DRAM的。这一点深深的影响了咱们这些程序员,后文将会对此进行讨论。在此之前,我们还是先了解下DRAM的更多细节。

访问DRAM

当一个程序使用虚拟地址访问内存时,处理器将虚拟地址转换为物理地址,内存控制器根据此地址访问RAM芯片。为了访问RAM芯片中的独立的内存单元,部分物理地址以多条地址线(address lines)数的形式被访问。

但是从内存控制器中直接通过地址线访问内存位置是不切实际的: 对于4GB大小的RAM需要 2322^{32} 个地址线『 1GB近似为 2302^{30} 』.因此可以通过将地址编码成二进制数,来降低地址线集合的大小。地址线通过这种方式传递到DRAM芯片时,先要经过多路解码器。一个有着N个地址线的多路解码器,可以输出 2N2^N 个输出线,这些输出线可以用于内存单元的选择,对于小容量芯片使用这种直接的方式,并没有什么大问题。

但是如果有很多单元,这个方法就不再合适。一个1G的芯片容量(注:我反感那些SI前缀,对于我一个giga-bit将总是 2302^{30} 而不是 10910^9字节)将需要30条地址线和 2302^{30} 个选择线。为了不牺牲速度,一个多路解码器的大小随输入地址线数量指数增长。一个30地址线的多路解码器,需要一大堆芯片,此外还有复杂度(时间和空间的),更重要的是在地址线上同步传递30脉冲比”只“传递15脉冲要困难的多。较少列能精确布局相同长度或恰当的时机(注:现代DRAM类型如DDR3可以自动调整计时,但可以容忍的范围有限)。

图2.7:动态 RAM 示意图
图2.7:动态 RAM 示意图

图2.7从一个高层次展示了DRAM芯片,DRAM被组织成行和列。虽然它们可以都被放在一行,但是DRAM芯片将会需要一个巨大的多路解码器。通过阵列方法,可以通过一个一半尺寸的多路解码器和一个一半尺寸的多路复用器『多路器(multiplexer)和多路解码器是一样的,当写数据时候,multiplexer需要以多路解码器身份工作。那么从现在开始我们不再讨论其区别.』来实现设计。这从各个方面来说都是巨大的节约。在上图的例子中,地址线 a0a_0a1a_1 通过行地址多路解码器( RAS\overline{RAS} )来选择芯片一整行的地址线,当读取的时候,所有单元的内容都能被列地址多路选择器( CAS\overline{CAS} )『名字上面的线表示信号是反向的』获取。基于地址线 a2a_2a3a_3 ,其中一列的内容随后可用于DRAM芯片的数据引脚。这会在多个DRAM芯片上并行地发生多次,以产生对应数据总线宽度的总字节数。

注:上面这一段有点难理解,以图2.7 为例,芯片由4*4的单元组成。对外提供两条地址线,在选择单元时,先由两条外部地址线指定行,将结果暂存起来。然后再由同样的两条地址线指定列。这样就能够只用两条地址线来指定16个单元中的一个。

对于写入操作,内存单元的新数据值被放到数据总线,然后,当使用 RAS\overline{RAS} 和CAS方式选中内存单元时,数据被存放在内存单元中。这是一个相当直观的设计,在现实中-很显然会复杂的多,对于读,需要规范从发出信号到数据在数据总线上变得可读的时延。如上节所述,电容无法立刻自动放电,从内存单元发出的信号是如此微弱,以至于它需要被放大。对于写操作,必须规范从完成 RAS\overline{RAS}CAS\overline{CAS} 后到数据被成功写入内存单元的时延(当然,电容不会立刻自动充电和放电)。这些时间常量对DRAM芯片的性能是至关重要的,我们将在下章讨论它。

另一个关于可扩展性的问题是,用30根地址线连接到每一个RAM芯片是行不通的。芯片的针脚是非常珍贵的资源,数据必须能尽可能地并行传输(比如:64位为一组)已经很糟糕了。内存控制器必须有能力定位每一个RAM模块(RAM芯片集合)。如果因为性能的原因要求并发行访问多个RAM模块并且每个RAM模块需要自己独占的30或多个地址线,那么对于8个RAM模块,仅仅是解析地址,内存控制器就需要240+之多的针脚。

在很长一段时间里,地址线被复用以解决DRAM芯片的这些次要的可扩展性问题。这意味着地址被转换成两部分。第一部分由地址位a0和a1选择行(如图2.7)。这个选择保持有效直到撤销。然后是第二部分,地址位a2和a3选择列。关键差别在于:只需要两根外部地址线。还需要多几条线来指明 RAS\overline{RAS}CAS\overline{CAS} 信号何时有效,但这对于将地址线数量减半来说是一个很小的代价。不过,这种地址多路复用也带来了一系列问题。我们将在2.2节中讨论它们。

总结

如果这章节的内容有些难以应付,不用担心。纵观这章节的重点,有:

  1. 为什么不是所有的存储器都是SRAM的原因
  2. 存储单元需要单独选择来使用
  3. 地址线数目直接反映存储控制器,主板,DRAM模块和DRAM芯片的成本
  4. 在读或写操作结果有效之前需要占用一段时间

接下来的章节会涉及更多的有关访问DRAM存储器的实际操作的细节。我们不会深入更多有关访问SRAM的具体内容,它通常是直接寻址。这里是由于速度和有限的SRAM存储器的尺寸。SRAM现在应用在CPU的高速缓存和芯片,它们的连接件很小而且完全能在CPU设计师的掌控之下。我们以后会讨论到CPU高速缓存这个主题,但我们所需要知道的是SRAM存储单元是有确定的最大速度,这取决于花在SRAM上的努力。这速度与CPU核心相比略慢一到两个数量级。

DRAM 访问技术细节

在上文介绍DRAM时,我们已经看到DRAM芯片为了节约地址针脚资源,对地址进行了多路复用。而且,访问DRAM单元是需要一些时间的,因为电容器的放电并不是瞬时的。此外,我们还看到,DRAM需要不停地刷新。在这一节里,我们将把这些因素拼合起来,看看它们是如何决定DRAM的访问过程。

我们将主要关注在当前的科技上,不会再去讨论异步DRAM以及它的各种变体。如果对它感兴趣,可以去参考319。我们也不会讨论Rambus DRAM(RDRAM),虽然它并不过时,但在系统内存领域应用不广。我们将主要介绍同步DRAM(Synchronous DRAM,SDRAM)及其后继者双倍传输速度DRAM(Double Data Rate DRAM,DDR)。

同步DRAM,顾名思义,是参照一个时间源工作的。由内存控制器提供一个时钟,时钟的频率决定了前端总线(FSB)的速度。FSB是内存控制器提供给DRAM芯片的接口。在我写作本文的时候,FSB已经达到800MHz、1066MHz,甚至1333MHz,并且下一代的1600MHz也已经宣布。但这并不表示时钟频率有这么高。实际上,目前的总线都是双倍或四倍传输的,每个周期传输2次或4次数据。报的越高,卖的越好,所以这些厂商们喜欢把四倍传输的200MHz总线宣传为“有效的”800MHz总线。

以今天的SDRAM为例,每次数据传输包含64位,即8字节。所以FSB的传输速率应该是有效总线频率乘于8字节(对于4倍传输200MHz总线而言,传输速率为6.4GB/s)。听起来很高,但要知道这只是峰值速率,实际上无法达到的最高速率。我们将会看到,与RAM模块交流的协议有大量时间是处于非工作状态,不进行数据传输。我们必须对这些非工作时间有所了解,并尽量缩短它们,才能获得最佳的性能。

读访问协议

图2.8: SDRAM读访问的时序
图2.8: SDRAM读访问的时序

图2.8展示了某个DRAM模块一些连接器上的活动,可分为以不同颜色表示的三个阶段。按惯例,时间为从左向右流逝。这里忽略了许多细节,我们只关注时钟频率、 RAS\overline{RAS}CAS\overline{CAS} 信号、地址总线和数据总线。读周期开始于内存控制器将行地址放在地址总线上,并降低RAS信号。所有信号都在时钟(CLK)的上升沿读取,因此,只要信号在读取的时间点上保持稳定,就算不是标准的方波也没有关系。设置行地址会促使RAM芯片锁住指定的行。

CAS\overline{CAS} 信号在 tRCDt_{RCD} ( RAS\overline{RAS}CAS\overline{CAS} 时延)个时钟周期后发出。内存控制器在地址总线上提供列地址,并降低 CAS\overline{CAS} 的电位来传输列地址。这里我们可以看到,地址的两个组成部分是怎么通过同一条总线传输的。

现在寻址完成,数据可以传输了。RAM芯片需要一些时间来为此做准备。延迟通常称为 CAS\overline{CAS} 延迟(CL)。在图2.8中, CAS\overline{CAS} 延迟为2。延迟的值可大可小,这取决于内存控制器、电路板和DRAM模块的质量。延迟还可能是半周期。当CL=2.5时,第一个数据将在蓝色区域的第一个下降沿准备就绪。

既然数据的传输需要这么多的准备工作,仅仅传输一个字显然是太浪费了。因此,DRAM模块允许内存控制指定本次传输多少数据。可以是2、4或8个字。这样,就可以在没有新的 RAS/CAS\overline{RAS}/\overline{CAS} 序列的情况下填充高速缓存中的整行。另外,内存控制器还可以在不重置行选择的情况下发送新的CAS信号。这样,可以更快地读取或写入连续的内存地址,因为不需要发送RAS信号,也不需要把行置为非激活状态(见下文)。是否要将行保持为“打开”状态是内存控制器判断的事情。让它一直保持打开的话,对真正的应用会有不好的影响(参见3)。CAS信号的发送仅与RAM模块的命令速率(Command Rate)有关(常常记为 TxT_x ,其中x为1或2,高性能的DRAM模块一般为1,表示在每个周期都可以接收新命令)。

在上图中,SDRAM的每个周期输出一个字的数据。这是第一代的SDRAM。而DDR可以在一个周期中输出两个字。这种做法可以减少传输时间,但无法降低时延。DDR2尽管看上去不同,但在本质上也是相同的做法。对于DDR2,不需要再深入介绍了,我们只需要知道DDR2更快、更便宜、更可靠、更节能(参见6)就足够了。

预充电与激活

图2.8 不包括整个周期。它只显示访问DRAM的整个周期的一部分。在发送新的 RAS\overline{RAS} 信号之前,必须先把当前锁住的行置为非激活状态,并对新行进行预充电。在这里,我们主要讨论由于显式发送指令而触发以上行为的情况。协议有一些改进,在某些情况下,可以避免这个额外的步骤。不过,预充电引入的延迟仍然会影响操作。

图2.9: SDRAM的预充电与激活
图2.9: SDRAM的预充电与激活

图2.9显示从一个 CAS\overline{CAS} 信号开始到另一个的 CAS\overline{CAS} 信号的活动。第一个CAS信号请求的数据在CL周期后准备就绪。在示例中,在简单的SDRAM上,请求两个字需要两个周期来传输。如果换成DDR的话,则可以传输4个字。

即使是在一个命令速率为1的DRAM模块上,也无法立即发出预充电命令,而要等数据传输完成。在上图中,即为两个周期。刚好与CL相同,但只是巧合而已。预充电信号并没有专用线,实际上,某些实现是用同时降低写使能( WE\overline{WE} )线和 RAS\overline{RAS} 线的方式来触发。这一组合方式本身没有特殊的意义(参见18)。

发出预充电信命令后,还需等待 tRPt_{RP} (行预充电时间)个周期之后才能使行被选中。在图2.9中,这个时间(紫色部分)大部分与内存传输的时间(淡蓝色部分)重合。这很好!但 tRPt_{RP} 大于传输时间,因此下一个 RAS\overline{RAS} 信号只能等待一个周期。

如果我们补充完整上图中的时间线,最后会发现下一次数据传输发生在前一次停止之后的5个周期。这意味着,数据总线的7个周期中只有2个周期才是真正在用的。再用它乘于FSB速度,结果就是,800MHz总线的理论速率6.4GB/s降到了1.8GB/s。真是太糟了。第6节将介绍一些技术,可以帮助我们提高总线有效速率。程序员们也需要尽自己的努力。

SDRAM还有一些定时值,我们并没有谈到。在图2.9中,预充电命令仅受数据传输时间的限制。除此之外,SDRAM模块在 RAS\overline{RAS} 信号之后,需要经过一段时间,才能预充电另一行(记为 tRASt_{RAS} )。这个数字通常相当高,大约是 tRPt_{RP} 值的两三倍。如果在某个 RAS\overline{RAS} 信号之后,只有一个 CAS\overline{CAS} 信号跟随,并且数据传输在几个周期内完成,那么这就是一个问题。假设在图2.9中,第一个 CAS\overline{CAS} 信号是直接跟在一个 RAS\overline{RAS} 信号后面的,而 tRASt_{RAS} 为8个周期。那么预充电命令还需要被推迟一个周期,因为 tRCDt_{RCD} ,CL和 tRPt_{RP} 的总和只有7个周期。

DDR模块往往用w-z-y-z-T来表示。例如,2-3-2-8-T1,意思是:

标记例子含义
w2CAS时延(CL)
x3RAS-to-CAS时延( tRCDt_{RCD} )
y2RAS预充电时间( tRPt_{RP} )
z8激活到预充电时间( tRASt_{RAS} )
TT1命令速率

当然,除以上的参数外,还有许多其它参数影响命令的发送与处理。但以上5个参数已经足以确定模块的性能。

在解读计算机性能参数时,这些信息可能会派上用场。而在购买计算机时,了解这些细节肯定是有用的,因为它们与FSB/SDRAM速度一起,都是决定计算机速度的关键因素。

喜欢冒险的读者们还可以利用它们来调优系统。有些计算机的BIOS可以让你修改部分或全部这些参数。SDRAM模块有一些可编程寄存器,可供设置参数。通常,BIOS会选择最佳默认值。如果RAM模块的质量足够好,我们可以在保持系统稳定的前提下将以上某个时延参数减小。互联网上有大量超频网站提供了相关的文档。不过,请自担风险,不要说您没有被提醒。

重充电

谈到DRAM的访问时,重充电是常常被忽略的一个主题。在2.1.2中曾经介绍,DRAM必须保持不断刷新。对于系统的其余部分来说,这种情况并不完全透明。行在充电时是无法访问的。3 中的研究发现,“令人吃惊,DRAM刷新对性能有着巨大的影响”。

根据JEDEC规范,DRAM单元必须保持每64ms刷新一次。对于8192行的DRAM,这意味着内存控制器平均每7.8125µs就需要发出一个刷新命令(在实际情况下,由于刷新命令可以纳入队列,因此这个时间间隔可以更大一些)。刷新命令的调度由内存控制器负责。DRAM模块会记录上一次刷新行的地址,然后在下次刷新请求时自动对这个地址进行递增。

对于刷新及发出刷新命令的时间点,程序员无法施加影响。但我们在解读性能参数时有必要知道,它也是DRAM生命周期的一个部分。如果系统需要读取某个重要的字,而刚好它所在的行正在刷新,那么处理器将会被延迟很长一段时间。刷新的具体耗时取决于DRAM模块本身。

内存类型

我们有必要花一些时间来了解一下目前流行的内存,以及那些即将流行的内存。首先从SDR(单倍速)SDRAM开始,因为它们是DDR(双倍速)SDRAM的基础。SDR非常简单,内存单元和数据传输率是相等的。

图2.10: SDR SDRAM的操作
图2.10: SDR SDRAM的操作

在图2.10中,DRAM单元阵列能以等同于内存总线的速率输出内容。假设DRAM单元阵列工作在100MHz上,那么总线的数据传输率可以达到100Mb/s。所有组件的频率f保持相同。由于提高频率会导致耗电量增加,所以提高吞吐量需要付出很高的的代价。如果是很大规模的内存阵列,代价会非常巨大「 功率=动态电容×电压2×频率\text{功率} = \text{动态电容} \times \text{电压}^2 \times \text{频率} 」。

图2.11 DDR1 SDRAM的操作
图2.11 DDR1 SDRAM的操作

我们从图2.11上可以看出DDR1与SDR的不同之处,也可以从DDR1的名字里猜到那么几分,DDR1的每个周期可以传输两倍的数据,它的上升沿和下降沿都传输数据。有时又被称为“双倍频(double-pumped)”总线。为了在不提升频率的前提下实现双倍传输,DDR引入了一个缓冲区。缓冲区的每条数据线都持有两位。它要求内存单元阵列的数据总线包含两条线。实现的方式很简单,用同一个列地址同时访问两个DRAM单元。对单元阵列的修改也很小。

SDR DRAM是以频率来命名的(例如,对应于100MHz的称为PC100)。为了让DDR1听上去更好听,营销人员们不得不想了一种新的命名方案。这种新方案中含有DDR模块可支持的传输速率(DDR拥有64位总线):

100MHz×64bit×2=1600MB/s\text{100MHz} \times \text{64bit} \times \text{2} = \text{1600MB/s}

于是,100MHz频率的DDR模块就被称为PC1600。由于1600 > 100,营销方面的需求得到了满足,听起来非常棒,但实际上仅仅只是提升了两倍而已「我接受两倍这个事实,但不喜欢类似的数字膨胀戏法」。

图2.12: DDR2 SDRAM的操作
图2.12: DDR2 SDRAM的操作

为了更进一步,DDR2有了更多的创新。在图2.12中,最明显的变化是,总线的频率加倍了。频率的加倍意味着带宽的加倍。如果对单元阵列的频率加倍,显然是不经济的,因此DDR2要求I/O缓冲区在每个时钟周期读取4位。也就是说,DDR2的变化仅在于使I/O缓冲区运行在更高的速度上。这是可行的,而且耗电也不会显著增加。DDR2的命名与DDR1相仿,只是将因子2替换成4(四倍频总线)。图2.13显示了目前常用的一些模块的名称。

阵列频率总线频率数据速率名称(速率)名称(FSB)
133MHz266MHz4,256MB/sPC2-4200DDR2-533
166MHz333MHz5,312MB/sPC2-5300DDR2-667
200MHz400MHz6,400MB/sPC2-6400DDR2-800
250MHz500MHz8,000MB/sPC2-8000DDR2-1000
266MHz533MHz8,512MB/sPC2-8500DDR2-1066

在命名方面还有一个变扭的地方。FSB速度是用有效频率来标记的,即把上升、下降沿均传输数据的因素考虑进去,因此数字被撑大了。所以,拥有266MHz总线的133MHz模块有着533MHz的FSB“频率”。

DDR3要求更多的改变(这里指真正的DDR3,而不是图形卡中假冒的GDDR3)。电压从1.8V下降到1.5V。由于功耗是与电压的平方成正比,因此仅此一项就节约30%的电力。加上芯片尺寸的减小加上其他电气进步,DDR3可以在保持相同频率的情况下,降低一半的电力消耗。或者,在保持相同耗电的情况下,达到更高的频率。又或者,在保持相同热量排放的情况下,实现容量的翻番。

DDR3模块的单元阵列将运行在内部总线的四分之一速度上,DDR3的I/O缓冲区从DDR2的4位提升到8位。见图2.14。

图2.14: DDR3 SDRAM的操作
图2.14: DDR3 SDRAM的操作

一开始,DDR3可能会有较高的CAS时延,因为DDR2的技术相比之下更为成熟。由于这个原因,DDR3可能只会用于DDR2无法达到的高频率下,而且带宽比时延更重要的场景。此前,已经有讨论指出,1.3V的DDR3可以达到与DDR2相同的CAS时延。无论如何,更高速度带来的价值都会超过时延增加带来的影响。

DDR3可能会有一个问题,即在1600Mb/s或更高速率下,每个通道的模块数可能会限制为1。在早期版本中,这一要求是针对所有频率的。我们希望这个要求可以提高一些,否则系统容量将会受到严重的限制。

图2.15显示了我们预计中各DDR3模块的名称。JEDEC目前同意了前四种。由于Intel的45nm处理器是1600Mb/s的FSB,1866Mb/s可以用于超频市场。随着DDR3的发展,可能会有更多类型加入。

阵列频率总线频率数据速率名称(速率)名称(FSB)
100MHz400MHz6,400MB/sPC3-6400DDR3-800
133MHz533MHz8,512MB/sPC3-8500DDR3-1066
166MHz667MHz10,667MB/sPC3-10667DDR3-1333
200MHz800MHz12,800MB/sPC3-12800DDR3-1600
233MHz933MHz14,933MB/sPC3-14900DDR3-1866

所有的DDR内存都有一个问题:不总线频率的增加使得建立并行数据总线变得十分困难。一个DDR2模块有240根引脚。所有到地址和数据引脚的连线都必须经过路由,以便它们具有几乎相同的长度。更严重的问题是,如果多个DDR模块通过菊花链连接在同一个总线上,每个模块所接收到的信号随着模块的增加会变得越来越失真。DDR2规范允许每条总线(又称通道)连接最多两个模块,DDR3在高频率下只允许每个通道连接一个模块。每条总线多达240根引脚使得单个北桥无法以合理的方式驱动两个通道。替代方案是增加外部内存控制器(如图2.2),但这会提高成本。

这意味着商品主板被限制最多只能容纳四个DDR2或DDR3模块。这一限制严重限制了系统可以拥有的内存量。即使是旧的32位IA-32处理器也可以处理64GB的内存,同时家庭使用的内存需求也在增长,所以必须做点什么。

一种解法是,在处理器中加入内存控制器,我们在第2节中曾经介绍过。AMD的Opteron系列和Intel的CSI技术就是采用这种方法。只要我们能把处理器要求的内存连接到处理器上,这种解法就是有效的。如果不能,按照这种思路就会引入NUMA架构,当然同时也会引入它的缺点。而在有些情况下,我们需要其它解法。

Intel针对大型服务器方面的解法(至少在未来几年),是被称为全缓冲DRAM(FB-DRAM)的技术。FB-DRAM采用与DDR2相同的器件,因此造价低廉。不同之处在于它们与内存控制器的连接方式。FB-DRAM没有用并行总线,而用了串行总线(Rambus DRAM也有这个,当时SATA是PATA的继任者,PCI/AGP的PCI Express也是如此)。串行总线可以达到更高的频率,串行化的负面影响,甚至可以增加带宽。使用串行总线后。

  1. 每个通道可以使用更多的模块。
  2. 每个北桥/内存控制器可以使用更多的通道。
  3. 串行总线是全双工的(两条线)。
  4. 它足够便宜,可以实现差分总线(每个方向两条线),从而提高速度。

FB-DRAM只有69个脚。通过菊花链方式连接多个FB-DRAM也很简单。FB-DRAM规范允许每个通道连接最多8个模块。

在对比下双通道北桥的连接性,采用FB-DRAM后,北桥可以驱动6个通道,而且脚数更少——6x69对比2x240。每个通道的布线也更为简单,有助于降低主板的成本。

全双工的并行总线过于昂贵。而换成串行线后,这不再是一个问题,因此串行总线按全双工来设计的,这也意味着,在某些情况下,仅靠这一特性,总线的理论带宽已经翻了一倍。还不止于此。由于FB-DRAM控制器可同时连接6个通道,因此可以利用它来增加某些小内存系统的带宽。对于一个双通道、4模块的DDR2系统,我们可以用一个普通FB-DRAM控制器,用4通道来实现相同的容量。串行总线的实际带宽取决于在FB-DRAM模块中所使用的DDR2(或DDR3)芯片的类型。

我们可以像这样总结这些优势:

DDR2FB-DRAM
针脚24069
通道26
每通道DIMM数28
最大内存16GB192GB
吞吐量~10GB/s~40GB/s

如果在单个通道上使用多个DIMM,会有一些问题。信号在每个DIMM上都会有延迟(尽管很小),也就是说,延迟是递增的。不过,如果在相同频率和相同容量上进行比较,FB-DRAM总是能快过DDR2及DDR3,因为FB-DRAM只需要在每个通道上使用一个DIMM即可。而如果说到大型内存系统,那么DDR更是没有商用组件的解决方案。

结论

通过本节,大家应该了解到访问DRAM的过程并不是一个快速的过程。至少与处理器的速度相比,或与处理器访问寄存器及缓存的速度相比,DRAM的访问不算快。大家还需要记住CPU和内存的频率是不同的。Intel Core 2处理器运行在2.933GHz,而1.066GHz FSB有11:1的时钟比率(注: 1.066GHz的总线为四倍频总线)。那么,内存总线上延迟一个周期意味着处理器延迟11个周期。绝大多数机器使用的DRAM更慢,因此延迟更大。在后续的章节中,我们需要讨论延迟这个问题时,请把以上的数字记在心里。

前文中读命令的时序图表明,DRAM模块可以支持高速数据传输。每个完整行可以被毫无延迟地传输。数据总线可以100%被占。对DDR而言,意味着每个周期传输2个64位字。对于DDR2-800模块和双通道而言,意味着12.8GB/s的速率。

但是,除非是特殊设计,DRAM的访问并不总是串行的。访问不连续的内存区意味着需要预充电和 RAS\overline{RAS} 信号。于是,各种速度开始慢下来,DRAM模块急需帮助。预充电越早发生,RAS信号发送越早,实际使用该行时的损失就越小。预充电的时间越短,数据传输所受的惩罚越小。

硬件和软件的预取(参见第6.3节)可以在时序中制造更多的重叠区,降低延迟。预取还可以转移内存操作的时间,从而减少争用。我们常常遇到的问题是,在这一轮中生成的数据需要被存储,而下一轮的数据需要被读出来。通过转移读取的时间,读和写就不需要同时发出了。

主存的其它用户

除了CPU外,系统中还有其它一些组件也可以访问主存。高性能网卡或大规模存储控制器是无法承受通过CPU来传输数据的,它们一般直接对内存进行读写(直接内存访问,DMA)。在图2.1中可以看到,它们可以通过南桥和北桥直接访问内存。另外,其它总线,比如USB等也需要FSB带宽,即使它们并不使用DMA,但南桥仍要通过FSB连接到北桥。

DMA当然有很大的优点,但也意味着FSB带宽会有更多的竞争。在有大量DMA流量的情况下,CPU在访问内存时必然会有更大的延迟。我们可以用一些硬件来解决这个问题。例如,通过图2.3中的架构,我们可以挑选不受DMA影响的节点,让它们的内存为我们的计算服务。还可以在每个节点上连接一个南桥,将FSB的负荷均匀地分担到每个节点上。除此以外,还有许多其它方法。我们将在第6节中介绍一些技术和编程接口,它们能够帮助我们通过软件的方式改善这个问题。

最后,还需要提一下某些廉价系统,它们的图形系统没有专用的显存,而是采用主存的一部分作为显存。由于对显存的访问非常频繁(例如,对于1024x768、16bpp、60Hz的显示设置来说,需要95MB/s的数据速率),而主存并不像显卡上的显存,并没有两个端口,因此这种配置会对系统性能、尤其是时延造成一定的影响。如果大家对系统性能要求比较高,最好不要采用这种配置。这种系统带来的问题超过了本身的价值。人们在购买它们时已经做好了性能不佳的心理准备。

cpu 高速缓存

现在的CPU比25年前要精密得多了。在那个年代,CPU的频率与内存总线的频率基本在同一层面上。内存的访问速度仅比寄存器慢那么一点点。但是,这一局面在上世纪90年代被打破了。CPU的频率大大提升,但内存总线的频率与内存芯片的性能却没有得到成比例的提升。并不是因为造不出更快的内存,只是因为太贵了。内存如果要达到目前CPU那样的速度,那么它的造价恐怕要贵上好几个数量级。

如果有两个选项让你选择,一个是速度非常快、但容量很小的内存,一个是速度还算快、但容量很多的内存,如果你的工作集比较大,超过了前一种情况,那么人们总是会选择第二个选项。原因在于辅存(一般为磁盘)的速度。由于工作集超过主存,那么必须用辅存来保存交换出去的那部分数据,而辅存的速度往往要比主存慢上好几个数量级。

好在这问题也并不全然是非全有即全无的(all-or-nothing)的选择。在配置大量DRAM的同时,我们还可以配置少量SRAM。将地址空间的某个部分划给SRAM,剩下的部分划给DRAM。一般来说,SRAM可以当作扩展的寄存器来使用。

上面的做法看起来似乎可以,但实际上并不可行。首先,将SRAM内存映射到进程的虚拟地址空间就是个非常复杂的工作,而且,在这种做法中,每个进程都需要管理这个SRAM区内存的分配。每个进程可能有大小完全不同的SRAM区,而组成程序的每个模块也需要索取属于自身的SRAM,更引入了额外的同步需求。简而言之,快速内存带来的好处完全被额外的管理开销给抵消了。因此,SRAM是作为CPU自动使用和管理的一个资源,而不是由OS或者用户管理的。在这种模式下,SRAM用来复制保存(或者叫缓存)主内存中有可能即将被CPU使用的数据。这意味着,在较短时间内,CPU很有可能重复运行某一段代码,或者重复使用某部分数据。从代码上看,这意味着CPU执行了一个循环,所以相同的代码一次又一次地执行(空间局部性的绝佳例子)。数据访问也相对局限在一个小的区间内。即使程序使用的物理内存不是相连的,在短期内程序仍然很有可能使用同样的数据(时间局部性)。这个在代码上表现为,程序在一个循环体内调用了入口一个位于另外的物理地址的函数。这个函数可能与当前指令的物理位置相距甚远,但是调用的时间差不大。在数据上表现为,程序使用的内存是有限的(相当于工作集的大小)。但是实际上由于RAM的随机访问特性,程序使用的物理内存并不是连续的。正是由于空间局部性和时间局部性的存在,我们才提炼出今天的CPU缓存概念。

我们先用一个简单的计算来展示一下高速缓存的效率。假设,访问主存需要200个周期,而访问高速缓存需要15个周期。如果使用100个数据元素100次,那么在没有高速缓存的情况下,需要2000000个周期,而在有高速缓存、而且所有数据都已被缓存的情况下,只需要168500个周期。节约了91.5%的时间。

用作高速缓存的SRAM容量比主存小得多。以我的经验来说,高速缓存的大小一般是主存的千分之一左右(目前一般是4GB主存、4MB缓存)。这一点本身并不是什么问题。只是,计算机一般都会有比较大的主存,因此工作集的大小总是会大于缓存。特别是那些运行多进程的系统,它的工作集大小是所有进程加上内核的总和。

处理高速缓存大小的限制需要制定一套很好的策略来决定在给定的时间内什么数据应该被缓存。由于不是所有数据的工作集都是在完全相同的时间段内被使用的,我们可以用一些技术手段将需要用到的数据临时替换那些当前并未使用的缓存数据。这种预取将会减少部分访问主存的成本,因为它与程序的执行是异步的。所有的这些技术将会使高速缓存在使用的时候看起来比实际更大。我们将在3.3节讨论这些问题。 我们将在第6章讨论如何让这些技术能很好地帮助程序员,让处理器更高效地工作。

概观 CPU cache

在深入介绍高速缓存的技术细节之前,有必要说明一下它在现代计算机系统中所处的位置。

图3.1:最简易的cache配置
图3.1:最简易的cache配置

图3.1展示了最简单的高速缓存配置。早期的一些系统就是类似的架构。在这种架构中,CPU核心不再直连到主存。{在一些更早的系统中,高速缓存像CPU与主存一样连到系统总线上。那种做法更像是一种hack,而不是真正的解决方案。}数据的读取和存储都经过高速缓存。CPU核心与高速缓存之间是一条特殊的快速通道。在简化的表示法中,主存与高速缓存都连到系统总线上,这条总线同时还用于与其它组件通信。我们管这条总线叫“FSB”——就是现在称呼它的术语,参见第2.2节。在这一节里,我们将忽略北桥。

即便过去数十年来的大多电脑都采用冯纽曼架构(von Neumann architecture),但经验证实将用于代码和数据的高速缓存分开是存在巨大优势的。自1993年以来,Intel 并且一直坚持使用独立的代码和数据高速缓存。由于所需的代码和数据的内存区域是几乎相互独立的,这就是为什么独立缓存工作得更完美的原因。近年来,独立缓存的另一个优势慢慢显现出来:常见处理器解码 指令的步骤是缓慢的,尤其当管线为空的时候,往往会伴随着错误的预测或无法预测的分支的出现,将高速缓存技术用于指令解码可以加快其执行速度。

在高速缓存出现后不久,系统变得更加复杂。高速缓存与主存之间的速度差异进一步拉大,直到加入了另一级缓存。新加入的这一级缓存比第一级缓存更大,但是更慢。由于加大一级缓存的做法从经济上考虑是行不通的,所以有了二级缓存,甚至现在的有些系统拥有三级缓存,如图3.2所示。随着单个CPU中核数的增加,未来甚至可能会出现更多层级的缓存。

图3.2: 三级缓存的处理器
图3.2: 三级缓存的处理器

图3.2展示了三级缓存,并介绍了本文将使用的一些术语。L1d是一级数据缓存,L1i是一级指令缓存,等等。请注意,这只是示意图,真正的数据流并不需要流经上级缓存。CPU的设计者们在设计高速缓存的接口时拥有很大的自由。而程序员是看不到这些设计选项的。

另外,我们有多核CPU,每个核心可以有多个“线程”。核心与线程的不同之处在于,核心拥有独立的硬件资源(「早期的多核CPU甚至有独立的二级缓存」)。在不同时使用相同资源(比如,通往外界的连接)的情况下,核心可以完全独立地运行。而线程只是共享资源。Intel的线程只有独立的寄存器,而且还有限制——不是所有寄存器都独立,有些是共享的。综上,现代CPU的结构就像图3.3所示。

图3.3 多处理器、多核心、多线程
图3.3 多处理器、多核心、多线程

在上图中,有两个处理器,每个处理器有两个核心,每个核心有两个线程。线程们共享一级缓存。核心(以深灰色表示)有独立的一级缓存,同时共享二级缓存。处理器(淡灰色)之间不共享任何缓存。这些信息很重要,特别是在讨论多进程和多线程情况下缓存的影响时尤为重要。

高阶缓存操作

了解成本和节约使用缓存,我们必须结合在第二节中讲到的关于计算机体系结构和RAM技术,以及前一节讲到的缓存描述来探讨。

默认情况下,CPU核心所有的数据的读或写都存储在缓存中。当然,也有内存区域不能被缓存的,但是这种情况只发生在操作系统的实现者对数据考虑的前提下;对程序实现者来说,这是不可见的。这也说明,程序设计者可以故意绕过某些缓存,不过这将是第六节中讨论的内容了。

如果CPU需要访问某个字(word),先检索缓存。很显然,缓存不可能容纳主存所有内容(否则还需要主存干嘛)。系统用字的内存地址来对缓存条目进行标记。如果需要读写某个地址的字,那么根据标签来检索缓存即可。这里用到的地址可以是虚拟地址,也可以是物理地址,取决于缓存的具体实现。

标签是需要额外空间的,用字作为缓存的粒度显然毫无效率。比如,在x86机器上,32位字的标签可能需要32位,甚至更长。另一方面,由于空间局部性的存在,与当前地址相邻的地址有很大可能会被一起访问。再回忆下2.2.1节——内存模块在传输位于同一行上的多份数据时,由于不需要发送新CAS信号,甚至不需要发送RAS信号,因此可以实现很高的效率。基于以上的原因,缓存条目并不存储单个字,而是存储若干连续字组成的“线”。在早期的缓存中,线长是32字节,现在一般是64字节。对于64位宽的内存总线,每条线需要8次传输「1字节=8位」。而DDR对于这种传输模式的支持更为高效。

当处理器需要内存中的某块数据时,整条缓存线被装入L1d。缓存线的地址通过对内存地址进行掩码操作生成。对于64字节的缓存线,是将低6位地址置0「一次读取64字节,也就是连续的64个地址」。这些被丢弃的位作为线内偏移量。其它的位作为标签,并用于在缓存内定位。在实践中,我们将地址分为三个部分。32位地址的情况如下:

如果缓存线长度为 2O2^O ,那么地址的低O位用作线内偏移量。上面的S位选择“缓存集”。后面我们会说明使用缓存集的原因。现在只需要明白一共有 2S2^S 个缓存集就够了。剩下的32 - S - O = T位组成标签。它们用来区分别名相同的各条线「有相同S部分的缓存线被称为有相同的别名」。用于定位缓存集的S部分不需要存储,因为属于同一缓存集的所有线的S部分都是相同的。

当某条指令修改内存时,仍然要先装入缓存线,因为任何指令都不可能同时修改整条线(只有一个例外——第6.1节中将会介绍的写合并(write-combine))。因此需要在写操作前先把缓存线装载进来。如果缓存线被写入,但还没有写回主存,那就是所谓的“脏了”。脏了的线一旦写回主存,脏标记即被清除。

为了装入新数据,基本上总是要先在缓存中清理出位置。L1d将内容逐出L1d,推入L2(线长相同)。当然,L2也需要清理位置。于是L2将内容推入L3,最后L3将它推入主存。这种逐出操作一级比一级昂贵。这里所说的是现代AMD和VIA处理器所采用的独占型缓存(exclusive cache)。而Intel采用的是包容型缓存(inclusive cache),「并不完全正确,Intel有些缓存是独占型的,还有一些缓存具有独占型缓存的特点」。L1d的每条线同时存在于L2里。对这种缓存,逐出操作就很快了。如果有足够L2,对于相同内容存在不同地方造成内存浪费的缺点可以降到最低,而且在逐出时非常有利。而独占型缓存在装载新数据时只需要操作L1d,不需要碰L2,因此会比较快。

处理器体系结构中定义的作为存储器的模型只要还没有改变,那就允许多CPU按照自己的方式来管理高速缓存。这表示,例如,设计优良的处理器,利用很少或根本没有内存总线活动,并主动写回主内存脏高速缓存行。x86 与 x86-64 –– 不同厂商、甚至是同一厂商的不同型号之间 –– 的处理器之间有著各式各样的cache架构,证明memory模型抽象化的能力。

在对称多处理器(SMP)架构的系统中,CPU的高速缓存不能独立的工作。在任何时候,所有的处理器都应该拥有相同的内存内容。保证这样的统一的内存视图被称为“高速缓存一致性”。如果在其自己的高速缓存和主内存间,处理器设计简单,它将不会看到在其他处理器上的脏高速缓存行的内容。提供从一个处理器到另一个处理器cache的直接存取会非常昂贵,而且是个极大的瓶颈。取而代之地,处理器会在另一个处理器要读取或写入到某个cache行时察觉到。

如果CPU检测到一个写访问,而且该CPU的cache中已经缓存了一个cache line的原始副本,那么这个cache line将被标记为无效的cache line。接下来在引用这个cache line之前,需要重新加载该cache line。需要注意的是读访问并不会导致cache line被标记为无效的。

更精确的cache实现需要考虑到其他更多的可能性,比如第二个CPU在读或者写他的cache line时,发现该cache line在第一个CPU的cache中被标记为脏数据了,此时我们就需要做进一步的处理。在这种情况下,主存储器已经失效,第二个CPU需要读取第一个CPU的cache line。通过测试,我们知道在这种情况下第一个CPU会将自己的cache line数据自动发送给第二个CPU。这种操作是绕过主存储器的,但是有时候存储控制器是可以直接将第一个CPU中的cache line数据存储到主存储器中。对第一个CPU的cache的写访问会导致本地cache line的所有拷贝被标记为无效。

随着时间的推移,一大批缓存一致性协议已经建立。其中,最重要的是MESI,我们将在第3.3.4节进行介绍。以上结论可以概括为几个简单的规则:

  • 一个脏缓存线不存在于任何其他处理器的缓存之中。
  • 同一缓存线中的干净拷贝可以驻留在任意多个其他缓存之中。

如果遵守这些规则,处理器甚至可以在多处理器系统中更加有效的使用它们的缓存。所有的处理器需要做的就是监控其他每一个写访问和比较本地缓存中的地址。在下一节中,我们将介绍更多细节方面的实现,尤其是存储开销方面的细节。

最后,我们至少应该关注高速缓存命中或未命中带来的消耗。下面是英特尔奔腾 M 的数据:

To WhereCycles
Register<= 1
L1d~3
L2~14
Main Memory~240

这些是以 CPU 周期测量的实际存取时间。有趣的是,对内建于晶片上的 L2 cache而言,大部分(甚至可能超过一半)的存取时间都是由线路延迟造成的。这是一个只会随著cache大小变大而变糟的实体限制。只有制程的缩小(举例来说,从 Intel 系列中 Merom 的 60nm 到 Penryn 的 45nm)能提升这些数字。

表格中的数字看起来很高,但是,幸运的是,整个成本不必须负担每次出现的缓存加载和缓存失效。某些部分的成本可以被隐藏。现在的处理器都使用不同长度的内部管道,在管道内指令被解码,并为准备执行。如果数据要传送到一个寄存器,那么部分的准备工作是从存储器(或高速缓存)加载数据。如果内存加载操作在管道中足够早的进行,它可以与其他操作并行发生,那么加载的全部发销可能会被隐藏。对L1D常常可能如此;某些有长管道的处理器的L2也可以。

提早启动内存的读取有许多障碍。它可能只是简单的不具有足够资源供内存访问,或者地址从另一个指令获取,然后加载的最终地址才变得可用。在这种情况下,加载成本是不能隐藏的(完全的)。

对于写操作,CPU并不需要等待数据被安全地放入内存。只要指令具有类似的效果,就没有什么东西可以阻止CPU走捷径了。它可以早早地执行下一条指令,甚至可以在影子寄存器(shadow register)的帮助下,更改这个写操作将要存储的数据。

图3.4: 随机写操作的访问时间
图3.4: 随机写操作的访问时间

图3.4展示了缓存的效果。关于产生图中数据的程序,我们会在稍后讨论。这里大致说下,这个程序是连续随机地访问某块大小可配的内存区域。每个数据项的大小是固定的。数据项的多少取决于选择的工作集大小。Y轴表示处理每个元素平均需要多少个CPU周期,注意它是对数刻度。X轴也是同样,工作集的大小都以2的n次方表示。

图中有三个比较明显的不同阶段。很正常,这个处理器有L1d和L2,没有L3。根据经验可以推测出,L1d有 2132^{13} 字节,而L2有 2202^{20}字节。因为,如果整个工作集都可以放入L1d,那么只需不到10个周期就可以完成操作。如果工作集超过L1d,处理器不得不从L2获取数据,于是时间飘升到28个周期左右。如果工作集更大,超过了L2,那么时间进一步暴涨到480个周期以上。这时候,许多操作将不得不从主存中获取数据。更糟糕的是,如果修改了数据,还需要将这些脏了的缓存线写回内存。

看了这个图,大家应该会有足够的动力去检查代码、改进缓存的利用方式了吧?这里的性能改善可不只是微不足道的几个百分点,而是几个数量级呀。在第6节中,我们将介绍一些编写高效代码的技巧。而下一节将进一步深入缓存的设计。虽然精彩,但并不是必修课,大家可以选择性地跳过。

CPU cache的实现细节

缓存的实现者们都要面对一个问题——主存中每一个单元都可能需被缓存。如果程序的工作集很大,就会有许多内存位置为了缓存而打架。前面我们曾经提过缓存与主存的容量比,1:1000也十分常见。

关联性

我们可以让缓存的每条线能存放任何内存地址的数据。这就是所谓的全关联缓存(fully associative cache**)。对于这种缓存,处理器为了访问某条线,将不得不检索所有线的标签。而标签则包含了整个地址,而不仅仅只是线内偏移量(也就意味着,图3.2中的S为0)。

高速缓存有类似这样的实现,但是,看看在今天使用的L2的数目,表明这是不切实际的。给定4MB的高速缓存和64B的高速缓存段,高速缓存将有65,536个项。为了达到足够的性能,缓存逻辑必须能够在短短的几个时钟周期内,从所有这些项中,挑一个匹配给定的标签。实现这一点的工作将是巨大的。

图3.5:全关联式cache示意图
图3.5:全关联式cache示意图

对于每个高速缓存行,比较器是需要比较大标签(注意,S是零)。每个连接旁边的字母表示位的宽度。如果没有给出,它是一个单比特线。每个比较器都要比较两个T位宽的值。然后,基于该结果,适当的高速缓存行的内容被选中,并使其可用。这需要合并多套O数据线,因为他们是缓存桶(译注:这里类似把O输出接入多选器,所以需要合并)。实现仅仅一个比较器,需要晶体管的数量就非常大,特别是因为它必须非常快。没有迭代比较器是可用的。节省比较器的数目的唯一途径是通过反复比较标签,以减少它们的数目。这是不适合的,出于同样的原因,迭代比较器不可用:它的时间太长。

全关联高速缓存对小缓存是实用的(例如,在某些Intel处理器的TLB缓存是全关联的),但这些缓存都很小,非常小的。我们正在谈论的最多几十项。

对于L1i,L1d和更高级别的缓存,需要采用不同的方法。可以做的就是是限制搜索。最极端的限制是,每个标签映射到一个明确的缓存条目。计算很简单:给定的4MB/64B缓存有65536项,我们可以使用地址的bit6到bit21(16位)来直接寻址高速缓存的每一个项。地址的低6位作为cache行内部的索引。

图3.6:直接对映式cache示意图
图3.6:直接对映式cache示意图

如图 3.6 所见到的,这种直接对映式cache(direct-mapped cache)很快,而且实作起来相对简单。它需要一个比较器、一个多路复用器(在这张示意图中有两个,标签与数据是分离的,但在这个设计上,这点并不是个硬性要求)、以及一些用以选择有效cache行内容的逻辑。比较器是因速度要求而复杂,但现在只有一个;因此,便能够花费更多的精力来让它变快。在这个方法中,实际的复杂之处都落在多路复用器上。在一个简易的多工器上,晶体管的数量以 O(logN)O(\log N) 成长,其中 NN 为cache行的数量。这能够容忍,但可能会慢了点,在这种情况下,借由在多工器中增加更多的晶体管以平行化某些工作,便能够提升速度。晶体管的总数能够缓慢地随著cache大小的成长而成长,使得这种解法非常有吸引力。但它有个缺点:只有在程序用到的地址,对于用以直接映射的bit来说是均匀分布的情况下,它才能运作得很好。若非如此,而且经常这样的话,某些cache项目会因为频繁地使用而被重复地逐出,而其余的项目则几乎完全没用到、或者一直是空的。

图3.7:组关联高速缓存原理图
图3.7:组关联高速缓存原理图

可以通过使高速缓存的组关联来解决此问题。组关联结合高速缓存的全关联和直接映射高速缓存特点,在很大程度上避免那些设计的弱点。图3.7显示了一个组关联高速缓存的设计。标签与数据的储存被分成集合,其中之一会被cache行的地址所选择。这与直接对映式cache相似。但少数的值能以相同的集合编号cache,而非令cache中的每个集合编号都只有一个元素。所有集合内成员的标签会平行地比对,这与全关联式cache的运作方式相似。

其结果是高速缓存,不容易被不幸或故意选择同属同一组编号的地址所击败,同时高速缓存的大小并不限于由比较器的数目,可以以并行的方式实现。如果高速缓存增长,只(在该图中)增加列的数目,而不增加行数。只有高速缓存之间的关联性增加,行数才会增加。今天,处理器的L2高速缓存或更高的高速缓存,使用的关联性高达16。 L1高速缓存通常使用8。

将主存的数据映射到高速缓存,由于高速缓存的size较小,一定存在相同key(tag)的碰撞。多路set可以看做是一级Key,tag是二级key。解决了只有一级key时,多路复用器原件过于复杂的情况。同时set内可以通过电路实现并行比较。

给定我们32位机器,4MB/64B高速缓存,8路组关联,相关的缓存留给我们的有8192(65536/8)组,只用标签的13位,就可以寻址Set「cacheline地址,低5位(0~4)掩码,中间地址13位(5~17)用于区分Set,然后在8way的set中挨个对比tag。」。要确定哪些(如果有的话)的缓存组设置中的条目包含寻址的高速缓存行,8个标签都要进行比较。在很短的时间内做出来是可行的。通过一个实验,我们可以看到,这是有意义的。

L2 cache大小关联度
直接248
CL=32CL=64CL=32CL=64CL=32CL=64CL=32CL=64
512k27,794,59520,422,52725,222,61118,303,58124,096,51017,356,12123,666,92917,029,334
1M19,007,31513,903,85416,566,73812,127,17415,537,50011,436,70515,162,89511,233,896
2M12,230,9628,801,4039,081,8816,491,0117,878,6015,675,1817,391,3895,382,064
4M7,749,9865,427,8364,736,1873,159,5073,788,1222,418,8983,430,7132,125,103
8M4,731,9043,209,6932,690,4981,602,9572,207,6551,228,1902,111,0751,155,847
16M2,620,5871,528,5921,958,2931,089,5801,704,878883,5301,671,541862,324
表 3.1:cache大小、关联度、以及cache行大小的影响

表 3.1 显示了对于一支程序(在这个例子中是 gcc,根据 Linux 系统核心的人们的说法,它是所有基准中最重要的一个)在改变cache大小、cache行大小、以及关联度集合大小时,L2 cache错失的次数。在 7.2 节中,我们将会介绍对于这个测试,所需要用以模拟cache的工具。

以防这些值的关联仍不明显,这所有的值的关系是,cache的大小为

cache行大小×关联度×集合的数量\text{cache行大小} \times \text{关联度} \times \text{集合的数量}

地址是以 3.2 节的图中示意的方式,使用

O=log2cache行大小S=log2集合的数量\begin{aligned} \mathbf{O} &= \log_{2} \text{cache行大小} \\ \mathbf{S} &= \log_{2} \text{集合的数量} \end{aligned}

来对映到cache中的。

图3.8:缓存段大小 vs 关联行 (CL=32)
图3.8:缓存段大小 vs 关联行 (CL=32)

图3.8表中的数据更易于理解。它显示一个固定的32个字节大小的高速缓存行的数据。对于一个给定的高速缓存大小,我们可以看出,关联性,的确可以帮助明显减少高速缓存未命中的数量。对于8MB的缓存,从直接映射到2路组相联,可以减少近44%的高速缓存未命中。组相联高速缓存和直接映射缓存相比,该处理器可以把更多的工作集保持在缓存中。

在文献中,偶尔可以读到,引入关联性,和加倍高速缓存的大小具有相同的效果。在从4M缓存跃升到8MB缓存的极端的情况下,这是正确的。关联性再提高一倍那就肯定不正确啦。正如我们所看到的数据,后面的收益要小得多。我们不应该完全低估它的效果,虽然。在示例程序中的内存使用的峰值是5.6M。因此,具有8MB缓存不太可能有很多(两个以上)使用相同的高速缓存的组。从较小的缓存的关联性的巨大收益可以看出,较大工作集可以节省更多。

在一般情况下,增加8以上的高速缓存之间的关联性似乎对只有一个单线程工作量影响不大。随着介绍一个使用共享L2的多核处理器,形势发生了变化。现在你基本上有两个程序命中相同的缓存, 实际上导致高速缓存减半(对于四核处理器是1/4)。因此,可以预期,随着核的数目的增加,共享高速缓存的相关性也应增长。一旦这种方法不再可行(16 路组关联性已经很难)处理器设计者不得不开始使用共享的三级高速缓存和更高级别的,而L2高速缓存只被核的一个子集共享。

从图3.8中,我们还可以研究缓存大小对性能的影响。这一数据需要了解工作集的大小才能进行解读。很显然,与主存相同的缓存比小缓存能产生更好的结果,因此,缓存通常是越大越好。

上文已经说过,示例中最大的工作集为5.6M。它并没有给出最佳缓存大小值,但我们可以估算出来。问题主要在于内存的使用并不连续,因此,即使是16M的缓存,在处理5.6M的工作集时也会出现冲突(参见2路集合关联式16MB缓存vs直接映射式缓存的优点)。不管怎样,我们可以有把握地说,在同样5.6M的负载下,缓存从16MB升到32MB基本已没有多少提高的余地。但是,工作集是会变的。如果工作集不断增大,缓存也需要随之增大。在购买计算机时,如果需要选择缓存大小,一定要先衡量工作集的大小。原因可以参见图3.10。

图3.9:测试的内存分布情况
图3.9:测试的内存分布情况

我们执行两项测试。第一项测试是按顺序地访问所有元素。测试程序循着指针n进行访问,而所有元素是链接在一起的,从而使它们的被访问顺序与在内存中排布的顺序一致,如图3.9的下半部分所示,末尾的元素有一个指向首元素的引用。而第二项测试(见图3.9的上半部分)则是按随机顺序访问所有元素。在上述两个测试中,所有元素都构成一个单向循环链表。

Cache的性能测试

用于测试程序的数据可以模拟一个任意大小的工作集:包括读、写访问,随机、连续访问。在图3.4中我们可以看到,程序为工作集创建了一个与其大小和元素类型相同的数组:

1
2
3
4
struct l {
struct l *n;
long int pad[NPAD];
};

n字段将所有节点随机得或者顺序的加入到环形链表中,用指针从当前节点进入到下一个节点。pad字段用来存储数据,其可以是任意大小。在一些测试程序中,pad字段是可以修改的, 在其他程序中,pad字段只可以进行读操作。

在效能量测中,我们讨论的是工作集的大小。工作集是由一个 struct l 元素的阵列所组成的。一个 2N byte的工作集包含 2N / sizeof(struct l) 个元素。显而易见地,sizeof(struct l)NPAD 的值而定。以 32 bit的系统来说,NPAD=7 代表每个阵列元素的大小为 32 byte(4*7+1),以 64 bit的系统来说,大小为 64 byte。

单线程顺序访问

最简单的情况就是遍历链表中顺序存储的节点。无论是从前向后处理,还是从后向前,对于处理器来说没有什么区别。下面的测试中,我们需要得到处理链表中一个元素所需要的时间,以CPU时钟周期最为计时单元。图3.10显示了测试结构。除非有特殊说明, 所有的测试都是在Pentium 4 64-bit 平台上进行的,因此结构体l中NPAD=0,大小为8字节。

图3.10:顺序读访问, NPAD=0
图3.10:顺序读访问, NPAD=0

一开始的两个测试数据收到了噪音的污染。由于它们的工作负荷太小,无法过滤掉系统内其它进程对它们的影响。我们可以认为它们都是4个周期以内的。这样一来,整个图可以划分为比较明显的三个部分:

  • 工作集小于214字节的。
  • 工作集从215字节到220字节的。
  • 工作集大于221字节的。

这样的结果很容易解释——是因为处理器有16KB的L1d和1MB的L2。而在这三个部分之间,并没有非常锐利的边缘,这是因为系统的其它部分也在使用缓存,我们的测试程序并不能独占缓存的使用。尤其是L2,它是统一式的缓存,处理器的指令也会使用它(注: Intel使用的是包容式缓存)。

测试的实际耗时可能会出乎大家的意料。L1d的部分跟我们预想的差不多,在一台P4上耗时为4个周期左右。但L2的结果则出乎意料。大家可能觉得需要14个周期以上,但实际只用了9个周期。这要归功于处理器先进的处理逻辑,当它使用连续的内存区时,会 预先读取下一条缓存线的数据。这样一来,当真正使用下一条线的时候,其实已经早已读完一半了,于是真正的等待耗时会比L2的访问时间少很多。

在工作集超过L2的大小之后,预取的效果更明显了。前面我们说过,主存的访问需要耗时200个周期以上。但在预取的帮助下,实际耗时保持在9个周期左右。200 vs 9,效果非常不错。

图3.11:顺序读多个字节
图3.11:顺序读多个字节

我们可以观察到预取的行为,至少可以间接地观察到。图3.11中有4条线,它们表示处理不同大小结构时的耗时情况。随着结构的变大,元素间的距离变大了。图中4条线对应的元素距离分别是0、56、120和248字节。

图中最下面的这一条线来自前一个图,但在这里更像是一条直线。其它三条线的耗时情况比较差。图中这些线也有比较明显的三个阶段,同时,在小工作集的情况下也有比较大的错误(请再次忽略这些错误)。在只使用L1d的阶段,这些线条基本重合。因为这时候还不需要预取,只需要访问L1d就行。

在L2阶段,三条新加的线基本重合,而且耗时比老的那条线高很多,大约在28个周期左右,差不多就是L2的访问时间。这表明,从L2到L1d的预取并没有生效。这是因为,对于最下面的线(NPAD=0),由于结构小,8次循环后才需要访问一条新缓存线,而上面三条线对应的结构比较大,拿相对最小的NPAD=7来说,光是一次循环就需要访问一条新线,更不用说更大的NPAD=15和31了。而预取逻辑是无法在每个周期装载新线的,因此每次循环都需要从L2读取,我们看到的就是从L2读取的时延。

更有趣的是工作集超过L2容量后的阶段。现在四条线全都离得很远。元素的大小变成了主角,左右了性能。处理器应能识别每一步(stride)的大小,不去为NPAD=15和31获取那些实际并不需要的缓存线(参见6.3.1)。元素大小对预取的约束是根源于硬件预取的限制——它无法跨越页边界。如果允许预取器跨越页边界,而下一页不存在或无效,那么OS还得去寻找它。这意味着,程序需要遭遇一次并非由它自己产生的页错误,这是完全不能接受的。在NPAD=7或者更大的时候,由于每个元素都至少需要一条缓存线,预取器已经帮不上忙了,它没有足够的时间去从内存装载数据。另一个导致慢下来的原因是TLB缓存的未命中。TLB是存储虚实地址映射的缓存,参见第4节。为了保持快速,TLB只有很小的容量。如果有大量页被反复访问,超出了TLB缓存容量,就会导致反复地进行地址翻译,这会耗费大量时间。TLB查找的代价分摊到所有元素上,如果元素越大,那么元素的数量越少,每个元素承担的那一份就越多。

为了观察TLB的性能,我们可以进行另两项测试。第一项:我们还是顺序存储列表中的元素,使NPAD=7,让每个元素占满整个cache line,第二项:我们将列表的每个元素存储在一个单独的页上,忽略每个页没有使用的部分以用来计算工作集的大小。(这样做可能不太一致,因为在前面的测试中,我计算了结构体中每个元素没有使用的部分,从而用来定义NPAD的大小,因此每个元素占满了整个页,这样以来工作集的大小将会有所不同。但是这不是这项测试的重点,预取的低效率多少使其有点不同)。结果表明,第一项测试中,每次列表的迭代都需要一个新的cache line,而且每64个元素就需要一个新的页。第二项测试中,每次迭代都会在一个新的页中加载一个新的cache line。

图3.12:TLB 对顺序读取的影响
图3.12:TLB 对顺序读取的影响

结果见图3.12。该测试与图3.11是在同一台机器上进行的。基于可用RAM空间的有限性,测试设置容量空间大小为2的24次方字节,这就需要1GB的容量将对象放置在分页上。图3.12中下方的红色曲线正好对应了图3.11中NPAD等于7的曲线。我们看到不同的步长显示了高速缓存L1d和L2的大小。第二条曲线看上去完全不同,其最重要的特点是当工作容量到达2的13次方字节时开始大幅度增长。这就是TLB缓存溢出的时候。我们能计算出一个64字节大小的元素的TLB缓存有64个输入。成本不会受页面错误影响,因为程序锁定了存储器以防止内存被换出。

可以看出,计算物理地址并把它存储在TLB中所花费的周期数量级是非常高的。图3.12的表格显示了一个极端的例子,但从中可以清楚的得到:TLB缓存效率降低的一个重要因素是大型NPAD值的减缓。由于物理地址必须在缓存行能被L2或主存读取之前计算出来,地址转换这个不利因素就增加了内存访问时间。这一点部分解释了为什么NPAD等于31时每个列表元素的总花费比理论上的RAM访问时间要高。

图3.13:NPAD等于1时的顺序读和写
图3.13:NPAD等于1时的顺序读和写

通过查看链表元素被修改时测试数据的运行情况,我们可以窥见一些更详细的预取实现细节。图3.13显示了三条曲线。所有情况下元素宽度都为16个字节。第一条曲线“Follow”是熟悉的链表顺序访问在这里作为基线。第二条曲线,标记为“Inc”,仅会在前往下一个元素前,增加当前元素的 pad[0] 成员的值。。第三条曲线,标记为"Addnext0", 取出下一个元素的pad[0]的值并把它添加为当前链表元素的pad[0]成员。

在没运行时,大家可能会以为"Addnext0"更慢,因为它要做的事情更多——在没进到下个元素之前就需要装载它的值。但实际的运行结果令人惊讶——在某些小工作集下,"Addnext0"比"Inc"更快。这是为什么呢?原因在于,系统一般会对下一个元素进行强制性预取。当程序前进到下个元素时,这个元素其实早已被预取在L1d里。因此,只要工作集比L2小,"Addnext0"的性能基本就能与"Follow"测试媲美。

但是,"Addnext0"比"Inc"更快离开L2,这是因为它需要从主存装载更多的数据。而在工作集达到2 21字节时,"Addnext0"的耗时达到了28个周期,是同期"Follow"14周期的两倍。这个两倍也很好解释。"Addnext0"和"Inc"涉及对内存的修改,因此L2的逐出操作不能简单地把数据一扔了事,而必须将它们写入内存。因此FSB的可用带宽变成了一半,传输等量数据的耗时也就变成了原来的两倍。

图3.14:较大 L2/L3 cache的优势
图3.14:较大 L2/L3 cache的优势

决定顺序式缓存处理性能的另一个重要因素是缓存容量。虽然这一点比较明显,但还是值得一说。图3.14展示了128字节长元素的测试结果(64位机,NPAD=15)。这次我们比较三台不同计算机的曲线,两台P4,一台Core 2。两台P4的区别是缓存容量不同,一台是32k的L1d和1M的L2,一台是16K的L1d、512k的L2和2M的L3。Core 2那台则是32k的L1d和4M的L2。

图中最有趣的地方,并不是Core 2如何大胜两台P4,而是工作集开始增长到连最后一阶cache也放不下、需要主存积极参与之后的部分。

如同预期,最后一阶的cache越大,曲线在相应于 L2 存取成本的低水平停留得越久。要注意的重要部分是它所提供的效能优势。第二个处理器(它稍微旧了一点)在 220 byte的工作集上能够以两倍于第一个处理器的速度执行。这全都归功于最后一阶cache大小的提升。有著 4M L2 的 Core2 处理器甚至表现得更好。

对于随机的工作量而言,这可能不代表什么。但若是工作量能被裁剪成最后一阶cache的大小,程序效能便能够极为大幅地提升。这也是有时候值得为拥有较大cache的处理器花费额外金钱的原因。

单线程随机访问模式的测量

前面我们已经看到,处理器能够利用L1d到L2之间的预取消除访问主存、甚至是访问L2的时延。不过,这只有在能够预测memory的存取时才能良好运作。

图3.15:循序 vs 随机读取,NPAD=0
图3.15:循序 vs 随机读取,NPAD=0

若是存取模式是不可预测、或者随机的,情况便大大地不同。图3.15比较了顺序读取与随机读取的耗时情况。换成随机之后,处理器无法再有效地预取数据,只有少数情况下靠运气刚好碰到先后访问的两个元素挨在一起的情形。

在图 3.15 中,有两个要注意的重点。第一点是,增长工作集大小需要大量的周期数。机器能够在 200-300 个周期内存取主memory,但这里我们达到了 450 个周期以上。我们先前已经看过这个现象了(对比图 3.11)。自动预取在这里实际上起了反效果。

其次,代表随机访问的曲线在各个阶段不像顺序访问那样保持平坦,而是不断攀升。为了解释这个问题,我们测量了程序在不同
工作集下对L2的访问情况。结果如图3.16和表3.2。

图 3.16:L2d miss率
图 3.16:L2d miss率
集合大小循序随机
L2 命中数L2 错失数迭代次数错失/命中比率每迭代 L2 存取数L2 命中数L2 错失数迭代次数错失/命中比率每迭代 L2 存取数
22088,63684316,3840.94%5.530,46247211,02413.42%34.4
22188,1051,5848,1921.77%10.921,81715,15151240.98%72.2
22288,1061,6004,0961.78%21.922,25822,28525650.03%174.0
22388,1041,6142,0481.80%43.827,52126,27412848.84%420.3
22488,1141,6551,0241.84%87.733,16629,1156446.75%973.1
22588,1121,7305121.93%175.539,85832,3603244.81%2,256.8
22688,1121,9062562.12%351.648,53938,1511644.01%5,418.1
22788,1142,2441282.48%705.962,42352,049845.47%14,309.0
22888,1202,939643.23%1,422.881,90687,167451.56%42,268.3
22988,1374,318324.67%2,889.2119,079163,398257.84%141,238.5
表 3.2:循序与随机巡访时的 L2 命中与错失,NPAD=0

从图中可以看出,当工作集大小超过L2时,未命中率(L2未命中次数/L2访问次数)开始上升。整条曲线的走向与图3.15有些相似: 先急速爬升,随后缓缓下滑,最后再度爬升。它与耗时图有紧密的关联。L2未命中率会一直爬升到100%为止。只要工作集足够大(并且内存也足够大),就可以将缓存线位于L2内或处于装载过程中的可能性降到非常低。

缓存未命中率的攀升已经可以解释一部分的开销。除此以外,还有一个因素。观察表3.2的L2迭代次数列,可以看到每个循环对L2的使用次数在增长。由于工作集每次为上一次的两倍,如果没有缓存的话,内存的访问次数也将是上一次的两倍。在按顺序访问时,由于缓存的帮助及完美的预见性,对L2使用的增长比较平缓,完全取决于工作集的增长速度。

而换成随机访问后,单位耗时的增长超过了工作集的增长,根源是TLB未命中率的上升。图3.17描绘的是NPAD=7时随机访问的耗时情况。这一次,我们修改了随机访问的方式。正常情况下是把整个列表作为一个块进行随机(以 \infty 表示),而其它11条线则是在小一些的块里进行随机。例如,标签为’60’的线表示以60页(245760字节)为单位进行随机。先遍历完这个块里的所有元素,再访问另一个块。这样一来,可以保证任意时刻使用的TLB条目数都是有限的。

NPAD=7对应于64字节,正好等于缓存线的长度。由于元素顺序随机,硬件预取不可能有任何效果,特别是在元素较多的情况下。这意味着,分块随机时的L2未命中率与整个列表随机时的未命中率没有本质的差别。随着块的增大,曲线逐渐逼近整个列表随机对应的曲线。这说明,在这个测试里,性能受到TLB命中率的影响很大,如果我们能提高TLB命中率,就能大幅度地提升性能(在后面的一个例子里,性能提升了38%之多)。

图3.17: 页意义上(Page-Wise)的随机化,NPAD=7
图3.17: 页意义上(Page-Wise)的随机化,NPAD=7

写入行为

在我们开始研究多个线程或进程同时使用相同内存之前,先来看一下缓存实现的一些细节。我们要求缓存是一致的,而且这种一致性必须对用户级代码完全透明。而内核代码则有所不同,它有时候需要对缓存进行转储(flush)。

这意味着,如果对缓存线进行了修改,那么在这个时间点之后,系统的结果应该是与没有缓存的情况下是相同的,即主存的对应位置也已经被修改的状态。这种要求可以通过两种方式或策略实现:

  • write-through(直写)
  • write-back(写回)

write-through 比较简单。当修改缓存线时,处理器立即将它写入主存。这样可以保证主存与缓存的内容永远保持一致。当缓存线被替代时,只需要简单地将它丢弃即可。这种策略很简单,但是速度比较慢。如果某个程序反复修改一个本地变量,可能导致FSB上产生大量数据流,而不管这个变量是不是有人在用,或者是不是短期变量。

write-back比较复杂。当修改缓存线时,处理器不再马上将它写入主存,而是打上已弄脏(dirty)的标记。当以后某个时间点缓存线被丢弃时,这个已弄脏标记会通知处理器把数据写回到主存中,而不是简单地扔掉。

write-back有时候会有非常不错的性能,因此较好的系统大多采用这种方式。采用write-back时,处理器们甚至可以利用FSB的空闲容量来存储缓存线。这样一来,当需要缓存空间时,处理器只需清除脏标记,丢弃缓存线即可。

但写回也有一个很大的问题。当有多个处理器(或核心、超线程)访问同一块内存时,必须确保它们在任何时候看到的都是相同的内容。如果缓存线在其中一个处理器上弄脏了(修改了,但还没写回主存),而第二个处理器刚好要读取同一个内存地址,那么这个读操作不能去读主存,而需要读第一个处理器的缓存线。在下一节中,我们将研究如何实现这种需求。

在此之前,还有其它两种缓存策略需要提一下:

  • write-combining(合并写入)
  • uncacheable(不可缓存)

这两种策略都是用在定址空间中、并非被真正的 RAM 所支援的特殊区域。系统核心为这些地址范围设置了这些策略(在使用了memory型态范围暂存器〔Memory Type Range Register,MTRR〕的 x86 处理器上),剩下的部分自动地进行。MTRR 也能用于在直写式与回写式策略之间选择。

写入合并是一种有限的缓存优化策略,更多地用于显卡等设备之上的内存。由于设备的传输开销比本地内存要高的多,因此避免进行过多的传输显得尤为重要。如果仅仅因为修改了缓存线上的一个字,就传输整条线,而下个操作刚好是修改线上的下一个字,那么这次传输就过于浪费了。而这恰恰对于显卡来说是比较常见的情形——屏幕上水平邻接的像素往往在内存中也是靠在一起的。顾名思义,写入合并是在写出缓存线前,先将多个写入访问合并起来。在理想的情况下,缓存线被逐字逐字地修改,只有当写入最后一个字时,才将整条线写入内存,从而极大地加速内存的访问。

最后来讲一下不可缓存的内存。一般指的是不被RAM支持的内存位置,它可以是硬编码的特殊地址,承担CPU以外的某些功能。对于商用硬件来说,比较常见的是映射到外部卡或设备的地址。在嵌入式主板上,有时也有类似的地址,用来开关LED。对这些地址进行缓存显然没有什么意义。比如上述的LED,一般是用来调试或报告状态,显然应该尽快点亮或关闭。而对于那些PCI卡上的内存,由于不需要CPU的干涉即可更改,也不该缓存。

多处理器支持

在上节中我们已经指出当多处理器开始发挥作用的时候所遇到的问题。甚至对于那些不共享的高速级别的缓存(至少在L1d级别)的多核处理器也有问题。

提供从一个处理器到另一个处理器的cache的直接存取是完全不切实际的。首先,连线根本不够快。实际的替代方案是,将cache内容传输给另一个处理器 –– 假如需要的话。注意到这也同样适用于不在相同处理器上共享的cache。

现在的问题是,什么时候得传输这个cache行?这是个相当容易回答的问题:当一个处理器需要读取或写入一个cache行,而其在另一个处理器的cache上是脏的。但处理器要怎么样才能判断一个cache行在另一个处理器的cache上是脏的呢?仅因为一个cache行被另一个处理器载入就假定如此,(至多)也是次佳的(suboptimal)。通常,大多数的memory存取都是读取操作,产生的cache行也不是脏的。处理器对cache行的操作是很频繁的(那当然,不然我们怎么会有这篇论文?),这表示在每次写入操作之后,都去广播被改变的cache行的资讯是不切实际的。

这些年来所发展出来的就是 MESI cache一致性协议(修改〔Modified〕、独占〔Exclusive〕、共享〔Shared〕、无效〔Invalid〕)。协议的名称来自协议中缓存线可以进入的四种状态:

  • 变更的: 本地处理器修改了缓存线。同时暗示,它是所有缓存中唯一的拷贝。
  • 独占的: 缓存线没有被修改,而且没有被装入其它处理器缓存。
  • 共享的: 缓存线没有被修改,但可能已被装入其它处理器缓存。
  • 无效的: 缓存线无效,即,未被使用。

MESI协议开发了很多年,最初的版本比较简单,但是效率也比较差。现在的版本通过以上4个状态可以有效地实现写回式缓存,同时支持不同处理器对只读数据的并发访问。

图 3.18:MESI 协议的状态转换
图 3.18:MESI 协议的状态转换

在协议中,通过处理器监听其它处理器的活动,不需太多精力即可实现状态变更。处理器执行的某些操作会被发布在外部针脚上,因而让处理器的cache处理能被外界看到。处理中的cache行地址能在地址总线上看到。在接下来对状态与其转换(显示在图 3.18)的描述中,我们会指出总线参与的时机。

一开始,所有缓存线都是空的,缓存为无效(Invalid)状态。当有数据装进缓存供写入时,缓存变为变更(Modified)状态。如果有数据装进缓存供读取,那么新状态取决于其它处理器是否已经装载了同一条缓存线。如果是,那么新状态变成共享(Shared)状态,否则变成独占(Exclusive)状态。

如果本地处理器对某条Modified缓存线进行读写,那么直接使用缓存内容,状态保持不变。如果另一个处理器希望读它,那么第一个处理器将cache内容发给第二个处理器,然后可以将缓存状态置为Shared。而发给第二个处理器的数据由内存控制器接收,并放入内存中。如果这一步没有发生,就不能将这条线置为Shared。如果第二个处理器希望的是写,那么第一个处理器将内容发给它后,将缓存置为Invalid。这就是臭名昭著的"请求所有权(Request For Ownership,RFO)"操作。在最后一级缓存执行RFO操作的代价比较高。如果是直写式缓存,还要加上将内容写入更高阶cache或主存的时间,进一步提升了代价。

对于Shared缓存线,本地处理器的读取操作并不需要修改状态,而且可以直接从缓存满足。而本地处理器的写入操作则需要将状态置为Modified,并且需要将缓存线在其它处理器的所有副本置为Invalid。因此,这个写入操作需要通过RFO消息发通知其它处理器。如果第二个处理器请求读取,无事发生。因为主存已经包含了当前数据,而且状态已经为Shared。如果第二个处理器需要写入,则将缓存线置为Invalid。不需要总线操作。

Exclusive状态与Shared状态很像,只有一个不同之处: 在Exclusive状态时,本地写入操作不需要在总线上声明,因为本地的缓存是系统中唯一的拷贝。这是一个巨大的优势,所以处理器会尽量将缓存线保留在Exclusive状态,而不是Shared状态。只有在信息不可用时,才退而求其次选择shared。放弃Exclusive不会引起任何功能缺失,但会导致性能下降,因为E→M要远远快于S→M。

从以上的说明中应该已经可以看出,在多处理器环境下,哪一步的代价比较大了。填充缓存的代价当然还是很高,但我们还需要留意RFO消息。一旦涉及RFO,操作就快不起来了。

RFO在两种情况下是必需的:

  • 线程从一个处理器迁移到另一个处理器,需要将所有缓存线移到新处理器。
  • 某条缓存线确实需要被两个处理器使用。「对于同一处理器的两个核心,也有同样的情况,只是代价稍低。RFO消息可能会被发送多次。」

多线程或多进程的程序总是需要同步,而这种同步依赖内存来实现。因此,有些RFO消息是合理的,但仍然需要尽量降低发送频率。除此以外,还有其它来源的RFO。在第6节中,我们将解释这些场景。缓存一致性协议的消息必须发给系统中所有处理器。只有当协议确定已经给过所有处理器响应机会之后,才能进行状态跃迁。也就是说,协议的速度取决于最长响应时间。「这也是现在能看到三插槽AMD Opteron系统的原因。这类系统只有三个超级链路(hyperlink),其中一个连接南桥,每个处理器之间都只有一跳的距离。」总线上可能会发生冲突,NUMA系统的延时很大,突发的流量会拖慢通信。这些都是让我们避免无谓流量的充足理由。

此外,关于多处理器还有一个问题。虽然它的影响与具体机器密切相关,但根源是唯一的——FSB是共享的。在大多数情况下,所有处理器通过唯一的总线连接到内存控制器(参见图2.1)。如果一个处理器就能占满总线(十分常见),那么共享总线的两个或四个处理器显然只会得到更有限的带宽。即使每个处理器有自己连接内存控制器的总线,如图2.2,但还需要通往内存模块的总线。一般情况下,这种总线只有一条。退一步说,即使像图2.2那样不止一条,对同一个内存模块的并发访问也会限制它的带宽。

对于每个处理器拥有本地内存的AMD模型来说,也是同样的问题。的确,所有处理器可以非常快速地同时访问它们自己的内存。但是,多线程呢?多进程呢?它们仍然需要通过访问同一块内存来进行同步。对同步来说,有限的带宽严重地制约着并发度。程序需要更加谨慎的设计,将不同处理器访问同一块内存的机会降到最低。以下的测试展示了这一点,还展示了与多线程代码相关的其它效果。

多线程访问

为了帮助大家理解问题的严重性,我们来看一些曲线图,主角也是前文的那个程序。只不过这一次,我们运行多个线程,并测量这些线程中最快那个的运行时间。也就是说,等它们全部运行完是需要更长时间的。我们用的机器有4个处理器,而测试是做多跑4个线程。所有处理器共享同一条通往内存控制器的总线,另外,通往内存模块的总线也只有一条。

图3.19: 顺序读操作,多线程
图3.19: 顺序读操作,多线程

图3.19展示了顺序读访问时的性能,元素为128字节长(64位计算机,NPAD=15)。对于单线程的曲线,我们预计是与图3.11相似,只不过是换了一台机器,所以实际的数字会有些小差别。

更重要的部分当然是多线程的环节。由于是只读,不会去修改内存,不会尝试同步。但即使不需要RFO,而且所有缓存线都可共享,性能仍然分别下降了18%(双线程)和34%(四线程)。由于不需要在处理器之间传输缓存,因此这里的性能下降完全由以下两个瓶颈之一或同时引起: 一是从处理器到内存控制器的共享总线,二是从内存控制器到内存模块的共享总线。当工作集超过L3后,三种情况下都要预取新元素,而即使是双线程,可用的带宽也无法满足线性扩展(无惩罚)。

当加入修改之后,场面更加难看了。图3.20展示了顺序递增测试的结果。

图3.20: 顺序递增,多线程
图3.20: 顺序递增,多线程

图中Y轴采用的是对数刻度,不要被看起来很小的差值欺骗了。现在,双线程的性能惩罚仍然是18%,但四线程的惩罚飙升到了93%!原因在于,采用四线程时,预取的流量与写回的流量加在一起,占满了整个总线。

我们用对数刻度来展示L1d范围的结果。可以发现,当超过一个线程后,L1d就无力了。单线程时,仅当工作集超过L1d时访问时间才会超过20个周期,而多线程时,即使在很小的工作集情况下,访问时间也达到了那个水平。

这里并没有揭示问题的另一方面,主要是用这个程序很难进行测量。问题是这样的,我们的测试程序修改了内存,所以本应看到RFO的影响,但在结果中,我们并没有在L2阶段看到更大的开销。原因在于,要看到RFO的影响,程序必须使用大量内存,而且所有线程必须同时访问同一块内存。如果没有大量的同步,这是很难实现的,而如果加入同步,则会占满执行时间。

图3.21: 随机的Addnextlast,多线程
图3.21: 随机的Addnextlast,多线程

最后,在图3.21中,我们展示了随机访问的Addnextlast测试的结果。这里主要是为了让大家感受一下这些巨大到爆的数字。极端情况下,甚至用了1500个周期才处理完一个元素。如果加入更多线程,真是不可想象哪。我们把多线程的效能总结了一下:

执行线程顺序读取顺序递增随机增加
21.691.691.54
42.982.071.65

这个表展示了图3.21中多线程运行大工作集时的效能。表中的数字表示测试程序在使用多线程处理大工作集时可能达到的最大加速因子。双线程和四线程的理论最大加速因子分别是2和4。从表中数据来看,双线程的结果还能接受,但四线程的结果表明,扩展到双线程以上是没有什么意义的,带来的收益可以忽略不计。只要我们把图3.21换个方式呈现,就可以很容易看清这一点。

图3.22: 通过并行化实现的加速因子
图3.22: 通过并行化实现的加速因子

图3.22中的曲线展示了加速因子,即多线程相对于单线程所能获取的性能加成值。测量值的精确度有限,因此我们需要忽略比较小的那些数字。可以看到,在L2与L3范围内,多线程基本可以做到线性加速,双线程和四线程分别达到了2和4的加速因子。但是,一旦工作集的大小超出L3,曲线就崩塌了,双线程和四线程降到了基本相同的数值(参见表3.3中第4列)。也是部分由于这个原因,我们很少看到4CPU以上的主板共享同一个内存控制器。如果需要配置更多处理器,我们只能选择其它的实现方式(参见第5节)。

可惜,上图中的数据并不是普遍情况。在某些情况下,即使工作集能够放入末级缓存,也无法实现线性加速。实际上,这反而是正常的,因为普通的线程都有一定的耦合关系,不会像我们的测试程序这样完全独立。而反过来说,即使是很大的工作集,即使是两个以上的线程,也是可以通过并行化受益的,但是需要程序员的聪明才智。我们会在第6节进行一些介绍。

特例: 超线程

由CPU实现的超线程(有时又叫对称多线程,SMT)是一种比较特殊的情况,每个线程并不能真正并发地运行。它们共享着除寄存器外的绝大多数处理资源。每个核心和CPU仍然是并行工作的,但核心上的线程则受到这个限制。理论上,每个核心可以有大量线程,不过到目前为止,Intel的CPU最多只有两个线程。CPU负责对各线程进行时分复用,但这种复用本身并没有多少厉害。它真正的优势在于,CPU可以在当前运行的超线程发生延迟时,调度另一个线程。这种延迟一般由内存访问引起。

如果两个线程运行在一个超线程核心上,那么只有当两个线程合起来**的运行时间少于单线程运行时间时,效率才会比较高。我们可以将通常先后发生的内存访问叠合在一起,以实现这个目标。有一个简单的计算公式,可以帮助我们计算如果需要某个加速因子,最少需要多少的缓存命中率。

程序的执行时间可以通过一个只有一级缓存的简单模型来进行估算(参见16):

Texe=N[(1Fmem)Tproc+Fmem(GhitTcache+(1Ghit)Tmiss)]T_{\text{exe}} = N [ (1 - F_{\text{mem}}) T_{\text{proc}} + F_{\text{mem}} (G_{\text{hit}} T_{\text{cache}} + (1 - G_{\text{hit}}) T_{\text{miss}}) ]

各变量的含义如下:

N=指令数Fmem=N 次中存取memory的比率Ghit=载入次数中命中cache的比率Tproc=每个指令的周期数Tcache=cache命中的周期数Tmiss=cache错失的周期数Texe=程序执行时间\begin{aligned} N &= \text{指令数} \\ F_{\text{mem}} &= N \text{ 次中存取memory的比率} \\ G_{\text{hit}} &= \text{载入次数中命中cache的比率} \\ T_{\text{proc}} &= \text{每个指令的周期数} \\ T_{\text{cache}} &= \text{cache命中的周期数} \\ T_{\text{miss}} &= \text{cache错失的周期数} \\ T_{\text{exe}} &= \text{程序执行时间} \end{aligned}

为了要让使用两条执行线程有任何意义,两条执行线程任一的执行时间都必须至多为单执行线程的一半。两者都有一个唯一的变量缓存命中数。如果我们要解决最小缓存命中率相等的问题需要使我们获得的线程的执行率不少于50%或更多,如图 3.23.

图 3.23: 最小缓存命中率-加速
图 3.23: 最小缓存命中率-加速

X轴表示单线程指令的缓存命中率 GhitG_{hit} ,Y轴表示多线程指令所需的缓存命中率。这个值永远不能高于单线程命中率,否则,单线程指令也会使用改良的指令。为了使单线程的命中率在低于55%的所有情况下优于使用多线程,cup要或多或少的足够空闲因为缓存丢失会运行另外一个超线程。

绿色区域是我们的目标。如果线程的速度没有慢过50%,而每个线程的工作量只有原来的一半,那么它们合起来的耗时应该会少于单线程的耗时。对我们用的示例系统来说(使用超线程的P4机器),如果单线程代码的命中率为60%,那么多线程代码至少要达到10%才能获得收益。这个要求一般来说还是可以做到的。但是,如果单线程代码的命中率达到了95%,那么多线程代码要做到80%才行。这就很难了。而且,这里还涉及到超线程,在两个超线程的情况下,每个超线程只能分到一半的有效缓存。因为所有超线程是使用同一个缓存来装载数据的,如果两个超线程的工作集没有重叠,那么原始的95%也会被打对折——47%,远低于80%。

因此,超线程只在某些情况下才比较有用。单线程代码的缓存命中率必须低到一定程度,从而使缓存容量变小时新的命中率仍能满足要求。只有在这种情况下,超线程才是有意义的。在实践中,采用超线程能否获得更快的结果,取决于处理器能否有效地将每个进程的等待时间与其它进程的执行时间重叠在一起。并行化也需要一定的开销,需要加到总的运行时间里,这个开销往往是不能忽略的。

在6.3.4节中,我们会介绍一种技术,它将多个线程通过公用缓存紧密地耦合起来。这种技术适用于许多场合,前提是程序员们乐意花费时间和精力扩展自己的代码。

如果两个超线程执行完全不同的代码(两个线程就像被当成两个处理器,分别执行不同进程),那么缓存容量就真的会降为一半,导致缓冲未命中率大大攀升,这一点应该是很清楚的。这样的调度机制是很有问题的,除非你的缓存足够大。所以,除非程序的工作集设计得比较合理,能够确实从超线程获益,否则还是建议在BIOS中把超线程功能关掉。「我们可能会因为另一个原因开启超线程,那就是调试,因为SMT在查找并行代码的问题方面真的非常好用。」

其它细节

我们已经介绍了地址的组成,即标签、集合索引和偏移三个部分。那么,实际会用到什么样的地址呢?目前,处理器一般都向进程提供虚拟地址空间,意味着我们有两种不同的地址: 虚拟地址和物理地址。

虚拟地址有个问题——并不唯一。随着时间的变化,虚拟地址可以变化,指向不同的物理地址。同一个地址在不同的进程里也可以表示不同的物理地址。那么,是不是用物理地址会比较好呢?

问题是,处理器指令用的虚拟地址,而且需要在内存管理单元(MMU)的协助下将它们翻译成物理地址。这并不是一个很小的操作。在执行指令的管线(pipeline)中,物理地址只能在很后面的阶段才能得到。这意味着,缓存逻辑需要在很短的时间里判断地址是否已被缓存过。而如果可以使用虚拟地址,缓存查找操作就可以更早地发生,一旦命中,就可以马上使用内存的内容。结果就是,使用虚拟内存后,可以让管线把更多内存访问的开销隐藏起来。

处理器的设计人员们现在使用虚拟地址来标记第一级缓存。这些缓存很小,很容易被清空。在进程页表树发生变更的情况下,至少是需要清空部分缓存的。如果处理器拥有指定变更地址范围的指令,那么可以避免缓存的完全刷新。由于一级缓存L1i及L1d的时延都很小(~3周期),基本上必须使用虚拟地址。

对于更大的缓存,包括L2和L3等,则需要以物理地址作为标签。因为这些缓存的时延比较大,虚拟到物理地址的映射可以在允许的时间里完成,而且由于主存时延的存在,重新填充这些缓存会消耗比较长的时间,刷新的代价比较昂贵。

一般来说,我们并不需要了解这些缓存处理地址的细节。我们不能更改它们,而那些可能影响性能的因素,要么是应该避免的,要么是有很高代价的。填满缓存是不好的行为,缓存线都落入同一个集合,也会让缓存早早地出问题。对于后一个问题,可以通过缓存虚拟地址来避免,但作为一个用户级程序,是不可能避免缓存物理地址的。我们唯一可以做的,是尽最大努力不要在同一个进程里用多个虚拟地址映射同一个物理地址。

另一个细节对程序员们来说比较乏味,那就是缓存的替换策略。大多数缓存会优先逐出最近最少使用(Least Recently Used,LRU)的元素。这往往是一个效果比较好的策略。在关联性很大的情况下(随着以后核心数的增加,关联性势必会变得越来越大),维护LRU列表变得越来越昂贵,于是我们开始看到其它的一些策略。

在缓存的替换策略方面,程序员可以做的事情不多。如果缓存使用物理地址作为标签,我们是无法找出虚拟地址与缓存集之间关联的。有可能会出现这样的情形: 所有逻辑页中的缓存线都映射到同一个缓存集,而其它大部分缓存却空闲着。即使有这种情况,也只能依靠OS进行合理安排,避免频繁出现。

虚拟化的出现使得这一切变得更加复杂。现在不仅操作系统可以控制物理内存的分配。虚拟机监视器(VMM,也称为 hypervisor)也负责分配内存。

对程序员来说,最好 a) 完全使用逻辑内存页面 b) 在有意义的情况下,使用尽可能大的页面大小来分散物理地址。更大的页面大小也有其他好处,不过这是另一个话题(见第4节)。

指令缓存

其实,不光处理器使用的数据被缓存,它们执行的指令也是被缓存的。只不过,指令缓存的问题相对来说要少得多,因为:

  • 执行的代码量取决于代码大小。而代码大小通常取决于问题复杂度。问题复杂度则是固定的。
  • 程序的数据处理逻辑是程序员设计的,而程序的指令却是编译器生成的。编译器的作者知道如何生成优良的代码。
  • 程序的流向比数据访问模式更容易预测。现如今的CPU很擅长模式检测,对预取很有利。
  • 代码永远都有良好的时间局部性和空间局部性。

有一些准则是需要程序员们遵守的,但大都是关于如何使用工具的,我们会在第6节介绍它们。而在这里我们只介绍一下指令缓存的技术细节。

随着CPU的核心频率大幅上升,缓存与核心的速度差越拉越大,CPU的处理开始管线化。也就是说,指令的执行分成若干阶段。首先,对指令进行解码,随后,准备参数,最后,执行它。这样的管线可以很长(例如,Intel的Netburst架构超过了20个阶段)。在管线很长的情况下,一旦发生延误(即指令流中断),需要很长时间才能恢复速度。管线延误发生在这样的情况下: 下一条指令未能正确预测,或者装载下一条指令耗时过长(例如,需要从内存读取时)。

为了解决这个问题,CPU的设计人员们在分支预测上投入大量时间和晶片面积,以降低管线延误的出现频率。

在CISC处理器上,指令的解码阶段也需要一些时间。x86及x86-64处理器尤为严重。近年来,这些处理器不再将指令的原始字节序列存入L1i,而是缓存解码后的版本。这样的L1i被叫做“追踪缓存(trace cache)”。追踪缓存可以在命中的情况下让处理器跳过管线最初的几个阶段,在管线发生延误时尤其有用。

前面说过,L2以上的缓存是统一缓存,既保存代码,也保存数据。显然,这里保存的代码是原始字节序列,而不是解码后的形式。

在提高性能方面,与指令缓存相关的只有很少的几条准则:

  • 生成尽量少的代码。也有一些例外,如出于管线化的目的需要更多的代码,或使用小代码会带来过高的额外开销。
  • 尽量帮助处理器作出良好的预取决策,可以通过代码布局或显式预取来实现。
  • 这些准则一般会由编译器的代码生成阶段强制执行。至于程序员可以参与的部分,我们会在第6节介绍。

自我修改的程序码

在计算机的早期岁月里,内存十分昂贵。人们想尽千方百计,只为了尽量压缩程序容量,给数据多留一些空间。其中,有一种方法是修改程序自身,称为自修改代码(SMC)。现在,有时候我们还能看到它,一般是出于提高性能的目的,也有的是为了攻击安全漏洞。

一般情况下,应该避免SMC。虽然一般情况下没有问题,但有时会由于执行错误而出现性能问题。显然,发生改变的代码是无法放入追踪缓存(追踪缓存放的是解码后的指令)的。即使没有使用追踪缓存(代码还没被执行或有段时间没执行),处理器也可能会遇到问题。如果某个进入管线的指令发生了变化,处理器只能扔掉目前的成果,重新开始。在某些情况下,甚至需要丢弃处理器的大部分状态。

最后,由于处理器认为代码页是不可修改的(这是出于简单化的考虑,而且在99.9999999%情况下确实是正确的),L1i用到并不是MESI协议,而是一种简化后的SI协议。这样一来,如果万一检测到修改的情况,就需要作出大量悲观的假设。

因此,对于SMC,强烈建议能不用就不用。现在内存已经不再是一种那么稀缺的资源了。最好是写多个函数,而不要根据需要把一个函数改来改去。也许有一天可以把SMC变成可选项,我们就能通过这种方式检测入侵代码。如果一定要用SMC,应该让写操作越过缓存,以免由于L1i需要L1d里的数据而产生问题。更多细节,请参见6.1节。

在Linux上,判断程序是否包含SMC是很容易的。利用正常工具链(toolchain)构建的程序代码都是写保护(write-protected)的。程序员需要在链接时施展某些关键的魔术才能生成可写的代码页。现代的Intel x86和x86-64处理器都有统计SMC使用情况的专用计数器。通过这些计数器,我们可以很容易判断程序是否包含SMC,即使它被准许运行。

缓存未命中的因素

我们已经看过内存访问没有命中缓存时,那陡然猛涨的高昂代价。但是有时候,这种情况又是无法避免的,因此我们需要对真正的代价有所认识,并学习如何缓解这种局面。

缓存与内存带宽

为了更好地理解处理器的能力,我们测量了各种理想环境下能够达到的带宽值。由于不同处理器的版本差别很大,所以这个测试比较有趣,也因为如此,这一节都快被测试数据灌满了。我们使用了x86和x86-64处理器的SSE指令来装载和存储数据,每次16字节。工作集则与其它测试一样,从1kB增加到512MB,测量的具体对象是每个周期所处理的字节数。

图 3.24:P4 的带宽
图 3.24:P4 的带宽

图3.24展示了一颗64位Intel Netburst处理器的性能图表。当工作集能够完全放入L1d时,处理器的每个周期可以读取完整的16字节数据,即每个周期执行一条装载指令(moveaps指令,每次移动16字节的数据)。测试程序并不对数据进行任何处理,只是测试读取指令本身。当工作集增大,无法再完全放入L1d时,性能开始急剧下降,跌至每周期6字节。在 2182^{18} 工作集处出现的台阶是由于DTLB缓存耗尽,因此需要对每个新页施加额外处理。由于这里的读取是按顺序的,预取机制可以完美地工作,而FSB能以5.3字节/周期的速度传输内容。但预取的数据并不进入L1d。当然,真实世界的程序永远无法达到以上的数字,但我们可以将它们看作一系列实际上的极限值。

更令人惊讶的是写操作和复制操作的性能。即使是在很小的工作集下,写操作也始终无法达到4字节/周期的速度。这意味着,Intel为Netburst处理器的L1d选择了直写(write-through)模式,所以写入性能受到L2速度的限制。同时,这也意味着,复制测试的性能不会比写入测试差太多(复制测试是将某块内存的数据拷贝到另一块不重叠的内存区),因为读操作很快,可以与写操作实现部分重叠。最值得关注的地方是,两个操作在工作集无法完全放入L2后出现了严重的性能滑坡,降到了0.5字节/周期!比读操作慢了10倍!显然,如果要提高程序性能,优化这两个操作更为重要。

再来看图3.25,它来自同一颗处理器,只是运行双线程,每个线程分别运行在处理器的一个超线程上。

图3.25: P4开启两个超线程时的带宽表现
图3.25: P4开启两个超线程时的带宽表现

图3.25采用了与图3.24相同的刻度,以方便比较两者的差异。图3.25中的曲线抖动更多,是由于采用双线程的缘故。结果正如我们预期,由于超线程共享着几乎所有资源(仅除寄存器外),所以每个超线程只能得到一半的缓存和带宽。所以,即使每个线程都要花上许多时间等待内存,从而把执行时间让给另一个线程,也是无济于事——因为另一个线程也同样需要等待。这里恰恰展示了使用超线程时可能出现的最坏情况。

图3.26: Core 2的带宽表现
图3.26: Core 2的带宽表现
图3.27: Core 2运行双线程时的带宽表现
图3.27: Core 2运行双线程时的带宽表现

再来看Core 2处理器的情况。看看图3.26和图3.27,再对比下P4的图3.24和3.25,可以看出不小的差异。Core 2是一颗双核处理器,有着共享的L2,容量是P4 L2的4倍。但更大的L2只能解释写操作的性能下降出现较晚的现象。

当然还有更大的不同。可以看到,读操作的性能在整个工作集范围内一直稳定在16字节/周期左右,在 2202^{20} 处的下降同样是由于DTLB的耗尽引起。能够达到这么高的数字,不但表明处理器能够预取数据,并且按时完成传输,而且还意味着,预取的数据是被装入L1d的。

写/复制操作的性能与P4相比,也有很大差异。处理器没有采用直写策略,写入的数据留在L1d中,只在必要时才逐出。这使得写操作的速度可以逼近16字节/周期。一旦工作集超过L1d,性能即飞速下降。由于Core 2读操作的性能非常好,所以两者的差值显得特别大。当工作集超过L2时,两者的差值甚至超过20倍!但这并不表示Core 2的性能不好,相反,Core 2永远都比Netburst强。

在图3.27中,启动双线程,各自运行在Core 2的一个核心上。它们访问相同的内存,但不需要完美同步。从结果上看,读操作的性能与单线程并无区别,只是多了一些多线程情况下常见的抖动。

有趣的地方来了——当工作集小于L1d时,写操作与复制操作的性能很差,就好像数据需要从内存读取一样。两个线程彼此竞争着同一个内存位置,于是不得不频频发送RFO消息。问题的根源在于,虽然两个核心共享着L2,但无法以L2的速度处理RFO请求。而当工作集超过L1d后,性能出现了迅猛提升。这是因为,由于L1d容量不足,于是将被修改的条目刷新到共享的L2。由于L1d的未命中可以由L2满足,只有那些尚未刷新的数据才需要RFO,所以出现了这样的现象。这也是这些工作集情况下速度下降一半的原因。这种渐进式的行为也与我们期待的一致: 由于每个核心共享着同一条FSB,每个核心只能得到一半的FSB带宽,因此对于较大的工作集来说,每个线程的性能大致相当于单线程时的一半。

由于同一个厂商的不同处理器之间都存在着巨大差异,我们没有理由不去研究一下其它厂商处理器的性能。图3.28展示了AMD家族10h Opteron处理器的性能。这颗处理器有64kB的L1d、512kB的L2和2MB的L3,其中L3缓存由所有核心所共享。

图3.28: AMD家族10h Opteron的带宽表现
图3.28: AMD家族10h Opteron的带宽表现

大家首先应该会注意到,在L1d缓存足够的情况下,这个处理器每个周期能处理两条指令。读操作的性能超过了32字节/周期,写操作也达到了18.7字节/周期。但是,不久,读操作的曲线就急速下降,跌到2.3字节/周期,非常差。处理器在这个测试中并没有预取数据,或者说,没有有效地预取数据。

另一方面,写操作的曲线随几级缓存的容量而流转。在L1d阶段达到最高性能,随后在L2阶段下降到6字节/周期,在L3阶段进一步下降到2.8字节/周期,最后,在工作集超过L3后,降到0.5字节/周期。它在L1d阶段超过了Core 2,在L2阶段基本相当(Core 2的L2更大一些),在L3及主存阶段比Core 2慢。

复制的性能既无法超越读操作的性能,也无法超越写操作的性能。因此,它的曲线先是被读性能压制,随后又被写性能压制。

图3.29显示的是Opteron处理器在多线程时的性能表现。

图3.29: AMD Fam 10h在双线程时的带宽表现
图3.29: AMD Fam 10h在双线程时的带宽表现

读操作的性能没有受到很大的影响。每个线程的L1d和L2表现与单线程下相仿,L3的预取也依然表现不佳。两个线程并没有过渡争抢L3。问题比较大的是写操作的性能。两个线程共享的所有数据都需要经过L3,而这种共享看起来却效率很差。即使是在L3足够容纳整个工作集的情况下,所需要的开销仍然远高于L3的访问时间。再来看图3.27,可以发现,在一定的工作集范围内,Core 2处理器能以共享的L2缓存的速度进行处理。而Opteron处理器只能在很小的一个范围内实现相似的性能,而且,它仅仅只能达到L3的速度,无法与Core 2的L2相比。

关键字加载

内存以比缓存线还小的块从主存储器向缓存传送。如今64位可一次性传送,缓存线的大小为64或128比特。这意味着每个缓存线需要8或16次传送。

DRAM芯片可以以触发模式传送这些64位的块。这使得不需要内存控制器的进一步指令和可能伴随的延迟,就可以将缓存线充满。如果处理器预取了缓存,这有可能是最好的操作方式。

如果程序在访问数据或指令缓存时没有命中(这可能是强制性未命中或容量性未命中,前者是由于数据第一次被使用,后者是由于容量限制而将缓存线逐出),情况就不一样了。程序需要的并不总是缓存线中的第一个字,而数据块的到达是有先后顺序的,即使是在突发模式和双倍传输率下,也会有明显的时间差,一半在4个CPU周期以上。举例来说,如果程序需要缓存线中的第8个字,那么在首字抵达后它还需要额外等待30个周期以上。

当然,这样的等待并不是必需的。事实上,内存控制器可以按不同顺序去请求缓存线中的字。当处理器告诉它,程序需要缓存中具体某个字,即「关键字(critical word)」时,内存控制器就会先请求这个字。一旦请求的字抵达,虽然缓存线的剩余部分还在传输中,缓存的状态还没有达成一致,但程序已经可以继续运行。这种技术叫做关键字优先及较早重启(Critical Word First & Early Restart)。

现在的处理器都已经实现了这一技术,但有时无法运用。比如,预取操作的时候,并不知道哪个是关键字。如果在预取的中途请求某条缓存线,处理器只能等待,并不能更改请求的顺序。

图3.30: 关键字位于缓存线尾时的表现
图3.30: 关键字位于缓存线尾时的表现

在关键字优先技术生效的情况下,关键字的位置也会影响结果。图3.30展示了下一个测试的结果,图中表示的是关键字分别在线首和线尾时的性能对比情况。元素大小为64字节,等于缓存线的长度。图中的噪声比较多,但仍然可以看出,当工作集超过L2后,关键字处于线尾情况下的性能要比线首情况下低0.7%左右。而顺序访问时受到的影响更大一些。这与我们前面提到的预取下条线时可能遇到的问题是相符的。

缓存配置

缓存放置的位置与超线程,内核和处理器之间的关系,不在程序员的控制范围之内。但是程序员可以决定线程执行的位置,于是cache如何与使用的 CPU 共处就变得很重要。

这里我们将不会深入(探讨)什么时候选择什么样的内核以运行线程的细节。我们仅仅描述了在设置线程亲和性的时候,程序员需要考虑的系统结构的细节。

超线程,通过定义,共享除去寄存器集以外的所有数据。包括 L1 缓存。这里没有什么可以多说的。多核处理器的独立核心带来了一些乐趣。每个核心都至少拥有自己的 L1 缓存。除此之外,下面列出了一些不同的特性:

  • 早期多核心处理器有独立的 L2 缓存且没有更高层级的缓存。
  • 之后英特尔的双核心处理器模型拥有共享的L2 缓存。对四核处理器,则分对拥有独立的L2 缓存,且没有更高层级的缓存。
  • AMD 家族的 10h 处理器有独立的 L2 缓存以及一个统一的L3 缓存。

关于各种处理器模型的优点,已经在它们各自的宣传手册里写得够多了。在每个核心的工作集互不重叠的情况下,独立的L2拥有一定的优势,单线程的程序可以表现优良。考虑到目前实际环境中仍然存在大量类似的情况,这种方法的表现并不会太差。不过,不管怎样,我们总会遇到工作集重叠的情况。如果每个缓存都保存着某些通用运行库的常用部分,那么很显然是一种浪费。

如果像Intel的双核处理器那样,共享除L1外的所有缓存,则会有一个很大的优点。如果两个核心的工作集重叠的部分较多,那么综合起来的可用缓存容量会变大,从而允许容纳更大的工作集而不导致性能的下降。如果两者的工作集并不重叠,那么则是由Intel的高级智能缓存管理(Advanced Smart Cache management)发挥功用,防止其中一个核心垄断整个缓存。

即使每个核心只使用一半的缓存,也会有一些摩擦。缓存需要不断衡量每个核心的用量,在进行逐出操作时可能会作出一些比较差的决定。我们来看另一个测试程序的结果。

图3.31: 两个进程的带宽表现
图3.31: 两个进程的带宽表现

这次,测试程序两个进程,第一个进程不断用SSE指令读/写2MB的内存数据块,选择2MB,是因为它正好是Core 2处理器L2缓存的一半,第二个进程则是读/写大小变化的内存区域,我们把这两个进程分别固定在处理器的两个核心上。图中显示的是每个周期读/写的字节数,共有4条曲线,分别表示不同的读写搭配情况。例如,标记为读/写(read/write)的曲线代表的是后台进程进行写操作(固定2MB工作集),和一个读取可变工作集、用于测量的进程。

图中最有趣的是 2202^{20}2232^{23} 之间的部分。如果两个核心的L2是完全独立的,那么所有4种情况下的性能下降均应发生在 2212^{21}2222^{22} 之间,也就是L2缓存耗尽的时候。但从图上来看,实际情况并不是这样,特别是后台进程进行写操作时尤为明显。当工作集达到1MB( 2202^{20} )时,性能即出现恶化,两个进程并没有共享内存,因此并不会产生RFO消息。所以,完全是缓存逐出操作引起的问题。目前这种智能的缓存处理机制有一个问题,每个核心能实际用到的缓存更接近1MB,而不是理论上的2MB。如果未来的处理器仍然保留这种多核共享缓存模式的话,我们唯有希望厂商会把这个问题解决掉。

推出拥有双L2缓存的4核处理器仅仅只是一种临时措施,是开发更高级缓存之前的替代方案。与独立插槽及双核处理器相比,这种设计并没有带来多少性能提升。两个核心是通过同一条总线(被外界看作FSB)进行通信,并没有什么特别快的数据交换通道。

未来,针对多核处理器的缓存将会包含更多层次。AMD的10h家族是一个开始,至于会不会有更低级共享缓存的出现,还需要我们拭目以待。我们有必要引入更多级别的缓存,因为频繁使用的高速缓存不可能被许多核心共用,否则会对性能造成很大的影响。我们也需要更大的高关联性缓存,它们的数量、容量和关联性都应该随着共享核心数的增长而增长。巨大的L3和适度的L2应该是一种比较合理的选择。L3虽然速度较慢,但也较少使用。

对于程序员来说,不同的缓存设计就意味着调度决策时的复杂性。为了达到最高的性能,我们必须掌握工作负载的情况,必须了解机器架构的细节。好在我们在判断机器架构时还是有一些支援力量的,我们会在后面的章节介绍这些接口。

FSB 的影响

FSB在性能中扮演了核心角色。缓存数据的存取速度受制于内存通道的速度。我们做一个测试,在两台机器上分别跑同一个程序,这两台机器除了内存模块的速度有所差异,其它完全相同。图3.32展示了Addnext0测试(将下一个元素的pad[0]加到当前元素的pad[0]上)在这两台机器上的结果(NPAD=7,64位机器)。两台机器都采用Core 2处理器,一台使用667MHz的DDR2内存,另一台使用800MHz的DDR2内存(比前一台增长20%)。

图3.32: FSB速度的影响
图3.32: FSB速度的影响

图上的数字表明,当工作集大到对FSB造成压力的程度时,高速FSB确实会带来巨大的优势。在我们的测试中,性能的提升达到了18.5%,接近理论上的极限。而当工作集比较小,可以完全纳入缓存时,FSB的作用并不大。当然,这里我们只测试了一个程序的情况,在实际环境中,系统往往运行多个进程,工作集是很容易超过缓存容量的。

如今,一些英特尔的处理器,支持前端总线(FSB)的速度高达1,333 MHz,这意味着速度有另外60%的提升。将来还会出现更高的速度。速度是很重要的,工作集会更大,快速的RAM和高FSB速度的内存肯定是值得投资的。我们必须小心使用它,因为即使处理器可以支持更高的前端总线速度,但是主板的北桥芯片可能不会。使用时,检查它的规范是至关重要的。

后续翻译见『What Every Programmer Should Know About Memory (2)』

参考

  • [11] Joe Gebis and David Patterson. Embracing and Extending 20th-Century Instruction Set Architectures. Computer, 40(4):68–75, April 2007. 8.4
  • [18] Double Data Rate (DDR) SDRAM MT46V. Micron Technology, 2003. Rev. L 6/06 EN.