第2章

Cache和内存

2.1 存储系统简介

一般而言,存储系统不仅仅指用于存储数据的磁盘、磁带和光盘存储器等,还包括内存和CPU内部的Cache。当处理完毕之后,系统还要提供数据存储的服务。存储系统的性能和系统的处理能力息息相关,如果CPU性能很好,处理速度很快,但是配备的存储系统吞吐率不够或者性能不够好,那CPU也只能处于忙等待,从而导致处理数据的能力下降。接下来本章会讨论Cache和内存,对于磁盘和磁带等永久性存储系统,在此不作讨论。

2.1.1 系统架构的演进

在当今时代,一个处理器通常包含多个核心(Core),集成Cache子系统,内存子系统通过内部或外部总线与其通信。

在经典计算机系统中一般都有两个标准化的部分:北桥(North Bridge)和南桥(South Bridge)。它们是处理器和内存以及其他外设沟通的渠道。处理器和内存系统通过前端总线(Front Side Bus, FSB)相连,当处理器需要读取或者写回数据时,就通过前端总线和内存控制器通信。图2-1给出了处理器、内存、南北桥以及其他总线之间的关系。

图2-1 计算机系统中的南北桥示意图

我们可以看到,该架构所有的处理器共用一条前端总线与北桥相连。北桥也称为主桥(Host Bridge),主要用来处理高速信号,通常负责与处理器的联系,并控制内存AGP、PCI数据在北桥内部传输。而北桥中往往集成了一个内存控制器(最近几年英特尔的处理器已经把内存控制器集成到了处理器内部),根据不同的内存,比如SRAM、DRAM、SDRAM,集成的内存控制器也不一样。南桥也称为IO桥(IO bridge),负责I/O总线之间的通信,比如PCI总线、SATA、USB等,可以连接光驱、硬盘、键盘灯设备交换数据。在这种系统中,所有的数据交换都需要通过北桥:

1)处理器访问内存需要通过北桥。

2)处理器访问所有的外设都需要通过北桥。

3)处理器之间的数据交换也需要通过北桥。

4)挂在南桥的所有设备访问内存也需要通过北桥。

可以看出,这种系统的瓶颈就在北桥中。当北桥出现拥塞时,所有的设备和处理器都要瘫痪。这种系统设计的另外一个瓶颈体现在对内存的访问上。不管是处理器或者显卡,还是南桥的硬盘、网卡或者光驱,都需要频繁访问内存,当这些设备都争相访问内存时,增大了对北桥带宽的竞争,而且北桥到内存之间也只有一条总线。

为了改善对内存的访问瓶颈,出现了另外一种系统设计,内存控制器并没有被集成在北桥中,而是被单独隔离出来以协调北桥与某个相应的内存之间的交互,如图2-2所示。这样的话,北桥可以和多个内存相连。

图2-2 更为复杂的南北桥示意图

图2-2所示的这种架构增加了内存的访问带宽,缓解了不同设备对同一内存访问的拥塞问题,但是却没有改进单一北桥芯片的瓶颈的问题。

为了解决这个瓶颈,产生了如图2-3所示的NUMA(Non-Uniform Memory Architecture,非一致性内存架构)系统。

图2-3NUMA系统

在这种架构下,在一个配有四核的机器中,不需要一个复杂的北桥就能将内存带宽增加到以前的四倍。当然,这样的架构也存在缺点。该系统中,访问内存所花的时间和处理器相关。之所以和处理器相关是因为该系统每个处理器都有本地内存(Local memory),访问本地内存的时间很短,而访问远程内存(remote memory),即其他处理器的本地内存,需要通过额外的总线!对于某个处理器来说,当其要访问其他的内存时,轻者要经过另外一个处理器,重者要经过2个处理器,才能达到访问非本地内存的目的,因此内存与处理器的“距离”不同,访问的时间也有所差异,对于NUMA,后续章节会给出更详细的介绍。

2.1.2 内存子系统

为了了解内存子系统,首先需要解释一下和内存相关的常用用语。

1)RAM(Random Access Memory):随机访问存储器

2)SRAM(Static RAM):静态随机访问存储器

3)DRAM(Dynamic RAM):动态随机访问存储器。

4)SDRAM(Synchronous DRAM):同步动态随机访问存储器。

5)DDR(Double Data Rate SDRAM):双数据速率SDRAM。

6)DDR2:第二代DDR。

7)DDR3:第三代DDR。

8)DDR4:第四代DDR。

1. SRAM

SRAM内部有一块芯片结构维持信息,通常非常快,但是成本相对DRAM很高,应用时容量不会很大,因而不能作用系统的主要内存。一般处理器内部的Cache就是采用SRAM。

2. DRAM

DRAM通常是系统的主要内存,动态表示信息是存储在集成电路的电容器内的,由于电容器会自动放电,为了避免数据丢失,需要定期充电。通常,内存控制器会负责定期充电的操作。不过随着更好技术的提出,该技术已经被淘汰。

3. SDRAM

一般DRAM都是采用异步时钟进行同步,而SDRAM则是采用同步时钟进行同步。通常,采用SDRAM结构的系统会使处理器和内存通过一个相同的时钟锁在一起,从而使处理器和内存能够共享一个时钟周期,以相同的速度同步工作。该时钟会驱动一个内部的有限状态机,能够采用流水线的方式处理多个读写请求。

SDRAM采用分布式架构,内含多个存储块(Bank),在一个时钟周期内,它能够独立地访问每个存储块,从而可以多次进行读写操作,增加了内存系统的吞吐率。

SDRAM技术广泛用在计算机行业中,随着该技术的提出,又出现了DDR(也称为DDR1), DDR2, DDR3。最新的DDR4技术标准也在2014年下半年发布。

2.2 Cache系统简介

随着计算机行业的飞速发展,CPU的速度和内存的大小都发生了翻天覆地的变化。英特尔公司在1982年推出80286芯片的时候,处理器内部含有13.4万个晶体管,时钟频率只有6MHz,内部和外部数据总线只有16位,地址总线24位,可寻址内存大小16MB。

而英特尔公司在2014年推出的Haswell处理器的时候,处理器内部仅处理器本身就包含了17亿个晶体管,还不包括Cache和GPU这种复杂部件。时钟频率达到3.8GHz,数据总线和地址总线也都扩展到了64位,可以寻址的内存大小也已经开始以TB(1T=1024GB)计算。

在处理器速度不断增加的形势下,处理器处理数据的能力也得到大大提升。但是,数据是存储在内存中的,虽然随着DDR2、DDR3、DDR4的新技术不断推出,内存的吞吐率得到了大大提升,但是相对于处理器来讲,仍然非常慢。一般来讲,处理器要从内存中直接读取数据都要花大概几百个时钟周期,在这几百个时钟周期内,处理器除了等待什么也不能做。在这种环境下,才提出了Cache的概念,其目的就是为了匹配处理器和内存之间存在的巨大的速度鸿沟。

2.2.1 Cache的种类

一般来讲,Cache由三级组成,之所以对Cache进行分级,也是从成本和生产工艺的角度考虑的。一级(L1)最快,但是容量最小;三级(LLC, Last Level Cache)最慢,但是容量最大,在早期计算机系统中,这一级Cache也可以省略。不过在当今时代,大多数处理器都会包含这一级Cache。

Cache是一种SRAM,在早期计算机系统中,一般一级和二级Cache集成在处理器内部,三级Cache集成在主板上,这样做的主要原因是生产工艺的问题,处理器内部能够集成的晶体管数目有限,而三级Cache又比较大,从而占用的晶体管数量较多。以英特尔最新的Haswell i7-5960X为例,一级Cache有32K,二级有512K,但是三级却有20M,在早期计算机系统中集成如此大的SRAM实在是很难做到。不过随着90nm、45nm、32nm以及22nm工艺的推出,处理器内部能够容纳更多的晶体管,所以三级Cache也慢慢集成到处理器内部了。

图2-4是一个简单的Cache系统逻辑示意图。

一级Cache,一般分为数据Cache和指令Cache,数据Cache用来存储数据,而指令Cache用于存放指令。这种Cache速度最快,一般处理器只需要3~5个指令周期就能访问到数据,因此成本高,容量小,一般都只有几十KB。在多核处理器内部,每个处理器核心都拥有仅属于自己的一级Cache。

二级Cache,和一级Cache分为数据Cache和指令Cache不同,数据和指令都无差别地存放在一起。速度相比一级Cache慢一些,处理器大约需要十几个处理器周期才能访问到数据,容量也相对来说大一些,一般有几百KB到几MB不等。在多核处理器内部,每个处理器核心都拥有仅属于自己的二级Cache。

图2-4 Cache系统示意图

三级Cache,速度更慢,处理器需要几十个处理器周期才能访问到数据,容量更大,一般都有几MB到几十个MB。在多核处理器内部,三级Cache由所有的核心所共有。这样的共享方式,其实也带来一个问题,有的处理器可能会极大地占用三级Cache,导致其他处理器只能占用极小的容量,从而导致Cache不命中,性能下降。因此,英特尔公司推出了Intel®CAT技术,确保有一个公平,或者说软件可配置的算法来控制每个核心可以用到的Cache大小。在此,本书就不再赘述。

对于各级Cache的访问时间,在英特尔的处理器上一直都保持着非常稳定,一级Cache访问是4个指令周期,二级Cache是12个指令周期,三级Cache则是26~31个指令周期。这里所谓的稳定,是指在不同频率、不同型号的英特尔处理器上,处理器访问这三级Cache所花费的指令周期数是相同的。请参照[Ref2-2]。

除了上述的Cache种类之外,还包含一些其他类型,接下来的章节会接着介绍。

2.2.2 TLB Cache

在早期计算机系统中,程序员都是直接访问物理地址进行编程,当程序出现错误时,整个系统都瘫痪掉;或者在多进程系统中,当一个进程出现问题,对属于另外一个进程的数据或者指令区域进行写操作,会导致另外一个进程崩溃。因此,随着计算机技术的进步,虚拟地址和分段分页技术被提出来用来保护脆弱的软件系统。软件使用虚拟地址访问内存,而处理器负责虚拟地址到物理地址的映射工作。为了完成映射工作,处理器采用多级页表来进行多次查找最终找到真正的物理地址。当处理器发现页表中找不到真正对应的物理地址时,就会发出一个异常,挂起寻址错误的进程,但是其他进程仍然可以正常工作。

页表也存储在内存中,处理器虽然可以利用三级Cache系统来缓存页表内容,但是基于两点原因不能这样做。一种原因下面的段落会讲到,我们先讲另外一个原因。处理器每当进行寻址操作都要进行一次映射工作,这使得处理器访问页表的频率非常得高,有可能一秒钟需要访问几万次。因此,即使Cache的命中率能够达到99%以上,也就是说不命中率有1%,那么不命中的概率每秒也有几百次,这会导致处理器在单位时间内访问内存(因为Cache没有命中,只能访问内存)的次数增多,降低了系统的性能。

因此,TLB(Translation Look-aside Buffer)Cache应运而生,专门用于缓存内存中的页表项。TLB一般都采用相连存储器或者按内容访问存储器(CAM, Content Addressable Memory)。相连存储器使用虚拟地址进行搜索,直接返回对应的物理地址,相对于内存中的多级页表需要多次访问才能得到最终的物理地址,TLB查找无疑大大减少了处理器的开销,这也是上文提到的第二个原因。如果需要的地址在TLB Cache中,相连存储器迅速返回结果,然后处理器用该物理地址访问内存,这样的查找操作也称为TLB命中;如果需要的地址不在TLB Cache中,也就是不命中,处理器就需要到内存中访问多级页表,才能最终得到物理地址。

2.3 Cache地址映射和变换

Cache的容量一般都很小,即使是最大的三级Cache(L3)也只有20MB~30MB。而当今内存的容量都是以GB作为单位,在一些服务器平台上,则都是以TB(1TB=1024GB)作为单位。在这种情况下,如何把内存中的内容存放到Cache中去呢?这就需要一个映射算法和一个分块机制。

分块机制就是说,Cache和内存以块为单位进行数据交换,块的大小通常以在内存的一个存储周期中能够访问到的数据长度为限。当今主流块的大小都是64字节,因此一个Cache line就是指64个字节大小的数据块。

而映射算法是指把内存地址空间映射到Cache地址空间。具体来说,就是把存放在内存中的内容按照某种规则装入到Cache中,并建立内存地址与Cache地址之间的对应关系。当内容已经装入到Cache之后,在实际运行过程中,当处理器需要访问这个数据块内容时,则需要把内存地址转换成Cache地址,从而在Cache中找到该数据块,最终返回给处理器。

根据Cache和内存之间的映射关系的不同,Cache可以分为三类:第一类是全关联型Cache(full associative cache),第二类是直接关联型Cache(direct mapped cache),第三类是组关联型Cache(N-ways associative cache)。

2.3.1 全关联型Cache

全关联型Cache是指主存中的任何一块内存都可以映射到Cache中的任意一块位置上。在Cache中,需要建立一个目录表,目录表的每个表项都有三部分组成:内存地址、Cache块号和一个有效位。当处理器需要访问某个内存地址时,首先通过该目录表查询是否该内容缓存在Cache中,具体过程如图2-5所示。

图2-5 全关联Cache查找过程

首先,用内存的块地址A在Cache的目录表中进行查询,如果找到等值的内存块地址,检查有效位是否有效,只有有效的情况下,才能通过Cache块号在Cache中找到缓存的内存,并且加上块内地址B,找到相应数据,这时则称为Cache命中,处理器拿到数据返回;否则称为不命中,处理器则需要在内存中读取相应的数据。

可以看出,使用全关联型Cache,块的冲突最小(没有冲突), Cache的利用率也高,但是需要一个访问速度很快的相联存储器。随着Cache容量的增加,其电路设计变得十分复杂,因此只有容量很小的Cache才会设计成全关联型的(如一些英特尔处理器中的TLB Cache)。

2.3.2 直接关联型Cache

直接关联型Cache是指主存中的一块内存只能映射到Cache的一个特定的块中。假设一个Cache中总共存在N个Cache line,那么内存被分成N等分,其中每一等分对应一个Cache line。举个简单的例子,假设Cache的大小是2K,而一个Cache line的大小是64B,那么就一共有2K/64B=32个Cache line,那么对应我们的内存,第1块(地址0~63),第33块(地址64*32~64*33-1),以及第(N*32+1)块(地址64*(N-1)~64*N-1)都被映射到Cache第一块中;同理,第2块,第34块,以及第(N*32+2)块都被映射到Cache第二块中;可以依次类推其他内存块。

直接关联型Cache的目录表只有两部分组成:区号和有效位。其查找过程如图2-6所示。首先,内存地址被分成三部分:区号A、块号B和块内地址C。根据区号A在目录表中找到完全相等的区号,并且在有效位有效的情况下,说明该数据在Cache中,然后通过内存地址的块号B获得在Cache中的块地址,加上块内地址C,最终找到数据。如果在目录表中找不到相等的区号,或者有效位无效的情况下,则说明该内容不在Cache中,需要到内存中读取。

图2-6 直接相联Cache查找过程

可以看出,直接关联是一种很“死”的映射方法,当映射到同一个Cache块的多个内存块同时需要缓存在Cache中时,只有一个内存块能够缓存,其他块需要被“淘汰”掉。因此,直接关联型命中率是最低的,但是其实现方式最为简单,匹配速度也最快。

2.3.3 组关联型Cache

组关联型Cache是目前Cache中用的比较广泛的一种方式,是前两种Cache的折中形式。在这种方式下,内存被分为很多组,一个组的大小为多个Cache line的大小,一个组映射到对应的多个连续的Cache line,也就是一个Cache组,并且该组内的任意一块可以映射到对应Cache组的任意一个。可以看出,在组外,其采用直接关联型Cache的映射方式,而在组内,则采用全关联型Cache的映射方式。

假设有一个4路组关联型Cache,其大小为1M,一个Cache line的大小为64B,那么总共有16K个Cache line,但是在4路组关联的情况下,我们并不是简简单单拥有16K个Cache line,而是拥有了4K个组,每个组有4个Cache line。一个内存单元可以缓存到它所对应的组中的任意一个Cache line中去。

图2-7以4路组关联型Cache为例介绍其在Cache中的查找过程。目录表由三部分组成,分别是“区号+块号”、Cache块号和有效位。当收到一个内存地址时,该地址被分成四部分:区号A、组号B、块号C和块内地址D。首先,根据组号B按地址查找到一组目录表项,在4路组关联中,则有四个表项,每个表项都有可能存放该内存块;然后,根据区号A和块号C在该组表项中进行关联查找(即并行查找,为了提高效率),如果匹配且有效位有效,则表明该数据块缓存在Cache中,得到Cache块号,加上块内地址D,可以得到该内存地址在Cache中映射的地址,得到数据;如果没有找到匹配项或者有效位无效,则表示该内存块不在Cache中,需要处理器到内存中读取。

图2-7 4路组关联型Cache查找过程

实际上,直接关联型Cache和全关联型Cache只是组关联型Cache的特殊情况,当组内Cache Line数目为1时,即为直接关联型Cache。而当组内Cache Line数目和Cache大小相等时,即整个Cache只有一个组,这成为全关联型Cache。

2.4 Cache的写策略

内存的数据被加载到Cache后,在某个时刻其要被写回内存,对于这个时刻的选取,有如下几个不同的策略。

直写(write-through):所谓直写,就是指在处理器对Cache写入的同时,将数据写入到内存中。这种策略保证了在任何时刻,内存的数据和Cache中的数据都是同步的,这种方式简单、可靠。但由于处理器每次对Cache更新时都要对内存进行写操作,因此总线工作繁忙,内存的带宽被大大占用,因此运行速度会受到影响。假设一段程序在频繁地修改一个局部变量,尽管这个局部变量的生命周期很短,而且其他进程/线程也用不到它,CPU依然会频繁地在Cache和内存之间交换数据,造成不必要的带宽损失。

回写(write-back):回写相对于直写而言是一种高效的方法。直写不仅浪费时间,而且有时是不必要的,比如上文提到的局部变量的例子。回写系统通过将Cache line的标志位字段添加一个Dirty标志位,当处理器在改写了某个Cache line后,并不是马上把其写回内存,而是将该Cache line的Dirty标志设置为1。当处理器再次修改该Cache line并且写回到Cache中,查表发现该Dirty位已经为1,则先将Cache line内容写回到内存中相应的位置,再将新数据写到Cache中。其实,回写策略在多核系统中会引起Cache一致性的问题。设想有两个处理器核心都需要对某个内存块进行读写,其中一个核心已经修改了该数据块,并且写回到Cache中,设置了Dirty位;这时另外一个核心也完成了该内存块的修改,并且准备写入到Cache中,这时才发现该Cache line是“脏”的,在这种情况下,Cache如何处理呢?之后的章节我们会继续这个话题。

除了上述这两种写策略,还有WC(write-combining)和UC(uncacheable)。这两种策略都是针对特殊的地址空间来使用的。

write-combining策略是针对于具体设备内存(如显卡的RAM)的一种优化处理策略。对于这些设备来说,数据从Cache到内存转移的开销比直接访问相应的内存的开销还要高得多,所以应该尽量避免过多的数据转移。试想,如果一个Cache line里的字被改写了,处理器将其写回内存,紧接着又一个字被改写了,处理器又将该Cache line写回内存,这样就显得低效,符合这种情况的一个例子就是显示屏上水平相连的像素点数据。write-combining策略的引入就是为了解决这种问题,顾名思义,这种策略就是当一个Cache line里的数据一个字一个字地都被改写完了之后,才将该Cache line写回到内存中。

uncacheable内存是一部分特殊的内存,比如PCI设备的I/O空间通过MMIO方式被映射成内存来访问。这种内存是不能缓存在Cache中的,因为设备驱动在修改这种内存时,总是期望这种改变能够尽快通过总线写回到设备内部,从而驱动设备做出相应的动作。如果放在Cache中,硬件就无法收到指令。

2.5 Cache预取

以上章节讲到了多种和Cache相关的技术,但是事实上,Cache对于绝大多数程序员来说都是透明不可见的。程序员在编写程序时不需要关心是否有Cache的存在,有几级Cache,每级Cache的大小是多少;不需要关心Cache采取何种策略将指令和数据从内存中加载到Cache中;也不需要关心Cache何时将处理完毕的数据写回到内存中。这一切,都是硬件自动完成的。但是,硬件也不是完全智能的,能够完美无缺地处理各种各样的情况,保证程序能够以最优的效率执行。因此,一些体系架构引入了能够对Cache进行预取的指令,从而使一些对程序执行效率有很高要求的程序员能够一定程度上控制Cache,加快程序的执行。

接下来,将简单介绍一下硬件预取的原理,通过英特尔NetBurst架构具体介绍其预取的原则,最后介绍软件可以使用的Cache预取指令。

2.5.1 Cache的预取原理

Cache之所以能够提高系统性能,主要是程序执行存在局部性现象,即时间局部性和空间局部性。

1)时间局部性:是指程序即将用到的指令/数据可能就是目前正在使用的指令/数据。因此,当前用到的指令/数据在使用完毕之后可以暂时存放在Cache中,可以在将来的时候再被处理器用到。一个简单的例子就是一个循环语句的指令,当循环终止的条件满足之前,处理器需要反复执行循环语句中的指令。

2)空间局部性:是指程序即将用到的指令/数据可能与目前正在使用的指令/数据在空间上相邻或者相近。因此,在处理器处理当前指令/数据时,可以从内存中把相邻区域的指令/数据读取到Cache中,这样,当处理器需要处理相邻内存区域的指令/数据时,可以直接从Cache中读取,节省访问内存的时间。一个简单的例子就是一个需要顺序处理的数组。

所谓的Cache预取,也就是预测数据并取入到Cache中,是根据空间局部性和时间局部性,以及当前执行状态、历史执行过程、软件提示等信息,然后以一定的合理方法,在数据/指令被使用前取入Cache。这样,当数据/指令需要被使用时,就能快速从Cache中加载到处理器内部进行运算和执行。

以上介绍的只是基本的预取原理,在不同体系架构,甚至不同处理器上,具体采取的预取方法都可能是不同的。以下以英特尔NetBurst架构的处理器为例介绍其预取的原则。详细内容请参见[Ref2-1]。

2.5.2 NetBurst架构处理器上的预取

在NetBurst架构上,每一级Cache都有相应的硬件预取单元,根据相应原则来预取数据/指令。由于篇幅原因,仅以一级数据Cache进行介绍。

1. 一级数据Cache的预取单元

NetBurst架构的处理器上有两个硬件预取单元,用来加快程序,这样可以更快速地将所需要的数据送到一级数据Cache中。

1)数据Cache预取单元:也叫基于流的预取单元(Streaming prefetcher)。当程序以地址递增的方式访问数据时,该单元会被激活,自动预取下一个Cache行的数据。

2)基于指令寄存器(Instruction Pointer, IP)的预取单元:该单元会监测指令寄存器的读取(Load)指令,当该单元发现读取数据块的大小总是相对固定的情况下,会自动预取下一块数据。假设当前读取地址是0xA000,读取数据块大小为256个字节,那地址是0xA100-0xA200的数据就会自动被预取到一级数据Cache中。该预取单元能够追踪的最大数据块大小是2K字节。

不过需要指出的是,只有以下的条件全部满足的情况下,数据预取的机制才会被激活。

1)读取的数据是回写(Writeback)的内存类型。

2)预取的请求必须在一个4K物理页的内部。这是因为对于程序员来说,虽然指令和数据的虚拟地址都是连续的,但是分配的物理页很有可能是不连续的。而预取是根据物理地址进行判断的,因此跨界预取的指令和数据很有可能是属于其他进程的,或者没有被分配的物理页。

3)处理器的流水线作业中没有fence或者lock这样的指令。

4)当前读取(Load)指令没有出现很多Cache不命中。

5)前端总线不是很繁忙。

6)没有连续的存储(Store)指令。

在该硬件预取单元激活的情况下,也不一定能够提高程序的执行效率。这取决于程序是如何执行的。

当程序需要多次访问某种大的数据结构,并且访问的顺序是有规律的,硬件单元能够捕捉到这种规律,进而能够提前预取需要处理的数据,那么就能提高程序的执行效率;当访问的顺序没有规律,或者硬件不能捕捉这种规律,这种预取不但会降低程序的性能,而且会占用更多的带宽,浪费一级Cache有限的空间;甚至在某些极端情况下,程序本身就占用了很多一级数据Cache的空间,而预取单元为了预取它认为程序需要的数据,不适当地淘汰了程序本身存放在一级Cache的数据,从而导致程序的性能严重下降。

2. 硬件预取所遵循的原则

在Netburst架构的处理器中,硬件遵循以下原则来决定是否开启自动预取。

1)只有连续两次Cache不命中才能激活预取机制。并且,这两次不命中的内存地址的位置偏差不能超过256或者512字节(NetBurst架构的不同处理器定义的阈值不一样),否则也不会激活预取。这样做的目的是因为预取也会有开销,会占用内部总线的带宽,当程序执行没有规律时,盲目预取只会带来更多的开销,并且并不一定能够提高程序执行的效率。

2)一个4K字节的页(Page)内,只定义一条流(Stream,可以是指令,也可以是数据)。因为处理器同时能够追踪的流是有限的。

3)能够同时、独立地追踪8条流。每条流必须在一个4K字节的页内。

4)对4K字节的边界之外不进行预取。也就是说,预取只会在一个物理页(4K字节)内发生。这和一级数据Cache预取遵循相同的原则。

5)预取的数据存放在二级或者三级Cache中。

6)对于UC(Strong Uncacheable)和WC(Write Combining)内存类型不进行预取。

2.5.3 两个执行效率迥异的程序

虽然绝大多数Cache预取对程序员来说都是透明的,但是了解预取的基本原理还是很有必要的,这样可以帮助我们编写高效的程序。以下就是两个相似的程序片段,但是执行效率却相差极大。这两个程序片段都定义了一个二维数组arr[1024][1024],对数组中每个元素都进行赋值操作。在内循环内,程序1是依次对a[i][0], a[i][1], a[i][2]… a[i][1023]进行赋值;程序2是依次对a[0][i], a[1][i], a[2][i] … a[1023] [i]进行赋值。

        程序1:
        for(int i = 0; i < 1024; i++) {
            for(int j = 0; j < 1024; j++) {
                arr[i][j] = num++;
            }
        }
        程序2:
        for(int i = 0; i < 1024; i++) {
        for(int j = 0; j < 1024; j++) {
            arr[j][i] = num++;
        }
    }

通过图2-8可以清晰地看到程序1和程序2的执行顺序。程序1是按照数组在内存中的保存方式顺序访问,而程序2则是跳跃式访问。对于程序1,硬件预取单元能够自动预取接下来需要访问的数据到Cache,节省访问内存的时间,从而提高程序1的执行效率;对于程序2,硬件不能够识别数据访问的规律,因而不会预取,从而使程序2总是需要在内存中读取数据,降低了执行的效率。

图2-8 两组程序执行过程示意图

2.5.4 软件预取

从上面的介绍可以看出,硬件预取单元并不一定能够提高程序执行的效率,有些时候可能会极大地降低执行的效率。因此,一些体系架构的处理器增加了一些指令,使得软件开发者和编译器能够部分控制Cache。能够影响Cache的指令很多,本书仅介绍预取相关的指令。

❑软件预取指令

预取指令使软件开发者在性能相关区域,把即将用到的数据从内存中加载到Cache,这样当前数据处理完毕后,即将用到的数据已经在Cache中,大大减小了从内存直接读取的开销,也减少了处理器等待的时间,从而提高了性能。增加预取指令并不是让软件开发者需要时时考虑到Cache的存在,让软件自己来管理Cache,而是在某些热点区域,或者性能相关区域能够通过显示地加载数据到Cache,提高程序执行的效率。不过,不正确地使用预取指令,造成Cache中负载过重或者无用数据的比例增加,反而还会造成程序性能下降,也有可能造成其他程序执行效率降低(比如某程序大量加载数据到三级Cache,影响到其他程序)。因此,软件开发者需要仔细衡量利弊,充分进行测试,才能够正确地优化程序。需要指出的是,预取指令只对数据有效,对指令预取是无效的。表2-1给出了预取的指令列表。

表2-1 预取指令列表

预取指令是汇编指令,对于很多软件开发者来说,直接插入汇编指令不是很方便,一些程序库也提供了相应的软件版本。比如“mmintrin.h”提供了如下的函数原型:

        void _mm_prefetch(char *p, int i);

p是需要预取的内存地址,i对应相应的预取指令,如表2-2所示。

表2-2 软件库中的预取函数

接下来,我们将以DPDK中PMD(Polling Mode Driver)驱动中的一个程序片段看看DPDK是如何利用预取指令的。

❑DPDK中的预取

在讨论之前,我们需要了解另外一个和性能相关的话题。DPDK一个处理器核每秒钟大概能够处理33M个报文,大概每30纳秒需要处理一个报文,假设处理器的主频是2.7GHz,那么大概每80个处理器时钟周期就需要处理一个报文。那么,处理报文需要做一些什么事情呢?以下是一个基本过程。

1)写接收描述符到内存,填充数据缓冲区指针,网卡收到报文后就会根据这个地址把报文内容填充进去。

2)从内存中读取接收描述符(当收到报文时,网卡会更新该结构)(内存读),从而确认是否收到报文。

3)从接收描述符确认收到报文时,从内存中读取控制结构体的指针(内存读),再从内存中读取控制结构体(内存读),把从接收描述符读取的信息填充到该控制结构体。

4)更新接收队列寄存器,表示软件接收到了新的报文。

5)内存中读取报文头部(内存读),决定转发端口。

6)从控制结构体把报文信息填入到发送队列发送描述符,更新发送队列寄存器。

7)从内存中读取发送描述符(内存读),检查是否有包被硬件传送出去。

8)如果有的话,从内存中读取相应控制结构体(内存读),释放数据缓冲区。

可以看出,处理一个报文的过程,需要6次读取内存(见上“内存读”)。而之前我们讨论过,处理器从一级Cache读取数据需要3~5个时钟周期,二级是十几个时钟周期,三级是几十个时钟周期,而内存则需要几百个时钟周期。从性能数据来说,每80个时钟周期就要处理一个报文。

因此,DPDK必须保证所有需要读取的数据都在Cache中,否则一旦出现Cache不命中,性能将会严重下降。为了保证这点,DPDK采用了多种技术来进行优化,预取只是其中的一种。

而从上面的介绍可以看出,控制结构体和数据缓冲区的读取都没有遵循硬件预取的原则,因此DPDK必须用一些预取指令来提前加载相应数据。以下就是部分接收报文的代码。

              while (nb_rx < nb_pkts) {
                  rxdp = &rx_ring[rx_id]; //读取接收描述符
                  staterr = rxdp->wb.upper.status_error;
                  //检查是否有报文收到
                  if (! (staterr & rte_cpu_to_le_32(IXGBE_RXDADV_STAT_DD)))
                      break;
                  rxd = *rxdp;
                  //分配数据缓冲区
                  nmb = rte_rxmbuf_alloc(rxq->mb_pool);
                  nb_hold++;
                  //读取控制结构体
                  rxe = &sw_ring[rx_id];
                  ……
                  rx_id++;
                  if (rx_id == rxq->nb_rx_desc)
                      rx_id = 0;
                  //预取下一个控制结构体mbuf
                  rte_ixgbe_prefetch(sw_ring[rx_id].mbuf);
                  //预取接收描述符和控制结构体指针
                  if ((rx_id & 0x3) == 0) {
                      rte_ixgbe_prefetch(&rx_ring[rx_id]);
                      rte_ixgbe_prefetch(&sw_ring[rx_id]);
                  }
                  ……
            //预取报文
            rte_packet_prefetch((char *)rxm->buf_addr + rxm->data_off);
            //把接收描述符读取的信息存储在控制结构体mbuf中
            rxm->nb_segs = 1;
            rxm->next = NULL;
            rxm->pkt_len = pkt_len;
            rxm->data_len = pkt_len;
            rxm->port = rxq->port_id;
            ……
            rx_pkts[nb_rx++] = rxm;
        }

2.6 Cache一致性

我们知道,Cache是按照Cache Line作为基本单位来组织内容的,其大小是32(较早的ARM、1990年~2000年早期的x86和PowerPC)、64(较新的ARM和x86)或128(较新的Power ISA机器)字节。当我们定义了一个数据结构或者分配了一段数据缓冲区之后,在内存中就有一个地址和其相对应,然后程序就可以对它进行读写。对于读,首先是从内存加载到Cache,最后送到处理器内部的寄存器;对于写,则是从寄存器送到Cache,最后通过内部总线写回到内存。这两个过程其实引出了两个问题:

1)该数据结构或者数据缓冲区的起始地址是Cache Line对齐的吗?如果不是,即使该数据区域的大小小于Cache Line,那么也需要占用两个Cache entry;并且,假设第一个Cache Line前半部属于另外一个数据结构并且另外一个处理器核正在处理它,那么当两个核都修改了该Cache Line从而写回各自的一级Cache,准备送到内存时,如何同步数据?毕竟每个核都只修改了该Cache Line的一部分。

2)假设该数据结构或者数据缓冲区的起始地址是Cache Line对齐的,但是有多个核同时对该段内存进行读写,当同时对内存进行写回操作时,如何解决冲突?

接下来,我们先回答第一个问题,然后再回答第二个问题。

2.6.1 Cache Line对齐

对于第一个问题,其实有多种方法来解决。比如,用解决第二个问题的方法去解决它,从本质来讲,第一个问题和第二个问题都是因为多个核同时操作一个Cache Line进行写操作造成的。

另外一个简单的方法就是定义该数据结构或者数据缓冲区时就申明对齐,DPDK对很多结构体定义的时候就是如此操作的。见下例:

        struct rte_ring_debug_stats {
        uint64_t enq_success_bulk;
        uint64_t enq_success_objs;
        uint64_t enq_quota_bulk;
        uint64_t enq_quota_objs;
        uint64_t enq_fail_bulk;
        uint64_t enq_fail_objs;
        uint64_t deq_success_bulk;
        uint64_t deq_success_objs;
        uint64_t deq_fail_bulk;
        uint64_t deq_fail_objs;
        } __rte_cache_aligned;

__rte_cache_aligned的定义如下所示:

        #define RTE_CACHE_LINE_SIZE 64
        #define __rte_cache_aligned
        __attribute__((__aligned__(RTE_CACHE_LINE_SIZE)))

其实现在编译器很多时候也比较智能,会在编译的时候尽量做到Cache Line对齐。

2.6.2 Cache一致性问题的由来

上文提到的第二个问题,即多个处理器对某个内存块同时读写,会引起冲突的问题,这也被称为Cache一致性问题。

Cache一致性问题出现的原因是在一个多处理器系统中,每个处理器核心都有独占的Cache系统(比如我们之前提到的一级Cache和二级Cache),而多个处理器核心都能够独立地执行计算机指令,从而有可能同时对某个内存块进行读写操作,并且由于我们之前提到的回写和直写的Cache策略,导致一个内存块同时可能有多个备份,有的已经写回到内存中,有的在不同的处理器核心的一级、二级Cache中。由于Cache缓存的原因,我们不知道数据写入的时序性,因而也不知道哪个备份是最新的。还有另外一个一种可能,假设有两个线程A和B共享一个变量,当线程A处理完一个数据之后,通过这个变量通知线程B,然后线程B对这个数据接着进行处理,如果两个线程运行在不同的处理器核心上,那么运行线程B的处理器就会不停地检查这个变量,而这个变量存储在本地的Cache中,因此就会发现这个值总也不会发生变化。

其实,关于一致性问题的阐述,我们附加了很多限制条件,比如多核,独占Cache, Cache写策略。如果当中有一个或者多个条件不成立时可能就不会引发一致性的问题了。

1)假设只是单核处理器,那么只有一个处理器会对内存进行读写,Cache也是只有一份,因而不会出现一致性的问题。

2)假设是多核处理器系统,但是Cache是所有处理器共享的,那么当一个处理器对内存进行修改并且缓存在Cache中时,其他处理器都能看到这个变化,因而也不会产生一致性的问题。

3)假设是多核处理器系统,每个核心也有独占的Cache,但是Cache只会采用直写,那么当一个处理器对内存进行修改之后,Cache会马上将数据写入到内存中,也不会有问题吗?考虑之前我们介绍的一个例子,线程A把结果写回到内存中,但是线程B只会从独占的Cache中读取这个变量(因为没人通知它内存的数据产生了变化),因此在这种条件下还是会有Cache一致性的问题。

因而,Cache一致性问题的根源是因为存在多个处理器独占的Cache,而不是多个处理器。如果多个处理器共享Cache,也就是说只有一级Cache,所有处理器都共享它,在每个指令周期内,只有一个处理器核心能够通过这个Cache做内存读写操作,那么就不会存在Cache一致性问题。

讲到这里,似乎我们找到了一劳永逸解决Cache一致性问题的办法,只要所有的处理器共享Cache,那么就不会有任何问题。但是,这种解决办法的问题就是太慢了。首先,既然是共享的Cache,势必容量不能小,那么就是说访问速度相比之前提到的一级、二级Cache,速度肯定几倍或者十倍以上;其次,每个处理器每个时钟周期内只有一个处理器才能访问Cache,那么处理器把时间都花在排队上了,这样效率太低了。

因而,我们还是需要针对出现的Cache一致性问题,找出一个解决办法。

2.6.3 一致性协议

解决Cache一致性问题的机制有两种:基于目录的协议(Directory-based protocol)和总线窥探协议(Bus snooping protocol)。其实还有另外一个Snarfing协议,在此不作讨论。

基于目录协议的系统中,需要缓存在Cache的内存块被统一存储在一个目录表中,目录表统一管理所有的数据,协调一致性问题。该目录表类似于一个仲裁者,当处理器需要把一个数据从内存中加载到自己独占的Cache中时,需要向目录表提出申请;当一个内存块被某个处理器改变之后,目录表负责改变其状态,更新其他处理器的Cache中的备份,或者使其他处理器的Cache的备份无效。

总线窥探协议是在1983年被首先提出来,这个协议提出了一个窥探(snooping)的动作,即对于被处理器独占的Cache中的缓存的内容,该处理器负责监听总线,如果该内容被本处理器改变,则需要通过总线广播;反之,如果该内容状态被其他处理器改变,本处理器的Cache从总线收到了通知,则需要相应改变本地备份的状态。

可以看出,这两类协议的主要区别在于基于目录的协议采用全局统一管理不同Cache的状态,而总线窥探协议则使用类似于分布式的系统,每个处理器负责管理自己的Cache的状态,通过共享的总线,同步不同Cache备份的状态。

通过之前的描述可以发现,在上面两种协议中,每个Cache Block都必须有自己的一个状态字段。而维护Cache一致性问题的关键在于维护每个Cache Block的状态域。Cache控制器通常使用一个状态机来维护这些状态域。

基于目录的协议的延迟性较大,但是在拥有很多个处理器的系统中,它有更好的可扩展性。而总线窥探协议适用于具有广播能力的总线结构,允许每个处理器能够监听其他处理器对内存的访问,适合小规模的多核系统。

接下来,我们将主要介绍总线窥探协议。最经典的总线窥探协议Write-Once由C.V.Ravishankar和James R. Goodman于1983年提出,继而被x86、ARM和Power等架构广泛采用,衍生出著名的MESI协议,或者称为Illinois Protocol。之所以有这个名字,是因为该协议是由伊利诺伊州立大学研发出来的。

2.6.4 MESI协议

MESI协议是Cache line四种状态的首字母的缩写,分别是修改(Modified)态、独占(Exclusive)态、共享(Shared)态和失效(Invalid)态。Cache中缓存的每个Cache Line都必须是这四种状态中的一种。详见[Ref2-2]。

❑修改态,如果该Cache Line在多个Cache中都有备份,那么只有一个备份能处于这种状态,并且“dirty”标志位被置上。拥有修改态Cache Line的Cache需要在某个合适的时候把该Cache Line写回到内存中。但是在写回之前,任何处理器对该Cache Line在内存中相对应的内存块都不能进行读操作。Cache Line被写回到内存中之后,其状态就由修改态变为共享态。

❑独占态,和修改状态一样,如果该Cache Line在多个Cache中都有备份,那么只有一个备份能处于这种状态,但是“dirty”标志位没有置上,因为它是和主内存内容保持一致的一份拷贝。如果产生一个读请求,它就可以在任何时候变成共享态。相应地,如果产生了一个写请求,它就可以在任何时候变成修改态。

❑共享态,意味着该Cache Line可能在多个Cache中都有备份,并且是相同的状态,它是和内存内容保持一致的一份拷贝,而且可以在任何时候都变成其他三种状态。

❑失效态,该Cache Line要么已经不在Cache中,要么它的内容已经过时。一旦某个Cache Line被标记为失效,那它就被当作从来没被加载到Cache中。

对于某个内存块,当其在两个(或多个)Cache中都保留了一个备份时,只有部分状态是允许的。如表2-3所示,横轴和竖轴分别表示了两个Cache中某个Cache Line的状态,两个Cache Line都映射到相同的内存块。如果一个Cache Line设置成M态或者E态,那么另外一个Cache Line只能设置成I态;如果一个Cache Line设置成S态,那么另外一个Cache Line可以设置成S态或者I态;如果一个Cache Line设置成I态,那么另外一个Cache Line可以设置成任何状态。

表2-3 MESI中两个Cache备份的状态矩阵

那么,究竟怎样的操作才会引起Cache Line的状态迁移,从而保持Cache的一致性呢?以下所示表2-4是根据不同读写操作触发的状态迁移表。

表2-4 MESI状态迁移表

2.6.5 DPDK如何保证Cache一致性

从上面的介绍我们知道,Cache一致性这个问题的最根本原因是处理器内部不止一个核,当两个或多个核访问内存中同一个Cache行的内容时,就会因为多个Cache同时缓存了该内容引起同步的问题。

DPDK与生俱来就是为了网络平台的高性能和高吞吐,并且总是需要部署在多核的环境下。因此,DPDK必须提出好的解决方案,避免由于不必要的Cache一致性开销而造成额外的性能损失。

其实,DPDK的解决方案很简单,首先就是避免多个核访问同一个内存地址或者数据结构。这样,每个核尽量都避免与其他核共享数据,从而减少因为错误的数据共享(cache line false sharing)导致的Cache一致性的开销。

以下是两个DPDK为了避免Cache一致性的例子。

例子1:数据结构定义。DPDK的应用程序很多情况下都需要多个核同时来处理事务,因而,对于某些数据结构,我们给每个核都单独定义一份,这样每个核都只访问属于自己核的备份。如下例所示:

        struct lcore_conf {
        uint16_t n_rx_queue;
        struct lcore_rx_queue rx_queue_list[MAX_RX_QUEUE_PER_LCORE];
        uint16_t tx_queue_id[RTE_MAX_ETHPORTS];
        struct mbuf_table tx_mbufs[RTE_MAX_ETHPORTS];
        lookup_struct_t * ipv4_lookup_struct;
        lookup_struct_t * ipv6_lookup_struct;
        } __rte_cache_aligned;    //Cache行对齐
        struct lcore_conf lcore[RTE_MAX_LCORE] __rte_cache_aligned;

以上的数据结构“struct lcore_conf”总是以Cache行对齐,这样就不会出现该数据结构横跨两个Cache行的问题。而定义的数组“lcore[RTE_MAX_LCORE]”中RTE_MAX_LCORE指一个系统中最大核的数量。DPDK中对每个核都进行编号,这样核n就只需要访问lcore[n],核m只需要访问lcore[m],这样就避免了多个核访问同一个结构体。

例子2:对网络端口的访问。在网络平台中,少不了访问网络设备,比如网卡。多核情况下,有可能多个核访问同一个网卡的接收队列/发送队列,也就是在内存中的一段内存结构。这样,也会引起Cache一致性的问题。那么DPDK是如何解决这个问题的呢?

图2-9 多核多队列收发示意图

需要指出的是,网卡设备一般都具有多队列的能力,也就是说,一个网卡有多个接收队列和多个访问队列,其他章节会很详细讲到,本节不再赘述。

DPDK中,如果有多个核可能需要同时访问同一个网卡,那么DPDK就会为每个核都准备一个单独的接收队列/发送队列。这样,就避免了竞争,也避免了Cache一致性问题。

图2-9是四个核可能同时访问两个网络端口的图示。其中,网卡1和网卡2都有两个接收队列和四个发送队列;核0到核3每个都有自己的一个接收队列和一个发送队列。核0从网卡1的接收队列0接收数据,可以发送到网卡1的发送队列0或者网卡2的发送队列0;同理,核3从网卡2的接收队列1接收数据,可以发送到网卡1的发送队列3或者网卡2的发送队列3。

2.7 TLB和大页

在之前的章节我们提到了TLB, TLB和Cache本质上是一样的,都是一种高速的SRAM,存放了内存中内容的一份快照或者备份,以便处理器能够快速地访问,减少等待的时间。有所不同的是,Cache存放的是内存中的数据或者代码,或者说是任何内容,而TLB存放的是页表项。

提到页表项,有必要简短介绍一下处理器的发展历史。最初的程序员直接对物理地址编程,自己去管理内存,这样不仅对程序员要求高,编程效率低,而且一旦程序出现问题也不方便进行调试。特别还出现了恶意程序,这对计算机系统危害实在太大,因而后来不同的体系架构推出了虚拟地址和分页的概念。

分页是指把物理内存分成固定大小的块,按照页来进行分配和释放。一般常规页大小为4K(212)个字节,之后又因为一些需要,出现了大页,比如2M(220)个字节和1G(230)个字节的大小,我们后面会讲到为什么使用大页。

虚拟地址是指程序员使用虚拟地址进行编程,不用关心物理内存的大小,即使自己的程序出现了问题也不会影响其他程序的运行和系统的稳定。而处理器在寄存器收到虚拟地址之后,根据页表负责把虚拟地址转换成真正的物理地址。

接下来,我们以一个例子来简单介绍地址转换过程。

2.7.1 逻辑地址到物理地址的转换

图2-10是x86在32位处理器上进行一次逻辑地址(或线性地址)转换物理地址的示意图。

处理器把一个32位的逻辑地址分成3段,每段都对应一个偏移地址。查表的顺序如下:

1)根据位bit[31:22]加上寄存器CR3存放的页目录表的基址,获得页目录表中对应表项的物理地址,读内存,从内存中获得该表项内容,从而获得下一级页表的基址。

2)根据位bit[21:12]页表加上上一步获得的页表基址,获得页表中对应表项的物理地址,读内存,从内存中获得该表项内容,从而获得内容页的基址。

3)根据为bit[11:0]加上上一步获得的内容页的基址得到准确的物理地址,读内容获得真正的内容。

图2-10 页表查找过程

从上面的描述可以看出,为了完成逻辑地址到物理地址的转换,需要三次内存访问,这实在是太浪费时间了。有的读者可能会问,为什么要分成三段进行查找呢?如果改成两段的话,那不是可以减少一级页表,也可以减少一次内存访问,从而可以提高访问速度。为了回答这个问题,我们举一个例子来看。

假设有一个程序,代码段加数据段可以放在两个4KB的页内。如果使用三段的方式,那么需要一个页存放页目录表(里面只有一个目录项有效),一个页存放页表(里面有两个目录项有效),因此需要总共两个页8192个字节就可以了;如果使用两段的方式,那使用bit[31:12]共20位来查页表,根据其范围,那么需要有220个表项,因此需要4MB来建立页表,也就是1024个物理页,而其中只有两个表项是有效的,这实在是太浪费了。特别是当程序变多时,系统内存会不堪使用。这样的改进代价实在太大。

通过之前的介绍我们知道有Cache的存在,我们也可以把页表缓存在Cache中,但是由于页表项的快速访问性(每次程序内存寻址都需要访问页表)和Cache的“淘汰”机制,有必要提供专门的Cache来保存,也就是TLB。

2.7.2 TLB

相比之前提到的三段查表方式,引入TLB之后,查找过程发生了一些变化。TLB中保存着逻辑地址前20位[31:12]和页框号的对应关系,如果匹配到逻辑地址就可以迅速找到页框号(页框号可以理解为页表项),通过页框号与逻辑地址后12位的偏移组合得到最终的物理地址。

如果没在TLB中匹配到逻辑地址,就出现TLB不命中,从而像我们刚才讨论的那样,进行常规的查找过程。如果TLB足够大,那么这个转换过程就会变得很快速。但是事实是,TLB是非常小的,一般都是几十项到几百项不等,并且为了提高命中率,很多处理器还采用全相连方式。另外,为了减少内存访问的次数,很多都采用回写的策略。

在有些处理器架构中,为了提高效率,还将TLB进行分组,以x86架构为例,一般都分成以下四组TLB:

第一组:缓存一般页表(4KB页面)的指令页表缓存(Instruction-TLB)。

第二组:缓存一般页表(4KB页面)的数据页表缓存(Data-TLB)。

第三组:缓存大尺寸页表(2MB/4MB页面)的指令页表缓存(Instruction-TLB)。

第四组:缓存大尺寸页表(2MB/4MB页面)的数据页表缓存(Data-TLB)。

2.7.3 使用大页

从上面的逻辑地址到物理地址的转换我们知道,如果采用常规页(4KB)并且使TLB总能命中,那么至少需要在TLB表中存放两个表项,在这种情况下,只要寻址的内容都在该内容页内,那么两个表项就足够了。如果一个程序使用了512个内容页也就是2MB大小,那么需要512个页表表项才能保证不会出现TLB不命中的情况。通过上面的介绍,我们知道TLB大小是很有限的,随着程序的变大或者程序使用内存的增加,那么势必会增加TLB的使用项,最后导致TLB出现不命中的情况。那么,在这种情况下,大页的优势就显现出来了。如果采用2MB作为分页的基本单位,那么只需要一个表项就可以保证不出现TLB不命中的情况;对于消耗内存以GB(230)为单位的大型程序,可以采用1GB为单位作为分页的基本单位,减少TLB不命中的情况。

2.7.4 如何激活大页

我们以Linux系统为例来说明如何激活大页的使用。

首先,Linux操作系统采用了基于hugetlbfs的特殊文件系统来加入对2MB或者1GB的大页面支持。这种采用特殊文件系统形式支持大页面的方式,使得应用程序可以根据需要灵活地选择虚存页面大小,而不会被强制使用2MB大页面。

为了使用大页,必须在编译内核的时候激活hugetlbfs。

在激活hugetlbfs之后,还必须在Linux启动之后保留一定数量的内存作为大页来使用。现在有两种方式来预留内存。

第一种是在Linux命令行指定,这样Linux启动之后内存就已经预留;第二种方式是在Linux启动之后,可以动态地预留内存作为大页使用。以下是2MB大页命令行的参数。

        Huagepage=1024

对于其他大小的大页,比如1GB,其大小必须显示地在命令行指定,并且命令行还可以指定默认的大页大小。比如,我们想预留4GB内存作为大页使用,大页的大小为1GB,那么可以用以下的命令行:

        default_hugepagesz=1G hugepagesz=1G hugepages=4

需要指出的是,系统能否支持大页,支持大页的大小为多少是由其使用的处理器决定的。以Intel®的处理器为例,如果处理器的功能列表有PSE,那么它就支持2MB大小的大页;如果处理器的功能列表有PDPE1GB,那么就支持1GB大小的大页。当然,不同体系架构支持的大页的大小都不尽相同,比如x86处理器架构的2MB和1GB大页,而在IBM Power架构中,大页的大小则为16MB和16GB。

在我们之后会讲到的NUMA系统中,因为存在本地内存的问题,系统会均分地预留大页。假设在有两个处理器的NUMA系统中,以上例预留4GB内存为例,在NODE0和NODE1上会各预留2GB内存。

在Linux启动之后,如果想预留大页,则可以使用以下的方法来预留内存。在非NUMA系统中,可以使用以下方法预留2MB大小的大页。

        echo 1024 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

该命令预留1024个大小为2MB的大页,也就是预留了2GB内存。

如果是在NUMA系统中,假设有两个NODE的系统中,则可以用以下的命令:

        echo 1024 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
        echo 1024 > /sys/devices/system/node/node1/hugepages/hugepages-2048kB/nr_hugepages

该命令在NODE0和NODE1上各预留1024个大小为2MB的大页,总共预留了4GB大小。

而对于大小为1GB的大页,则必须在Linux命令行的时候就指定,不能动态预留。

在大页预留之后,接下来则涉及使用的问题。我们以DPDK为例来说明如何使用大页。

DPDK也是使用HUGETLBFS来使用大页。首先,它需要把大页mount到某个路径,比如/mnt/huge,以下是命令:

        mkdir /mnt/huge
        mount -t hugetlbfs nodev /mnt/huge

需要指出的是,在mount之前,要确保之前已经成功预留内存,否则之上命令会失败。该命令只是临时的mount了文件系统,如果想每次开机时省略该步骤,可以修改/etc/fstab文件,加上一行:

        nodev /mnt/huge hugetlbfs defaults 0 0

对于1GB大小的大页,则必须用如下的命令:

        nodev /mnt/huge_1GB hugetlbfs pagesize=1GB 0 0

接下来,在DPDK运行的时候,会使用mmap()系统调用把大页映射到用户态的虚拟地址空间,然后就可以正常使用了。

2.8 DDIO

2.8.1时代背景

当今时代,随着大数据和云计算的爆炸式增长,宽带的普及以及个人终端网络数据的日益提高,对电信服务节点和数据中心的数据交换能力和网络带宽提出了更高的要求。并且,数据中心本身对虚拟化功能的需求也增加了更多的网络带宽需求。电信服务节点和数据中心为了应付这种需求,需要对内部的各种服务器资源进行升级。在这种环境下,英特尔公司提出了Intel®DDIO(Data Direct I/O)的技术。该技术的主要目的就是让服务器能更快处理网络接口的数据,提高系统整体的吞吐率,降低延迟,同时减少能源的消耗。但是,DDIO是如何做到这种优化和改进的呢?为了回答这个问题,有必要回顾一下DDIO技术出现之前,服务器是如何处理从网络上来的数据的。

当一个网络报文送到服务器的网卡时,网卡通过外部总线(比如PCI总线)把数据和报文描述符送到内存。接着,CPU从内存读取数据到Cache进而到寄存器。进行处理之后,再写回到Cache,并最终送到内存中。最后,网卡读取内存数据,经过外部总线送到网卡内部,最终通过网络接口发送出去。

可以看出,对于一个数据报文,CPU和网卡需要多次访问内存。而内存相对CPU来讲是一个非常慢速的部件。CPU需要等待数百个周期才能拿到数据,在这过程中,CPU什么也做不了。

DDIO技术是如何改进的呢?这种技术使外部网卡和CPU通过LLC Cache直接交换数据,绕过了内存这个相对慢速的部件。这样,就增加了CPU处理网络报文的速度(减少了CPU和网卡等待内存的时间),减小了网络报文在服务器端的处理延迟。这样做也带来了一个问题,因为网络报文直接存储在LLC Cache中,这大大增加了对其容量的需求,因而在英特尔的E5处理器系列产品中,把LLC Cache的容量提高到了20MB。

图2-11是DDIO技术对网络报文的处理流程示意图。

DDIO功能模块会学习来自I/O设备的读写请求,也就是I/O对内存的读或者写的请求。例如,当网卡需要从服务器端传送一个数据报文到网络上时,它会发起一个I/O读请求(读数据操作),请求把内存中的某个数据块通过外部总线送到网卡上;当网卡从网络中收到一个数据报文时,它会发起一个I/O写请求(写数据操作),请求把某个数据块通过外部总线送到内存中某个地址上。

接下来的章节会详细介绍在没有DDIO技术和有DDIO技术条件下,服务器是如何处理这些I/O读写请求的。

图2-11 DDIO中报文的处理流程

2.8.2 网卡的读数据操作

通常来说,为了发送一个数据报文到网络上去,首先是运行在CPU上的软件分配了一段内存,然后把这段内存读取到CPU内部,更新数据,并且填充相应的报文描述符(网卡会通过读取描述符了解报文的相应信息),然后写回到内存中,通知网卡,最终网卡把数据读回到内部,并且发送到网络上去。但是,没有DDIO技术和有DDIO技术条件的处理方式是不同的,图2-12是两种环境下的处理流程图。

图2-12网卡读数据的处理流程

图2-12a是没有DDIO技术的处理流程。

1)处理器更新报文和控制结构体。由于分配的缓冲区在内存中,因此会触发一次Cache不命中,处理器把内存读取到Cache中,然后更新控制结构体和报文信息。之后通知NIC来读取报文。

2)NIC收到有报文需要传递到网络上的通知后,它首先需要读取控制结构体进而知道从哪里获取报文。由于之前处理器刚把该缓冲区从内存读到Cache中并且做了更新,很有可能Cache还没有来得及把更新的内容写回到内存中。因此,当NIC发起一个对内存的读请求时,很有可能这个请求会发送到Cache系统中,Cache系统会把数据写回到内存中,然后内存控制器再把数据写到PCI总线上去。因此,一个读内存的操作会产生多次内存的读写。

图2-12b是有DDIO技术的处理流程。

1)处理器更新报文和控制结构体。这个步骤和没有DDIO的技术类似,但是由于DDIO的引入,处理器会开始就把内存中的缓冲区和控制结构体预取到Cache,因此减少了内存读的时间。

2)NIC收到有报文需要传递到网络上的通知后,通过PCI总线把控制结构体和报文送到NIC内部。利用DDIO技术,I/O访问可以直接将Cache的内容送到PCI总线上。这样,就减少了Cache写回时等待的时间。

由此可以看出,由于DDIO技术的引入,网卡的读操作减少了访问内存的次数,因而提高了访问效率,减少了报文转发的延迟。在理想状况下,NIC和处理器无需访问内存,直接通过访问Cache就可以完成更新数据,把数据送到NIC内部,进而送到网络上的所有操作。

2.8.3 网卡的写数据操作

网卡的写数据操作和上节讲到的网卡的读数据操作是完全相反的操作,通俗意义上来讲就是有网络报文需要送到系统内部进行处理,运行的软件可以对收到的报文进行协议分析,如果有问题可以丢弃,也可以转发出去。其过程一般是NIC从网络上收到报文后,通过PCI总线把报文和相应的控制结构体送到预先分配的内存,然后通知相应的驱动程序或者软件来处理。和之前讲到的网卡的读数据操作类似,有DDIO技术和没有DDIO技术的处理也是不一样的,以下是具体处理过程。

首先还是没有DDIO技术的处理流程,如图2-13a所示。

1)报文和控制结构体通过PCI总线送到指定的内存中。如果该内存恰好缓存在Cache中(有可能之前处理器有对该内存进行过读写操作),则需要等待Cache把内容先写回到内存中,然后才能把报文和控制结构体写到内存中。

2)运行在处理器上的驱动程序或者软件得到通知收到新报文,去内存中读取控制结构体和相应的报文,Cache不命中。之所以Cache一定不会命中,是因为即使该内存地址在Cache中,在步骤1中也被强制写回到内存中。因此,只能从内存中读取控制结构体和报文。

图2-13 网卡写数据的处理流程

有DDIO技术的处理流程,如图2-13b所示。

1)这时,报文和控制结构体通过PCI总线直接送到Cache中。这时有两种情形:

a)如果该内存恰好缓存在Cache中(有可能之前处理器有对该内存进行过读写操作),则直接在Cache中更新内容,覆盖原有内容。

b)如果该内存没有缓存在Cache中,则在最后一级Cache中分配一块区域,并相应更新Cache表,表明该内容是对应于内存中的某个地址的。

2)运行在处理器上的驱动或者软件被通知到有报文到达,其产生一个内存读操作,由于该内容已经在Cache中,因此直接从Cache中读。

由此可以看出,DDIO技术在处理器和外设之间交换数据时,减少了处理器和外设访问内存的次数,也减少了Cache写回的等待,提高了系统的吞吐率和数据的交换延迟。

2.9 NUMA系统

之前的章节已经简要介绍过NUMA系统,它是一种多处理器环境下设计的计算机内存结构。NUMA系统是从SMP(Symmetric Multiple Processing,对称多处理器)系统演化而来。

SMP系统最初是在20世纪90年代由Unisys、Convex Computer(后来的HP)、Honeywell、IBM等公司开发的一款商用系统,该系统被广泛应用于Unix类的操作系统,后来又扩展到Windows NT中,该系统有如下特点:

1)所有的硬件资源都是共享的。即每个处理器都能访问到任何内存、外设等。

2)所有的处理器都是平等的,没有主从关系。

3)内存是统一结构、统一寻址的(UMA, Uniform Memory Architecture)。

4)处理器和内存,处理器和处理器都通过一条总线连接起来。

其结构如图2-14所示:

SMP的问题也很明显,因为所有的处理器都通过一条总线连接起来,因此随着处理器的增加,系统总线成为了系统瓶颈,另外,处理器和内存之间的通信延迟也较大。为了克服以上的缺点,才应运而生了NUMA架构,如图2-15所示。

图2-14SMP系统示意图

图2-15 NUMA系统示意图

NUMA是起源于AMD Opteron的微架构,同时被英特尔Nehalem架构采用。在这个架构中,处理器和本地内存之间拥有更小的延迟和更大的带宽,而整个内存仍然可作为一个整体,任何处理器都能够访问,只不过跨处理器的内存访问的速度相对较慢一点。同时,每个处理器都可以拥有本地的总线,如PCIE、SATA、USB等。和内存一样,处理器访问本地的总线延迟低,吞吐率高;访问远程资源,则延迟高,并且要和其他处理器共享一条总线。图2-16是英特尔公司的至强E5服务器的架构示意图。

图2-16 至强E5服务器架构示意图

可以看到,该架构有两个处理器,处理器通过QPI总线相连。每个处理器都有本地的四个通道的内存系统,并且也有属于自己的PCIE总线系统。两个处理器有点不同的是,第一个处理器集成了南桥芯片,而第二个处理器只有本地的PCIE总线。

和SMP系统相比,NUMA系统访问本地内存的带宽更大,延迟更小,但是访问远程的内存成本相对就高多了。因此,我们要充分利用NUMA系统的这个特点,避免远程访问资源。

以下是DPDK在NUMA系统中的一些实例。

1)Per-core memory。一个处理器上有多个核(core), per-core memory是指每个核都有属于自己的内存,即对于经常访问的数据结构,每个核都有自己的备份。这样做一方面是为了本地内存的需要,另外一方面也是因为上文提到的Cache一致性的需要,避免多个核访问同一个Cache行。

2)本地设备本地处理。即用本地的处理器、本地的内存来处理本地的设备上产生的数据。如果有一个PCI设备在node0上,就用node0上的核来处理该设备,处理该设备用到的数据结构和数据缓冲区都从node0上分配。以下是一个分配本地内存的例子:

        /* allocate memory for the queue structure */
        q = rte_zmalloc_socket("fm10k", sizeof(*q),   RTE_CACHE_LINE_SIZE, socket_id);

该例试图分配一个结构体,通过传递socket_id,即node id获得本地内存,并且以Cache行对齐。