What every programmer should know about memory 笔记

What every programmer should know about memory, Part 1(笔记)

2.商用硬件现状
     如今硬件的组成对于pc机而言基本上都是一下的结构:
     
由2部分组成:南桥,北桥
CPU经过FSB(前端总线)链接到北桥芯片,北桥芯片主要包含内存控制器和其余一些组件,内存控制器决定了内存的类型,SDRAM,DRAM等都须要不一样类型的内存控制器。
南桥芯片主要是经过多条不一样的总线和设备通讯,主要有PCI,SATA,USB等还支持PATA,IEEE 1394,串口和并口。
须要注意一下地方:
     1.cpu之间的通讯须要经过它与北桥之间的链接总线
     2.与RAM的通讯须要走北桥芯片
     3.RAM只有一个端口
     4.CPU和南桥设备通讯须要通过北桥
第一个瓶颈:早期RAM的访问都要通过cpu,影响了总体性能,因此出现了DMA(直接内存访问),无需CPU干涉可是会致使和CPU争夺北桥的带宽。
第二个瓶颈:北桥和RAM之间的总线因此出现了双通道(能够实现带宽加倍,内存访问在两个通道上交错分配)
除了并发访问模式也是有瓶颈的看2.2
比较昂贵的系统上可能会出现:
北桥自身不带内存控制器,而是链接到外部多个内存控制器上,好处是支持更多的内存,能够同时访问不一样的内存区,下降了延迟,可是对北桥的内部带宽要求巨大。
使用外部内存控制器并非惟一的办法,比较流行的还有一种是把控制器集成到cpu内部,将内存直接链接到CPU
这样的架构,系统里有几个cpu就能够有几个内存库(memory bank),不须要强大的北桥就能够实现4倍的内存带宽。可是缺点也是很明显:1.致使内存再也不是统一的资源(NUMA的得名),2.cpu能够正常的访问本地内存,可是访问其余内存时须要和其余cpu互通。 在讨论访问远端内存的代价时,咱们用「NUMA因子」这个词。 好比说IBM的x445和SGI的Altix系列。CPU被纳入节点,节点内的内存访问时间是一致的,或者只有很小的NUMA因子。而在节点之间的链接代价很大,并且有巨大的NUMA因子。
2.1 RAM类型
RAM主要分为2中静态RAM,动态RAM,前者速度快,代价搞,后者速度慢代价低
2.1.1静态RAM
主要有6个晶体管组成,核心是4个晶体管M1-M4,他们有2个稳定状态分别表明0和1
2.1.2 动态RAM
动态RAM只有一个晶体管和一个电容
 
动态RAM优势是简单,可是缺点是因为读取状态时须要对电容器放电,因此这一过程不能无限重复,不得不在某个点上对它从新充电。更糟糕的是,为了容纳大量单元(如今通常在单个芯片上容纳10的9次方以上的RAM单元),电容器的容量必须很小(0.000000000000001法拉如下)。这样,完整充电后大约持有几万个电子。即便电容器的电阻很大(若干兆欧姆),仍然只需很短的时间就会耗光电荷,称为「泄漏」。这种泄露就是如今的大部分DRAM芯片每隔64ms就必须进行一次刷新的缘由。(附A关于三极管的输入输出特性)
2.2 DRAM访问细节
同步DRAM,顾名思义,是参照一个时间源工做的。由内存控制器提供一个时钟,时钟的频率决定了前端总线(FSB)的速度。以今天的SDRAM为例,每次数据传输包含64位,即8字节。因此FSB的传输速率应该是有效总线频率乘于8字节(对于4倍传输200MHz总线而言,传输速率为6.4GB/s)。听起来很高,但要知道这只是峰值速率,实际上没法达到的最高速率。咱们将会看到,与RAM模块交流的协议有大量时间是处于非工做状态,不进行数据传输。咱们必须对这些非工做时间有所了解,并尽可能缩短它们,才能得到最佳的性能。
2.2.1读访问协议
 
这里忽略了许多细节,咱们只关注时钟频率、RAS与CAS信号、地址总线和数据总线。首先,内存控制器将行地址放在地址总线上,并下降RAS信号,读周期开始。全部信号都在时钟(CLK)的上升沿读取,所以,只要信号在读取的时间点上保持稳定,就算不是标准的方波也没有关系。设置行地址会促使RAM芯片锁住指定的行。
 
CAS信号在tRCD(RAS到CAS时延)个时钟周期后发出。内存控制器将列地址放在地址总线上,下降CAS线。这里咱们能够看到,地址的两个组成部分是怎么经过同一条总线传输的。
 
既然数据的传输须要这么多的准备工做,仅仅传输一个字显然是太浪费了。所以,DRAM模块容许内存控制指定本次传输多少数据。能够是二、4或8个字。这样,就能够一次填满高速缓存的整条线,而不须要额外的RAS/CAS序列。另外,内存控制器还能够在不重置行选择的前提下发送新的CAS信号。这样,读取或写入连续的地址就能够变得很是快,由于不须要发送RAS信号,也不须要把行置为非激活状态(见下文)。
 
在上图中,SDRAM的每一个周期输出一个字的数据。这是第一代的SDRAM。而DDR能够在一个周期中输出两个字。这种作法能够减小传输时间,但没法下降时延。
 
2.2.2预充电和激活
2.2.1中的图只是读取数据的一部分,还有如下部分:
显示的是两次CAS信号的时序图。第一次的数据在CL周期后准备就绪。图中的例子里,是在SDRAM上,用两个周期传输了两个字的数据。若是换成DDR的话,则能够传输4个字。即便是在一个命令速率为1的DRAM模块上,也没法当即发出预充电命令,而要等数据传输完成。在上图中,即为两个周期。恰好与CL相同,但只是巧合而已。预充电信号并无专用线,某些实现是用同时下降写使能(WE)线和RAS线的方式来触发。
 
发出预充电信命令后,还需等待tRP(行预充电时间)个周期以后才能使行被选中。在图2.9中,这个时间(紫色部分)大部分与内存传输的时间(淡蓝色部分)重合。不错。但tRP大于传输时间,所以下一个RAS信号只能等待一个周期。
 
数据总线的7个周期中只有2个周期才是真正在用的。再用它乘于FSB速度,结果就是,800MHz总线的理论速率6.4GB/s降到了1.8GB/s
 
咱们会看到预充电指令被数据传输时间限制(途中为COL Addr的传输)除此以外,SDRAM模块在RAS信号以后,须要通过一段时间,才能进行预充电(记为tRAS)( minimum active to precharge time(也就是RAS信号以后到充电的最小时间间隔))它的值很大,通常达到tRP的2到3倍。若是在某个RAS信号以后,只有一个CAS信号,并且数据只传输不多几个周期,那么就有问题了。假设在图2.9中,第一个CAS信号是直接跟在一个RAS信号后免的,而tRAS为8个周期。那么预充电命令还须要被推迟一个周期,由于tRCD、CL和tRP加起来才7个周期。
 
DDR模块每每用w-z-y-z-T来表示。例如,2-3-2-8-T1,意思是:
 
w 2 CAS时延(CL) 
x 3 RAS-to-CAS时延(t RCD) 
y 2 RAS预充电时间(t RP) 
z 8 激活到预充电时间(t RAS) 
T T1 命令速率
 
2.2.3重充电
充电对内存是性能最大的影响,根据JEDEC规范,DRAM单元必须保持每64ms刷新一次咱们在解读性能参数时有必要知道,它也是DRAM生命周期的一个部分。若是系统须要读取某个重要的字,而恰好它所在的行正在刷新,那么处理器将会被延迟很长一段时间。刷新的具体耗时取决于DRAM模块自己。
 
2.2.4内存类型
如下是SDR(SDRAME)的操做图比较简单
DRAM阵列的频率和总线的频率保持相同,可是当全部组件频率上升时,那么致使耗电量也上升代价很大,因此就出了DDR,在不提升频率的情况下提升吞吐量
SDR和DDR1的区别就是DDR1能够在上升沿和降低沿都传输数据,实现了双倍的传输。DDR引入了一个缓冲区。缓冲区的每条数据线都持有两位。它要求内存单元阵列的数据总线包含两条线。实现的方式很简单,用同一个列地址同时访问两个DRAM单元。对单元阵列的修改也很小。
为了进一步,因而有了DDR2,最明显的变化是,总线的频率加倍了。频率的加倍意味着带宽的加倍。若是对单元阵列的频率加倍,显然是不经济的,所以DDR2要求I/O缓冲区在每一个时钟周期读取4位。也就是说,DDR2的变化仅在于使I/O缓冲区运行在更高的速度上。这是可行的,并且耗电也不会显著增长。DDR2的命名与DDR1相仿,只是将因子2替换成4
阵列频率 总线频率 数据率 名称(速率) 名称
(FSB)
133MHz 266MHz 4,256MB/s PC2-4200 DDR2-533
166MHz 333MHz 5,312MB/s PC2-5300 DDR2-667
200MHz 400MHz 6,400MB/s PC2-6400 DDR2-800
250MHz 500MHz 8,000MB/s PC2-8000 DDR2-1000
266MHz 533MHz 8,512MB/s PC2-8500 DDR2-1066
FSB速度是用有效频率来标记的,即把上升、降低沿均传输数据的因素考虑进去,所以数字被撑大了。因此,拥有266MHz总线的133MHz模块有着533MHz的FSB“频率”
 
DDR3变化更多,电压从1.8降低到了1.5因而耗电也变小了,或者说保持相同的耗电,ddr3能够达到更高的频率或者,保持一样的热能释放,实现容量翻番
 
DDR3模块的单元阵列将运行在内部总线的四分之一速度上,DDR3的I/O缓冲区从DDR2的4位提高到8位
 
DDR3可能会有一个问题,即在1600Mb/s或更高速率下,每一个通道的模块数可能会限制为1。在早期版本中,这一要求是针对全部频率的。咱们但愿这个要求能够提升一些,不然系统容量将会受到严重的限制。
咱们预计中各DDR3模块的名称。JEDEC目前赞成了前四种。因为Intel的45nm处理器是1600Mb/s的FSB,1866Mb/s能够用于超频市场。随着DDR3的发展,可能会有更多类型加入
阵列频率 总线频率 数据速率 名称(速率) 名称
(FSB)
100MHz 400MHz 6,400MB/s PC3-6400 DDR3-800
133MHz 533MHz 8,512MB/s PC3-8500 DDR3-1066
166MHz 667MHz 10,667MB/s PC3-10667 DDR3-1333
200MHz 800MHz 12,800MB/s PC3-12800 DDR3-1600
233MHz 933MHz 14,933MB/s PC3-14900 DDR3-1866
全部的DDR内存都有一个问题:不断增长的频率使得创建并行数据总线变得十分困难。一个DDR2模块有240根引脚。全部到地址和数据引脚的连线必须被布置得差很少同样长。更大的问题是,若是多于一个DDR模块经过菊花链链接在同一个总线上,每一个模块所接收到的信号随着模块的增长会变得愈来愈扭曲。
 
DDR2规范容许每条总线(又称通道)链接最多两个模块,DDR3在高频率下只容许每一个通道链接一个模块。每条总线多达240根引脚使得单个北桥没法以合理的方式驱动两个通道。替代方案是增长外部内存控制器,但这会提升成本。
一种解法是,在处理器中加入内存控制器
另一种是,Intel针对大型服务器方面的解法(至少在将来几年),是被称为全缓冲DRAM(FB-DRAM)的技术。FB-DRAM采用与DDR2相同的器件,所以造价低廉。不一样之处在于它们与内存控制器的链接方式。FB-DRAM没有用并行总线,而用了串行总线。串行总线能够达到更高的频率,串行化的负面影响,甚至能够增长带宽。使用串行总线后
 
  1. 每一个通道能够使用更多的模块。
  2. 每一个北桥/内存控制器能够使用更多的通道。
  3. 串行总线是全双工的(两条线)。
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)芯片的类型。

DDR2
FB-DRAM
240 69
通道 2 6
每通道DIMM数 2 8
最大内存 16GB 192GB
吞吐量 ~10GB/s ~40GB/s
 
2.2.5 结论
经过本节,你们应该了解到访问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信号。因而,各类速度开始慢下来,DRAM模块急需帮助。预充电的时间越短,数据传输所受的惩罚越小。
 
硬件和软件的预取(参见第6.3节)能够在时序中制造更多的重叠区,下降延迟。预取还能够转移内存操做的时间,从而减小争用。咱们经常遇到的问题是,在这一轮中生成的数据须要被存储,而下一轮的数据须要被读出来。经过转移读取的时间,读和写就不须要同时发出了
 
2.3主存的其余用户
除了CPU外,系统中还有其它一些组件也能够访问主存。高性能网卡或大规模存储控制器是没法承受经过CPU来传输数据的,它们通常直接对内存进行读写(直接内存访问,DMA)。在图2.1中能够看到,它们能够经过南桥和北桥直接访问内存。另外,其它总线,好比USB等也须要FSB带宽,即便它们并不使用DMA,但南桥仍要经过FSB链接到北桥。
 
DMA固然有很大的优势,但也意味着FSB带宽会有更多的竞争。在有大量DMA流量的状况下,CPU在访问内存时必然会有更大的延迟。咱们能够用一些硬件来解决这个问题。例如,经过图2.3中的架构,咱们能够挑选不受DMA影响的节点,让它们的内存为咱们的计算服务。还能够在每一个节点上链接一个南桥,将FSB的负荷均匀地分担到每一个节点上。
 
附A
三极管 有3个极1.发射极(e),基极(b),集电极(c)
输入特性:当b输入必定电压是,发射端导通
输出特性:当c,e之间加点压,电流大小,有基极输入电流控制c和e之间的电流大小
这样对动态内存单元中的电容来讲每次读取就是一个放过程,因此读完以后要从新充电
3.1 高速缓存的位置
 
早期的一些系统就是相似的架构。在这种架构中,CPU核心再也不直连到主存。数据的读取和存储都通过高速缓存。主存与高速缓存都连到系统总线上,这条总线同时还用于与其它组件通讯。咱们管这条总线叫“FSB”.
在高速缓存出现后不久,系统变得更加复杂。高速缓存与主存之间的速度差别进一步拉大,直到加入了另外一级缓存。新加入的这一级缓存比第一级缓存更大,可是更慢。因为加大一级缓存的作法从经济上考虑是行不通的,因此有了二级缓存,甚至如今的有些系统拥有三级缓存.
L1d是一级数据缓存,L1i是一级指令缓存,等等。请注意,这只是示意图, 真正的数据流并不须要流经上级缓存。CPU的设计者们在设计高速缓存的接口时拥有很大的自由。
咱们有多核CPU,每一个核心能够有多个“线程”。核心与线程的不一样之处在于,核心拥有独立的硬件资源
3.2 高级的缓存操做
默认状况下,CPU核心全部的数据的读或写都存储在缓存中。固然,也有内存区域不能被缓存的
若是CPU须要访问某个字(word),先检索缓存。很显然,缓存不可能容纳主存全部内容(不然还须要主存干吗)。系统用字的内存地址来对缓存条目进行标记。若是须要读写某个地址的字,那么根据标签来检索缓存便可(后面会介绍还会使用地址来计算缓存的地址)
标签是须要额外空间的,用字做为缓存的粒度显然毫无效率。由于标签可能也有32位(x86上)。内存模块在传输位于同一行上的多份数据时,因为不须要发送新CAS信号,甚至不须要发送RAS信号,所以能够实现很高的效率。基于以上的缘由,缓存条目并不存储单个字,而是存储若干连续字组成的“线”。在早期的缓存中,线长是32字节,如今通常是64字节。对于64位宽的内存总线,每条线须要8次传输。而DDR对于这种传输模式的支持更为高效。
当处理器须要内存中的某块数据时,整条缓存线被装入L1d。缓存线的地址经过对内存地址进行掩码操做生成。对于64字节的缓存线,是将低6位置0。这些被丢弃的位做为线内偏移量。其它的位做为标签,并用于在缓存内定位。在实践中,咱们将地址分为三个部分。32位地址的状况以下:
若是缓存线长度为2O,那么地址的低O位用做线内偏移量。上面的S位选择“缓存集”。后面咱们会说明使用缓存集的缘由。如今只须要明白一共有2S个缓存集就够了。剩下的32 - S - O = T位组成标签。它们用来同一个cache set中的各条线
当某条指令修改内存时,仍然要先装入缓存线,由于任何指令都不可能同时修改整条线。所以须要在写操做前先把缓存线装载进来。若是缓存线被写入,但尚未写回主存,那就是所谓的“脏了”。脏了的线一旦写回主存,脏标记即被清除。为了装入新数据,基本上老是要先在缓存中清理出位置。L1d将内容逐出L1d,推入L2(线长相同)。固然,L2也须要清理位置。因而L2将内容推入L3,最后L3将它推入主存。这种逐出操做一级比一级昂贵。(因此AMD公司一般使用exclusive caches见 附录1,Intel使用inclusive cache)
在对称多处理器(SMP)架构的系统中,CPU的高速缓存不能独立的工做。在任什么时候候,全部的处理器都应该拥有相同的内存内容。保证这样的统一的内存视图被称为“高速缓存一致性”从一个处理器直接访问另外一个处理器的高速缓存这种模型设计代价将是很是昂贵的,它是一个至关大的瓶颈。而是使用,当另外一个处理器要读取或写入到高速缓存线上时,处理器会去检测。 
若是CPU检测到一个写访问,并且该CPU的cache中已经缓存了一个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的写访问会致使第二个cpu的cache line的全部拷贝被标记为无效。
对于写入,cpu不须要等待安全的写入,只要能模拟这个效果,cpu就能够走捷径
如下是随机写入的图:
 
图中有三个比较明显的不一样阶段。很正常,这个处理器有L1d和L2,没有L3。根据经验能够推测出,L1d有2**13字节,而L2有2**20字节。由于,若是整个工做集均可以放入L1d,那么只需不到10个周期就能够完成操做。若是工做集超过L1d,处理器不得不从L2获取数据,因而时间飘升到28个周期左右。若是工做集更大,超过了L2,那么时间进一步暴涨到480个周期以上。这时候,许多操做将不得不从主存中获取数据。更糟糕的是,若是修改了数据,还须要将这些脏了的缓存线写回内存。( 为何工做集超过了L1d就会从L2中取数若为读,当读入的数据不在L1就要向L2获取,不在L2就要想L3或者内存获取,工做集越大,致使L1,L2被填满。如果写同理学入到脏数据存放在L1中,时间一长L1填满依次类推
3.3 CPU缓存实现的细节
3.3.1 关联性
咱们可让缓存的每条线能存听任何内存地址的数据。这就是所谓的全关联缓存(fully associative cache)。这种缓存方式,若是处理器要访问某条线,那么须要全部的cacheline的tag和请求的地址比较。
全关联:优势,能够听任意地址的数据,不会出现相似直接映射同样的情况,若是数据分布不均匀致使被换出,从而致使miss增长
              缺点,当cpu给出一个地址访问某一个缓存线,须要扫描全部的缓存
直接映射:优势,电路简单,查询某个元素的速度很快。由于一个元素出如今cache的位子是固定的。
               缺点,若是数据分布在同一个set那么,会致使miss大大的提升
组关联:这个是当前广泛的使用方法。是直接映射和全关联的折中办法
L2
Cache
Size
Associativity
Direct 2 4 8
CL=32 CL=64 CL=32 CL=64 CL=32 CL=64 CL=32 CL=64
512k 27,794,595 20,422,527 25,222,611 18,303,581 24,096,510 17,356,121 23,666,929 17,029,334
1M 19,007,315 13,903,854 16,566,738 12,127,174 15,537,500 11,436,705 15,162,895 11,233,896
2M 12,230,962 8,801,403 9,081,881 6,491,011 7,878,601 5,675,181 7,391,389 5,382,064
4M 7,749,986 5,427,836 4,736,187 3,159,507 3,788,122 2,418,898 3,430,713 2,125,103
8M 4,731,904 3,209,693 2,690,498 1,602,957 2,207,655 1,228,190 2,111,075 1,155,847
16M 2,620,587 1,528,592 1,958,293 1,089,580 1,704,878 883,530 1,671,541 862,324
 
表说明:关联度,cache大小,cacheline大小对miss的影响。从上面数据得出的结论是CL64比CL32miss要少,cache越到miss越少,关联度越高miss越少
图是表数据的图标化更容易看出,问题其中CL大小为32。cache size越大miss越少,关联越大miss 越少
在其余文献中提到说增长关联度和增长缓存有相同的效果,这个固然是不正确的看图中,4m-8m直接关联和,2路关联是有同样的提高,可是当缓存愈来愈大就很差说了。测试程序的workset只有5.6M,使用8MB以后天然没法体现优点。可是当workset愈来愈大,小缓存的关联性就体现出了巨大的优点。
随着多核cpu的出现,相对来讲cache的大小就被平分,所以关联性就显得比较重要。可是已经到了16路关联,若是再加显然比较困难,因此就有厂家开始考虑使用3级缓存。
3.3.2 Cache的性能测试
用于测试程序的数据能够模拟一个任意大小的工做集:包括读、写访问,随机、连续访问。在图3.4中咱们能够看到,程序为工做集建立了一个与其大小和元素类型相同的数组:
  struct l {
    struct l *n;
    long int pad[NPAD];
  };
2**N 字节的工做集包含2**N/sizeof(struct l)个元素。显然sizeof(struct l) 的值取决于NPAD的大小。在32位系统上,NPAD=7意味着数组的每一个元素的大小为32字节,在64位系统上,NPAD=7意味着数组的每一个元素的大小为64字节。( 关于如何CHECK,我还不知道)
单线程顺序访问
最简单的状况就是遍历链表中顺序存储的节点。不管是从前向后处理,仍是从后向前,对于处理器来讲没有什么区别。下面的测试中,咱们须要获得处理链表中一个元素所须要的时间,以CPU时钟周期最为计时单元。图显示了测试结构。除非有特殊说明, 全部的测试都是在Pentium 4 64-bit 平台上进行的,所以结构体l中NPAD=0,大小为8字节。
                顺序读访问, NPAD=0
                               顺序读多个字节
一开始的两个测试数据收到了噪音的污染。因为它们的工做负荷过小,没法过滤掉系统内其它进程对它们的影响。咱们能够认为它们都是4个周期之内的。这样一来,整个图能够划分为比较明显的三个部分:
  • 工做集小于2**14字节的。
  • 工做集从2**15字节到2**20字节的。
  • 工做集大于2**21字节的。
这样的结果很容易解释——是由于处理器有16KB的L1d和1MB的L2。
L1d的部分跟咱们预想的差很少,在一台P4上耗时为4个周期左右。但L2的结果则出乎意料。你们可能以为须要14个周期以上,但实际只用了9个周期。这要归功于处理器先进的处理逻辑,当它使用连续的内存区时,会 预先读取下一条缓存线的数据。这样一来,当真正使用下一条线的时候,其实已经早已读完一半了,因而真正的等待耗时会比L2的访问时间少不少。
在工做集超过L2的大小以后,预取的效果更明显了。前面咱们说过,主存的访问须要耗时200个周期以上。但在预取的帮助下,实际耗时保持在9个周期左右。200 vs 9,效果很是不错。
在L2阶段,三条新加的线基本重合,并且耗时比老的那条线高不少,大约在28个周期左右,差很少就是L2的访问时间。这代表,从L2到L1d的预取并无生效。这是由于,对于最下面的线(NPAD=0),因为结构小,8次循环后才须要访问一条新缓存线,而上面三条线对应的结构比较大,拿相对最小的NPAD=7来讲,光是一次循环就须要访问一条新线,更不用说更大的NPAD=15和31了。而预取逻辑是没法在每一个周期装载新线的,所以每次循环都须要从L2读取,咱们看到的就是从L2读取的时延。
(有一点想不通做者这里说是28个周期是L2的访问时间,可是上面为何说是14个周期,有一种不靠谱的感受。元素大小太大致使预取效果不好,可是顺序的访问方式很容易被预取,为何不没有呢,做者的观点是 预取逻辑是没法在每一个周期装载新线的因此致使预取无效)
另外一个致使慢下来的缘由是TLB缓存的未命中。TLB是存储虚实地址映射的缓存。为了保持快速,TLB只有很小的容量。若是有大量页被反复访问,超出了TLB缓存容量,就会致使反复地进行地址翻译,这会耗费大量时间。TLB查找的代价分摊到全部元素上,若是元素越大,那么元素的数量越少,每一个元素承担的那一份就越多。
为了观察TLB的性能,咱们能够进行另两项测试。第一项:咱们仍是顺序存储列表中的元素,使NPAD=7,让每一个元素占满整个cache line,第二项:咱们将列表的每一个元素存储在一个单独的页上,忽略每一个页没有使用的部分以用来计算工做集的大小。结果代表,第一项测试中,每次列表的迭代都须要一个新的cache line,并且每64个元素就须要一个新的页。第二项测试中,每次迭代都会访问一个cache,都须要加载一个新页。
           图 3.12: TLB 对顺序读的影响
基于可用RAM空间的有限性,测试设置容量空间大小为2的24次方字节,这就须要1GB的容量将对象放置在分页上。NPAD等于7的曲线。咱们看到不一样的步长显示了高速缓存L1d和L2的大小。第二条曲线看上去彻底不一样,其最重要的特色是当工做容量到达2的13次方字节时开始大幅度增加。这就是TLB缓存溢出的时候。咱们能计算出一个64字节大小的元素的TLB缓存有64个输入。成本不会受页面错误影响,由于程序锁定了存储器以防止内存被换出。能够看出,计算物理地址并把它存储在TLB中所花费的周期数量级是很是高的。从中能够清楚的获得:TLB缓存效率下降的一个重要因素是大型NPAD值的减缓。因为物理地址必须在缓存行能被L2或主存读取以前计算出来,地址转换这个不利因素就增长了内存访问时间。这一点部分解释了为何NPAD等于31时每一个列表元素的总花费比理论上的RAM访问时间要高。
          图3.13 NPAD等于1时的顺序读和写
全部状况下元素宽度都为16个字节。第一条曲线“Follow”是熟悉的链表走线在这里做为基线。第二条曲线,标记为“Inc”,仅仅在当前元素进入下一个前给其增长thepad[0]成员。第三条曲线,标记为"Addnext0", 取出下一个元素的thepad[0]链表元素并把它添加为当前链表元素的thepad[0]成员。
在没运行时,你们可能会觉得"Addnext0"更慢,由于它要作的事情更多——在没进到下个元素以前就须要装载它的值。但实际的运行结果使人惊讶——在某些小工做集下,"Addnext0"比"Inc"更快。这是为何呢?缘由在于, 系统通常会对下一个元素进行强制性预取。当程序前进到下个元素时这个元素其实早已被预取在L1d里。可是,"Addnext0"比"Inc"更快离开L2,这是由于它须要从主存装载更多的数据。而在工做集达到2 21字节时,"Addnext0"的耗时达到了28个周期,是同期"Follow"14周期的两倍。这个两倍也很好解释。"Addnext0"和"Inc"涉及对内存的修改,所以L2的逐出操做不能简单地把数据一扔了事,而必须将它们写入内存。所以FSB的可用带宽变成了一半,传输等量数据的耗时也就变成了原来的两倍。
           图3.14: 更大L2/L3缓存的优点
决定顺序式缓存处理性能的另外一个重要因素是缓存容量。虽然这一点比较明显,但仍是值得一说。图中展现了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,而是工做集开始增加到连末级缓存也放不下、须要主存热情参与以后的部分。与咱们预计的类似,最末级缓存越大,曲线停留在L2访问耗时区的时间越长。在2**20字节的工做集时,第二台P4(更老一些)比第一台P4要快上一倍,这要彻底归功于更大的末级缓存。而Core 2拜它巨大的4M L2所赐,表现更为卓越。
单线程随机访问模式的测量
           图3.15: 顺序读取vs随机读取,NPAD=0
若是换成随机访问或者不可预测的访问,状况就大不相同了。图3.15比较了顺序读取与随机读取的耗时状况。
换成随机以后,处理器没法再有效地预取数据,只有少数状况下靠运气恰好碰到前后访问的两个元素挨在一块儿的情形。
图3.15中有两个须要关注的地方。
首先,在大的工做集下须要很是多的周期。这台机器访问主存的时间大约为200-300个周期,但图中的耗时甚至超过了450个周期。咱们前面已经观察到过相似现象(对比图3.11)。这说明,处理器的自动预取在这里起到了反效果。
其次,表明随机访问的曲线在各个阶段不像顺序访问那样保持平坦,而是不断攀升。为了解释这个问题,咱们测量了程序在不一样工做集下对L2的访问状况。结果如图3.16和表3.2。
            图3.16: L2d未命中率
Set
Size
Sequential Random
L2 Hit L2 Miss #Iter Ratio Miss/Hit L2 Accesses Per Iter L2 Hit L2 Miss #Iter Ratio Miss/Hit L2 Accesses Per Iter
220 88,636 843 16,384 0.94% 5.5 30,462 4721 1,024 13.42% 34.4
221 88,105 1,584 8,192 1.77% 10.9 21,817 15,151 512 40.98% 72.2
222 88,106 1,600 4,096 1.78% 21.9 22,258 22,285 256 50.03% 174.0
223 88,104 1,614 2,048 1.80% 43.8 27,521 26,274 128 48.84% 420.3
224 88,114 1,655 1,024 1.84% 87.7 33,166 29,115 64 46.75% 973.1
225 88,112 1,730 512 1.93% 175.5 39,858 32,360 32 44.81% 2,256.8
226 88,112 1,906 256 2.12% 351.6 48,539 38,151 16 44.01% 5,418.1
227 88,114 2,244 128 2.48% 705.9 62,423 52,049 8 45.47% 14,309.0
228 88,120 2,939 64 3.23% 1,422.8 81,906 87,167 4 51.56% 42,268.3
229 88,137 4,318 32 4.67% 2,889.2 119,079 163,398 2 57.84% 141,238.5
               
       表3.2: 顺序访问与随机访问时L2命中与未命中的状况,NPAD=0
从图中能够看出,当工做集大小超过L2时,未命中率(L2未命中次数/L2访问次数)开始上升。整条曲线的走向与图3.15有些类似: 先急速爬升,随后缓缓下滑,最后再度爬升。它与耗时图有紧密的关联。L2未命中率会一直爬升到100%为止。只要工做集足够大(而且内存也足够大),就能够将缓存线位于L2内或处于装载过程当中的可能性降到很是低。
(工做集越大,随机访问,命中率就会越小)
           图3.17: 页意义上(Page-Wise)的随机化,NPAD=7
而换成随机访问后,单位耗时的增加超过了工做集的增加,根源是TLB未命中率的上升。图3.17描绘的是NPAD=7时随机访问的耗时状况。这一次,咱们修改了随机访问的方式。正常状况下是把整个列表做为一个块进行随机(以∞表示),而其它11条线则是在小一些的块里进行随机。例如,标签为'60'的线表示以60页(245760字节)为单位进行随机。先遍历完这个块里的全部元素,再访问另外一个块。这样一来,能够保证任意时刻使用的TLB条目数都是有限的。 (也就是上图的性能差距主要来自于TLB的未命中率)
 
NPAD=7对应于64字节,正好等于缓存线的长度。因为元素顺序随机,硬件预取不可能有任何效果,特别是在元素较多的状况下。这意味着,分块随机时的L2未命中率与整个列表随机时的未命中率没有本质的差异。随着块的增大,曲线逐渐逼近整个列表随机对应的曲线。这说明,在这个测试里,性能受到TLB命中率的影响很大,若是咱们能提升TLB命中率,就能大幅度地提高性能(在后面的一个例子里,性能提高了38%之多)。( 做者在这里突出当元素长度>=cache line 长度的时候而且元素是随机访问硬件预取失效,为何?我根据做者提供的结构体加上vtune,当结构体大小恰好为64B的时候并无发现L1的miss
3.3.3 写入时的行为
为了保持cache和内存的一致性,当cache被修改后,咱们要刷新到主存中(flush),能够经过2种方式实现:1.write through(写透),2.write back(写回)
写透是当修改cache会马上写入到主存,
缺点:速度慢,占用FSB总线
优势:实现简单
写回是当修改cache后不是立刻写入到主存,而是打上已弄脏(dirty)的标记。当之后某个时间点缓存线被丢弃时,这个已弄脏标记会通知处理器把数据写回到主存中,而不是简单地扔掉。
优势:速度快
缺点:当有多个处理器(或核心、超线程)访问同一块内存时,必须确保它们在任什么时候候看到的都是相同的内容。若是缓存线在其中一个处理器上弄脏了(修改了,但还没写回主存),而第二个处理器恰好要读取同一个内存地址,那么这个读操做不能去读主存,而须要读第一个处理器的缓存线。
3.3.4 多处理器支持
直接提供从一个处理器到另外一处理器的高速访问,这是彻底不切实际的。从一开始,链接速度根本就不够快。实际的选择是,在其须要的状况下,转移到其余处理器
如今的问题是,当该高速缓存线转移的时候会发生什么?这个问题回答起来至关容易:当一个处理器须要在另外一个处理器的高速缓存中读或者写的脏的高速缓存线的时候。但怎样处理器怎样肯定在另外一个处理器的缓存中的高速缓存线是脏的?假设它仅仅是由于一个高速缓存线被另外一个处理器加载将是次优的(最好的)。一般状况下,大多数的内存访问是只读的访问和产生高速缓存线,并不脏。在高速缓存线上处理器频繁的操做(固然,不然为何咱们有这样的文件呢?),也就意味着每一次写访问后,都要广播关于高速缓存线的改变将变得不切实际。
人们开发除了MESI缓存一致性协议(MESI=Modified, Exclusive, Shared, Invalid,变动的、独占的、共享的、无效的)。协议的名称来自协议中缓存线能够进入的四种状态:
  • 变动的: 本地处理器修改了缓存线。同时暗示,它是全部缓存中惟一的拷贝。
  • 独占的: 缓存线没有被修改,并且没有被装入其它处理器缓存。
  • 共享的: 缓存线没有被修改,但可能已被装入其它处理器缓存。
  • 无效的: 缓存线无效,即,未被使用。
MESI协议开发了不少年,最初的版本比较简单,可是效率也比较差。如今的版本经过以上4个状态能够有效地实现写回式缓存,同时支持不一样处理器对只读数据的并发访问。
(写回如何被实现,经过监听其处理器的状态)
在协议中,经过处理器监听其它处理器的活动,不需太多努力便可实现状态变动。处理器将操做发布在外部引脚上,使外部能够了解处处理过程。
一开始,全部缓存线都是空的,缓存为无效(Invalid)状态。当有数据装进缓存供写入时,缓存变为变动(Modified)状态。若是有数据装进缓存供读取,那么新状态取决于其它处理器是否已经状态了同一条缓存线。若是是,那么新状态变成共享(Shared)状态,不然变成独占(Exclusive)状态。
若是本地处理器对某条Modified缓存线进行读写,那么直接使用缓存内容,状态保持不变。若是另外一个处理器但愿读它,那么第一个处理器将内容发给第二个处理器,而后能够将缓存状态置为Shared。而发给第二个处理器的数据由内存控制器接收,并放入内存中。若是这一步没有发生,就不能将这条线置为Shared。若是第二个处理器但愿的是写,那么第一个处理器将内容发给它后,将缓存置为Invalid。这就是臭名昭著的"请求全部权(Request For Ownership,RFO)"操做。在末级缓存执行RFO操做的代价比较高。若是是写通式缓存,还要加上将内容写入上一层缓存或主存的时间,进一步提高了代价。
对于Shared缓存线,本地处理器的读取操做并不须要修改状态,并且能够直接从缓存知足。而本地处理器的写入操做则须要将状态置为Modified,并且须要将缓存线在其它处理器的全部拷贝置为Invalid。所以,这个写入操做须要经过RFO消息发通知其它处理器。若是第二个处理器请求读取,无事发生。由于主存已经包含了当前数据,并且状态已经为Shared。若是第二个处理器须要写入,则将缓存线置为Invalid。不须要总线操做。
Exclusive状态与Shared状态很像,只有一个不一样之处: 在Exclusive状态时,本地写入操做不须要在总线上声明,由于本地的缓存是系统中惟一的拷贝。这是一个巨大的优点,因此处理器会尽可能将缓存线保留在Exclusive状态,而不是Shared状态。只有在信息不可用时,才退而求其次选择shared。放弃Exclusive不会引发任何功能缺失,但会致使性能降低,由于E→M要远远快于S→M。
 
从以上的说明中应该已经能够看出,在多处理器环境下,哪一步的代价比较大了。填充缓存的代价固然仍是很高,但咱们还须要留意RFO消息。一旦涉及RFO,操做就快不起来了。
RFO在两种状况下是必需的:
  • 线程从一个处理器迁移到另外一个处理器,须要将全部缓存线移到新处理器。
  • 某条缓存线确实须要被两个处理器使用。{对于同一处理器的两个核心,也有一样的状况,只是代价稍低。RFO消息可能会被发送屡次。}
缓存一致性协议的消息必须发给系统中全部处理器。只有当协议肯定已经给过全部处理器响应机会以后,才能进行状态跃迁。也就是说,协议的速度取决于最长响应时间。
对同步来讲,有限的带宽严重地制约着并发度。程序须要更加谨慎的设计,将不一样处理器访问同一块内存的机会降到最低。
多线程测量
            图3.19: 顺序读操做,多线程
图3.19展现了顺序读访问时的性能,元素为128字节长(64位计算机,NPAD=15)。对于单线程的曲线,咱们预计是与图3.11类似,只不过是换了一台机器,因此实际的数字会有些小差异。
 
更重要的部分固然是多线程的环节。因为是只读,不会去修改内存,不会尝试同步。但即便不须要RFO,并且全部缓存线均可共享,性能仍然分别降低了18%(双线程)和34%(四线程)。因为不须要在处理器之间传输缓存,所以这里的性能降低彻底由如下两个瓶颈之一或同时引发: 一是从处理器到内存控制器的共享总线,二是从内存控制器到内存模块的共享总线。
            图3.20: 顺序递增,多线程
咱们用对数刻度来展现L1d范围的结果。能够发现,当超过一个线程后,L1d就无力了。单线程时,仅当工做集超过L1d时访问时间才会超过20个周期,而多线程时,即便在很小的工做集状况下,访问时间也达到了那个水平。
            图3.21: 随机的Addnextlast,多线程
最后,在图3.21中,咱们展现了随机访问的Addnextlast测试的结果。这里主要是为了让你们感觉一下这些巨大到爆的数字。极端状况下,甚至用了1500个周期才处理完一个元素。若是加入更多线程,真是不可想象哪。
     图3.22: 经过并行化实现的加速因子
图3.22中的曲线展现了加速因子,即多线程相对于单线程所能获取的性能加成值。测量值的精确度有限,所以咱们须要忽略比较小的那些数字。能够看到,在L2与L3范围内,多线程基本能够作到线性加速,双线程和四线程分别达到了2和4的加速因子。可是,一旦工做集的大小超出L3,曲线就崩塌了,双线程和四线程降到了基本相同的数值(参见表3.3中第4列)。 也是部分因为这个缘由,咱们不多看到4CPU以上的主板共享同一个内存控制器。若是须要配置更多处理器,咱们只能选择其它的实现方式(参见第5节)。
特例: 超线程
它真正的优点在于,CPU能够在当前运行的超线程发生延迟时,调度另外一个线程。这种延迟通常由内存访问引发。
若是两个线程运行在一个超线程核心上,那么只有当两个线程合起来的运行时间少于单线程运行时间时,效率才会比较高
程序的执行时间能够经过一个只有一级缓存的简单模型来进行估算(参见[htimpact]):
  exe    = N[(1-F    mem   )T    proc    + F    mem   (G    hit     cache    + (1-G    hit   )T    miss   )]
各变量的含义以下:
N = 指令数
Fmem = N中访问内存的比例
Ghit = 命中缓存的比例
Tproc = 每条指令所用的周期数
Tcache = 缓存命中所用的周期数
Tmiss = 缓冲未命中所用的周期数
Texe = 程序的执行时间
(也就是说在命中的时间+非命中的时间)
      图 3.23: 最小缓存命中率-加速
红色区域为单线程的命中率,绿色为双线程,好比 若是单线程命中率不低于60%,那么双线程就不能低于10%。绿色区域是咱们要达到的目标,
所以,超线程只在某些状况下才比较有用。单线程代码的缓存命中率必须低到必定程度,从而使缓存容量变小时新的命中率仍能知足要求。只有在这种状况下,超线程才是有意义的。在实践中,采用超线程可否得到更快的结果,取决于处理器可否有效地将每一个进程的等待时间与其它进程的执行时间重叠在一块儿。并行化也须要必定的开销,须要加到总的运行时间里,这个开销每每是不能忽略的。
3.3.5 其它细节
咱们已经介绍了地址的组成,即标签、集合索引和偏移三个部分。那么,实际会用到什么样的地址呢?目前,处理器通常都向进程提供虚拟地址空间,意味着咱们有两种不一样的地址: 虚拟地址和物理地址。
 
虚拟地址有个问题——并不惟一。随着时间的变化,虚拟地址能够变化,指向不一样的物理地址。
处理器指令用的虚拟地址,并且须要在内存管理单元(MMU)的协助下将它们翻译成物理地址。这并非一个很小的操做。在执行指令的管线(pipeline)中,物理地址只能在很后面的阶段才能获得。 这意味着,缓存逻辑须要在很短的时间里判断地址是否已被缓存过。而若是能够使用虚拟地址,缓存查找操做就能够更早地发生,一旦命中,就能够立刻使用内存的内容。结果就是, 使用虚拟内存后,可让管线把更多内存访问的开销隐藏起来
处理器的设计人员们如今使用虚拟地址来标记第一级缓存。
对于更大的缓存,包括L2和L3等,则须要以物理地址做为标签。由于这些缓存的时延比较大,虚拟到物理地址的映射能够在容许的时间里完成,并且因为主存时延的存在,从新填充这些缓存会消耗比较长的时间,刷新的代价比较昂贵。(刷新就是写入到内存)
 
通常来讲,咱们并不须要了解这些缓存处理地址的细节。咱们不能更改它们,而那些可能影响性能的因素,要么是应该避免的,要么是伴随更高代价的。填满缓存是很差的行为,缓存线都落入同一个集合,也会让缓存早早地出问题。(和关联性相关) 对于后一个问题,能够经过cache address中使用虚拟地址来避免(如何避免,依靠系统?),但做为一个用户级程序,是不可能避免缓存物理地址的。咱们惟一能够作的,是尽最大努力不要在同一个进程里用多个虚拟地址映射同一个物理地址(避免同一个数据在cache中有多个记录)。
3.4 指令缓存
其实,不光处理器使用的数据被缓存,它们执行的指令也是被缓存的。只不过,指令缓存的问题相对来讲要少得多,由于:
  • 执行的代码量取决于代码大小。而代码大小一般取决于问题复杂度。问题复杂度则是固定的。
  • 程序的数据处理逻辑是程序员设计的,而程序的指令倒是编译器生成的。编译器的做者知道如何生成优良的代码。
  • 程序的流向比数据访问模式更容易预测。现现在的CPU很擅长模式检测,对预取颇有利。
  • 代码永远都有良好的时间局部性和空间局部性。
有一些准则是须要程序员们遵照的,但大都是关于如何使用工具的,咱们会在第6节介绍它们。而在这里咱们只介绍一下指令缓存的技术细节。
随着CPU的核心频率大幅上升,缓存与核心的速度差越拉越大,CPU的处理开始管线化。也就是说,指令的执行分红若干阶段。首先,对指令进行解码,随后,准备参数,最后,执行它。这样的管线能够很长(例如,Intel的Netburst架构超过了20个阶段)。在管线很长的状况下,一旦发生延误(即指令流中断),须要很长时间才能恢复速度。管线延误发生在这样的状况下: 下一条指令未能正确预测,或者装载下一条指令耗时过长(例如,须要从内存读取时)。
3.4.1 自修改的代码
3.5 缓存未命中的因素
咱们已经看过内存访问没有命中缓存时,那陡然猛涨的高昂代价。可是有时候,这种状况又是没法避免的,所以咱们须要对真正的代价有所认识,并学习如何缓解这种局面。
3.5.1 缓存与内存带宽 
           图3.24: P4的带宽
当工做集可以彻底放入L1d时,处理器的每一个周期能够读取完整的16字节数据,即每一个周期执行一条装载指令(moveaps指令,每次移动16字节的数据)。测试程序并不对数据进行任何处理,只是测试读取指令自己。当工做集增大,没法再彻底放入L1d时,性能开始急剧降低,跌至每周期6字节。在218工做集处出现的台阶是因为DTLB缓存耗尽,所以须要对每一个新页施加额外处理。因为这里的读取是按顺序的,预取机制能够完美地工做,而FSB能以5.3字节/周期的速度传输内容。但预取的数据并不进入L1d (放不进L1D放去了哪里?)。固然,真实世界的程序永远没法达到以上的数字,但咱们能够将它们看做一系列实际上的极限值
更使人惊讶的是写操做和复制操做的性能。即便是在很小的工做集下,写操做也始终没法达到4字节/周期的速度。这意味着,Intel为Netburst处理器的L1d选择了写通(write-through)模式,因此写入性能受到L2速度的限制。同时,这也意味着,复制测试的性能不会比写入测试差太多(复制测试是将某块内存的数据拷贝到另外一块不重叠的内存区),由于读操做很快,能够与写操做实现部分重叠。最值得关注的地方是,两个操做在工做集没法彻底放入L2后出现了严重的性能滑坡,降到了0.5字节/周期!比读操做慢了10倍! (慢在哪里?)显然,若是要提升程序性能,优化这两个操做更为重要
图3.25采用了与图3.24相同的刻度,以方便比较二者的差别。图3.25中的曲线抖动更多,是因为采用双线程的缘故。结果正如咱们预期,因为超线程共享着几乎全部资源(仅除寄存器外),因此每一个超线程只能获得一半的缓存和带宽。因此,即便每一个线程都要花上许多时间等待内存,从而把执行时间让给另外一个线程,也是无济于事——由于另外一个线程也一样须要等待。这里偏偏展现了使用超线程时可能出现的最坏状况。
 
写/复制操做的性能与P4相比,也有很大差别。处理器没有采用写通策略,写入的数据留在L1d中,只在必要时才逐出。这使得写操做的速度能够逼近16字节/周期。一旦工做集超过L1d,性能即飞速降低。因为Core 2读操做的性能很是好,因此二者的差值显得特别大。当工做集超过L2时,二者的差值甚至超过20倍!但这并不表示Core 2的性能很差,相反,Core 2永远都比Netburst强。
           图3.27: Core 2运行双线程时的带宽表现
在图3.27中,启动双线程,各自运行在Core 2的一个核心上。它们访问相同的内存,但不须要完美同步。从结果上看,读操做的性能与单线程并没有区别,只是多了一些多线程状况下常见的抖动。
当工做集小于L1d时,写操做与复制操做的性能不好,就好像数据须要从内存读取同样。两个线程彼此竞争着同一个内存位置,因而不得不频频发送RFO消息。问题的根源在于,虽然两个核心共享着L2,但没法以L2的速度处理RFO请求。
当工做集小于L1d时,写操做与复制操做的性能不好,就好像数据须要从内存读取同样。两个线程彼此竞争着同一个内存位置,因而不得不频频发送RFO消息。问题的根源在于,虽然两个核心共享着L2,但没法以L2的速度处理RFO请求。 而当工做集超过L1d后,性能出现了迅猛提高。这是由于,因为L1d容量不足,因而将被修改的条目刷新到共享的L2。因为L1d的未命中能够由L2知足, 只有那些还没有刷新的数据才须要RFO,因此出现了这样的现象。这也是这些工做集状况下速度降低一半的缘由。
 
图3.28展现了AMD家族10h Opteron处理器的性能。这颗处理器有64kB的L1d、512kB的L2和2MB的L3,其中L3缓存由全部核心所共享。
           图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: AMD Fam 10h在双线程时的带宽表现
读操做的性能没有受到很大的影响。每一个线程的L1d和L2表现与单线程下相仿,L3的预取也依然表现不佳。两个线程并无过渡争抢L3。问题比较大的是写操做的性能。两个线程共享的全部数据都须要通过L3,而这种共享看起来却效率不好。即便是在L3足够容纳整个工做集的状况下,所须要的开销仍然远高于L3的访问时间。再来看图3.27,能够发现,在必定的工做集范围内,Core 2处理器能以共享的L2缓存的速度进行处理。而Opteron处理器只能在很小的一个范围内实现类似的性能,并且,它仅仅只能达到L3的速度,没法与Core 2的L2相比。
3.5.2 关键字加载
事实上,内存控制器能够按不一样顺序去请求缓存线中的字。当处理器告诉它,程序须要缓存中具体某个字,即「关键字(critical word)」时,内存控制器就会先请求这个字。一旦请求的字抵达,虽然缓存线的剩余部分还在传输中,缓存的状态尚未达成一致,但程序已经能够继续运行。这种技术叫作关键字优先及较早重启(Critical Word First & Early Restart)。
           图3.30: 关键字位于缓存线尾时的表现
图3.30展现了下一个测试的结果,图中表示的是关键字分别在线首和线尾时的性能对比状况。元素大小为64字节,等于缓存线的长度。图中的噪声比较多,但仍然能够看出,当工做集超过L2后,关键字处于线尾状况下的性能要比线首状况下低0.7%左右。
3.5.3 缓存设定
关于各类处理器模型的优势,已经在它们各自的宣传手册里写得够多了。在每一个核心的工做集互不重叠的状况下,独立的L2拥有必定的优点,单线程的程序能够表现优良。考虑到目前实际环境中仍然存在大量相似的状况,这种方法的表现并不会太差。不过,无论怎样,咱们总会遇到工做集重叠的状况。若是每一个缓存都保存着某些通用运行库的经常使用部分,那么很显然是一种浪费。
若是像Intel的双核处理器那样,共享除L1外的全部缓存,则会有一个很大的优势。若是两个核心的工做集重叠的部分较多,那么综合起来的可用缓存容量会变大,从而容许容纳更大的工做集而不致使性能的降低。若是二者的工做集并不重叠,那么则是由Intel的高级智能缓存管理(Advanced Smart Cache management)发挥功用,防止其中一个核心垄断整个缓存。
           图3.31: 两个进程的带宽表现
此次,测试程序两个进程,第一个进程不断用SSE指令读/写2MB的内存数据块,选择2MB,是由于它正好是Core 2处理器L2缓存的一半,第二个进程则是读/写大小变化的内存区域,咱们把这两个进程分别固定在处理器的两个核心上。图中显示的是每一个周期读/写的字节数,共有4条曲线,分别表示不一样的读写搭配状况。例如,标记为读/写(read/write)的曲线表明的是后台进程进行写操做(固定2MB工做集),而被测量进程进行读操做(工做集从小到大)。
 
图中最有趣的是2**20到2**23之间的部分。若是两个核心的L2是彻底独立的,那么全部4种状况下的性能降低均应发生在221到222之间,也就是L2缓存耗尽的时候。但从图上来看,实际状况并非这样,特别是背景进程进行写操做时尤其明显。 当工做集达到1MB(220)时,性能即出现恶化,两个进程并无共享内存,所以并不会产生RFO消息。因此,彻底是缓存逐出操做引发的问题。目前这种智能的缓存处理机制有一个问题,每一个核心能实际用到的缓存更接近1MB,而不是理论上的2MB。
3.5.4 FSB的影响
FSB在性能中扮演了核心角色。缓存数据的存取速度受制于内存通道的速度。
           图3.32: FSB速度的影响
图上的数字代表,当工做集大到对FSB形成压力的程度时,高速FSB确实会带来巨大的优点。在咱们的测试中,性能的提高达到了18.5%,接近理论上的极限。而当工做集比较小,能够彻底归入缓存时,FSB的做用并不大。
 
附录1
exclusive caches:就是全部的缓存中只保留一份缓存线。
     好处:能存更多的数据。坏处:当L1 miss,L2 hit后,L2要把数据交换到L1上,这个操做比copy的花费要大
inclusive caches:上一级缓存有的必须在本缓存中出现一份备份
     好处:当删除一个缓存线的时候只须要扫描L2的缓存线就能够了,不须要再去扫描L1的缓存线
               若是在二级缓存很大,而且cache数据比tag还要大,tags的空间就能够被节省下来,在L2中保留跟多的L1数据
     坏处:若是当L2的关联性比L1差,L1的关联性会被L2影响(不清楚情况)
               当L2的缓存线要被换出,L1的也须要被换出,可能会再次L1的miss率上升
4 虚拟内存
4.1 简单的地址转化
MMU会把地址映射到基于页的方式,想cache line的地址虚拟地址也被分为几个部分,使用这些部分用来构建最后的物理地址
图中前版部分指向了一个页表,页表中包含了物理内存页的地址,而后再经过offset计算出页内的偏移。这就就是一个物理地址了
页表的被保存在内存中,OS分配一个连续的物理内存而且把基地址存入寄存器中。虚拟地址前部分被做为页表的index
4.2多级页表
通常页大小是4KB,那么也就是2**12还有2**20,若是每一个项为4B,那么就须要4MB大小的页表,很不靠谱。因此分为了多级增长了页表的紧凑性,而且页保证了多个程序下对性能的影响不会太大。
4级页表,是最复杂的4个页表被分在不一样的4个寄存器上,Level1是局部物理地址加上安全选项
和上面的相似,惟一不一样的是多级页表是分为屡次,最后查询到物理地址。每一个进程都有本身的页表,因此为了性能和可扩展性尽可能保持较小的页表。把虚拟地址放在一块儿,这样能够减小页表空间。小的程序只会使用页表的不多的部分。
在现实中,由于安全性的关系可执行程序的多个部分被随机分散到各处。如果性能比安全重要,也能够关闭随机。
4.3 优化页表访问
页表是维护在内存中的,可是须要4次访问内存仍是很慢的,访问cache,4次访问也是很慢的。每一个绝对地址的计算都须要大量的访问页表4页表级别的至少要12个cpu周期,而且可能会致使L1miss,致使管线断裂。
因此并不缓存页表,而是缓存计算后的物理地址。由于offset并不参与缓存。放翻译结果的叫作TLB,很快可是也很小,现代的CPU都有提供多级的TLB,并使用LRU算法,当前,TLB引入了组相连,因此并非最老的就会被代替了。
Tag是虚拟地址的一部分用来访问TLB,若是找到再加上offset就是物理地址了。在某些状况下二级的TLB中没有找到,就必须经过页表计算,这个将是代价很是高的操做。在预取操做的时候为TLB预取是不行的,(硬件预取)只能经过指令预取才有TLB预取。
4.3.1 TLB注意事项
TLB是内核全局资源全部的线程,进程都运行在同一个TLB上,CPU不能盲目的重用,由于存的是虚拟地址会出现重复的状况。
因此有一下2个方法:
1.当页表被修改后刷新TLB数据
2.用一个标记来识别TLB中的页表
第一个是不行的页表会在上下文切换的时候被天,刷新会把可能能够继续使用的也刷新掉了
有些cpu结构优化了,把某个区间内的刷新掉,因此代价不是很高
最优的方法是新增一个惟一识别符。可是有个问题是TLB能给的标记有限,有些标示必须可以重用,当TLB出现刷新的时候,全部的重用都要被刷新掉。
使用标记的好处是若是当前使用的被调出,下次再被调度的时候TLB任然能够使用,出了这个还有另外2个好处:
1.内核或者VMM使用一般只须要很短的时间。没有tag会执行2次刷新,若是有tag地址就会被保留,由于内核和vmm并不常常修改tlb,因此上次保存的任然能够使用
2.若一个进程的2个线程进行切换,那么就没有必要刷新。
4.3.2 TLB的性能影响
首先就是页的大小,页越大减小了转化过程,减小了在TLB的容量
可是大的页在物理内存上必须连续,这样会形成内存浪费。
在x86-64中2MB的页,是有多个小的页组成,这些小的页在物理上市连续的。当系统运行了一段时间后,发现要分配2MB的连续内存空间变得十分困难
在linux中会在系统启动时使用hugetlbfs文件系统预先保留,若是要增长就必须重启系统。大的页能够提高性能,在资源丰富,可是安装麻烦也不是很大的问题。好比数据库系统。
增长最小虚拟页的大小也是有问题,内存映射操做验证这些页的大小,不能让更小的出现,若大小超过了可执行文件或者DSO的考虑范围,就没法加载。
第二个影响就是页表级别减小,由于须要参与映射的位数减小,减小了TLB中的使用空间,也减小了须要移动的数据。只是对对齐的需求比较大。TLB少了性能天然变好。
4.4 虚拟化的影响
VMM只是个软件,并不实现硬件的控制。在早期,内存和处理器以外的硬件都控制在DOM0中。如今Dom0和其余的Dom同样,在内存的控制上没有什么区别。
为了实现虚拟化,Dom0,DomU的内核直接访问物理内存是被限制的。而是使用页表结构来控制Dom0,DomU上的内存使用。
无论是软件虚拟仍是硬件虚拟,用户域中的为每一个进程建立的页表和硬件虚拟,软件虚拟是相似的。当用户OS修改了这些页表,VMM就会被调用,根据修改的信息,来修改VMM中的影子页表(原本应该是硬件处理),每次修改都须要调用VMM显然是一个代价很高的操做。
为了减小这个代价,intel 引入了EPT,AMD引入了NPT,2个功能同样,就是为VMM生产虚拟的物理地址,当使用内存的时候,cpu参与把这些地址进一步翻译为物理地址,这样就能够再非虚拟话的速度来运行,更多的vmm中关于内存控制的项被删除,减小了vmm的内存使用,vmm只须要为每一个Dom保留一份页表便可。
为了更高效的使用TLB,就加入了ASID在初始化处理器的时候生产用于TLB的扩展,来避免TLB的刷新,intel引入了VPID(虚拟处理器的id)来避免TLB的刷新。
 
基于VMM的虚拟机,多有2层内存控制,vmm和os须要实现如出一辙的功能
基于KVM的虚拟机就解决了这个问题,KVM虚拟机并非和VMM同样运行在VMM上的,而是直接运行的内核的上面,使用内核的内存管理,虚拟化被KVM VMM进程控制。尽管仍是有个2个内存控制,可是工做都是在内核中实现的,不须要再VMM再实现一遍。
虚拟化的内存访问确定比非虚拟话代价高,并且越去解决问题,可能代价会更高,cpu视图解决这个问题,可是只能减弱,并不能完全解决
5. NUMA的支持
NUMA由于的特点的体系设计,因此须要OS和应用程序作特别的支持
5.1 NUMA硬件
NUMA最大的特色是CPU直接链接内存,这样让cpu访问本地的内存代价就很低,可是访问远程的代价就变高。
NUMA主要解决,大内存,多CPU环境下,解决,多个CPU同时访问内存,致使内存总线热点,或者某个内存模块热点,从而下降吞吐量
AMD公司提出了一个Hypertransport的传输技术,让CPU经过这个传输技术访问内存,而不是直接访问内存。
     图5.1 立方体
这些节点的拓扑图就是立方体,限制了节点的大小2**C,C是节点的互链接口个数。对于2**n个cpu立方体拥有最小的直径(任意2点的距离)
缺点就是不能支持大量的CPU。
 
接下来就是给cpu分组,实现它们之间的内存共享问题,全部这样的系统都须要特殊的硬件,2台这样的机器能够经过共享内存,实现和工做在一台机器上同样。互连在NUMA中是一个很重要的因素,因此系统和应用程序必须考虑到这一点
 
5.2 OS对NUMA的支持
为了让NUMA尽可能使用本地的内存,这里有个特殊的例子只会在NUMA体系结构中出现。DSO的文本段在内存中,可是全部cpu都要使用,这就觉得这基本上都要使用远程访问。最理想的状态是对全部须要访问的作一个镜像,可是很难实现。
 
为了不相似的状况,应该防止常常切换到其余节点运行,从一个cpu切换到另一个就觉得这cache内容的丢失,若是要迁移,OS会选择一个任务的选择一个,可是在NUMA结构体系下,选择是有一些限制的,新的处理器内存的访问代价必定要比老的低,若是没有可用的处理器符合这个条件,os就没得选择只能切换到一个代价较高的。
 
在这个情况下有2种方向,1.只是临时的切换,2.迁移而且把内存也迁移走(页迁移代价过高,须要作大量的复制工做,进程也必须中止,等待数据页迁移完毕,应该避免这种状况的发送)
 
咱们不该该假设应用程序都使用相同大小的内存,一些程序使用大量内存,一些使用小量的,若是都是用本地,早晚本地内存会被耗尽。
 
为了解决这个问题,有个方法就是让内存条带化,可是缺点就是由于迁移,致使内存访问的开销变大。
 
5.3 相关信息
内核发布了一些关于处理器cache的信息(经过sysfs)
     /sys/devices/system/cpu/cpu*/cache
这里包含了资料叫作index*,列出了CPU进程的一些信息。

  type level shared_cpu_map
cpu0 index0 Data 1 00000001
index1 Instruction 1 00000001
index2 Unified 2 00000003
cpu1 index0 Data 1 00000002
index1 Instruction 1 00000002
index2 Unified 2 00000003
cpu2 index0 Data 1 00000004
index1 Instruction 1 00000004
index2 Unified 2 0000000c
cpu3 index0 Data 1 00000008
index1 Instruction 1 00000008
index2 Unified 2 0000000c
表5.1 sysfs中双核cpu cache信息
每一个内核都有L1i,L1D,L2。L1不和其余cpu共享。cpu0和cpu1共享L2,cpu2和cpu3共享L2
如下是4路双核cpu的cache信息

 

  type level shared_cpu_map
cpu0 index0 Data 1 00000001
index1 Instruction 1 00000001
index2 Unified 2 00000001
cpu1 index0 Data 1 00000002
index1 Instruction 1 00000002
index2 Unified 2 00000002
cpu2 index0 Data 1 00000004
index1 Instruction 1 00000004
index2 Unified 2 00000004
cpu3 index0 Data 1 00000008
index1 Instruction 1 00000008
index2 Unified 2 00000008
cpu4 index0 Data 1 00000010
index1 Instruction 1 00000010
index2 Unified 2 00000010
cpu5 index0 Data 1 00000020
index1 Instruction 1 00000020
index2 Unified 2 00000020
cpu6 index0 Data 1 00000040
index1 Instruction 1 00000040
index2 Unified 2 00000040
cpu7 index0 Data 1 00000080
index1 Instruction 1 00000080
index2 Unified 2 00000080
     图5.2 Opteron CPU cache信息
和图5.1相似,可是看表就会发现L2不和任何cpu共享
 
 /sys/devices/system/cpu/cpu*/topology显示了sysfs关于CPU拓扑信息

  physical_
package_id
core_id core_
siblings
thread_
siblings
cpu0 0 0 00000003 00000001
cpu1 1 00000003 00000002
cpu2 1 0 0000000c 00000004
cpu3 1 0000000c 00000008
cpu4 2 0 00000030 00000010
cpu5 1 00000030 00000020
cpu6 3 0 000000c0 00000040
cpu7 1 000000c0 00000080
     图5.3 Opteron CPU拓扑信息
由于thread_sinlings都是设置了一个bit,因此没有超线程,是一个4个cpu每一个cpu有2个内核。
 
/sys/devices/system/node这个目录下包含了系统中NUMA的信息,表5.4显示了总要的信息

  cpumap distance
node0 00000003 10 20 20 20
node1 0000000c 20 10 20 20
node2 00000030 20 20 10 20
node3 000000c0 20 20 20 10
     图5.4 sysfs Opteron 节点信息
上图显示了,cpu只有4个,有4个节点,distance表示了,他们访问内存的花费(这个是不正确的,cpu中至少有一个是链接到南桥的,因此花费可能不止20.)
 
5.4 远程访问开销
     图5.3 多个节点的读写性能
读性能优于写,这个能够预料,2个1hop性能有一点小差异,这个不是关键,关键是2hop性能大概比0hop低了30%到49%,写的性能2-hop比0-hop少了32%,比1-hop少了17%,下一代AMD处理器hypertransport将是4个,对于4路的cpu,间距就是1。可是8路的cpu仍是有相同的问题,由于立方体中8节点的间距仍是3.
 
最后一部分信息是来自PID文件的
00400000 default file=/bin/cat mapped=3 N3=3
00504000 default file=/bin/cat anon=1 dirty=1 mapped=2 N3=2
00506000 default heap anon=3 dirty=3 active=0 N3=3
38a9000000 default file=/lib64/ld-2.4.so mapped=22 mapmax=47 N1=22
38a9119000 default file=/lib64/ld-2.4.so anon=1 dirty=1 N3=1
38a911a000 default file=/lib64/ld-2.4.so anon=1 dirty=1 N3=1
38a9200000 default file=/lib64/libc-2.4.so mapped=53 mapmax=52 N1=51 N2=2
38a933f000 default file=/lib64/libc-2.4.so
38a943f000 default file=/lib64/libc-2.4.so anon=1 dirty=1 mapped=3 mapmax=32 N1=2 N3=1
38a9443000 default file=/lib64/libc-2.4.so anon=1 dirty=1 N3=1
38a9444000 default anon=4 dirty=4 active=0 N3=4
2b2bbcdce000 default anon=1 dirty=1 N3=1
2b2bbcde4000 default anon=2 dirty=2 N3=2
2b2bbcde6000 default file=/usr/lib/locale/locale-archive mapped=11 mapmax=8 N0=11
7fffedcc7000 default stack anon=2 dirty=2 N3=2
N0-N3表示了不一样的节点,后面表示在各个节点开辟的内存页数
从图5.3中,咱们发现读性能降低了9%-30%,若是L2失效,要用远程的内存,那么将会增长9%-30%的开销。
     图5.4远程内存的操做
这里读取性能下降了20%,可是前面图5.3中是9%,差距怎么大,想是使用老的cpu(这些图来自AMD的amdccnuma文档,只有AMD知道怎么回事儿了)( 我特地去查了amdccnuma并无以上2个图
写和复制操做也是20%,当工做集超过cache时,读写操做就不比本地的慢了,主要缘由是访问主存的开销( 不明白,感受没有讲清楚
6 程序员应该作什么
6.1 绕过Cache line
对于并非立刻要使用的数据,先读取在修改并不利于性能,由于这样会让cache效果变低。好比矩阵写入最后一个的时候第一个经常由于不太使用被牺牲掉了。
因此有了非临时写入,直接把数据写入到内存。由于处理器会使用write-combining,使得写入开销并非很大。
#include <emmintrin.h>
void setbytes(char *p, int c)
{
  __m128i i = _mm_set_epi8(c, c, c, c,
                           c, c, c, c,
                           c, c, c, c,
                           c, c, c, c);
  _mm_stream_si128((__m128i *)&p[0], i);
  _mm_stream_si128((__m128i *)&p[16], i);
  _mm_stream_si128((__m128i *)&p[32], i);
  _mm_stream_si128((__m128i *)&p[48], i);
}
由于写入绑定写入只在最后一条指令发送,直接写入不但避免了先读后修改,也避免了污染cache
下面测试矩阵访问的2中方式:行访问,列访问
主要的区别是行访问是顺序的,可是列访问时随机的。
图 6-1矩阵访问模式


Inner Loop Increment
  Row Column
Normal 0.048s 0.127s
Non-Temporal 0.048s 0.160s
直接写入内存和手动写入几乎同样快,主要缘由是使用了write-combining,还有就是写入并不在意顺序,这样处理器就能够直接写回,尽量的使用带宽。
列访问方式,并无write-combining,内存单元必须一个一个写入,由于是随机的。当在内存芯片上写入新行也须要选择这一行,有相关的延迟。
 
在读入方面尚未非零食访问的预取,须要经过指令显示预取,intel实现了NTA load,实现一个buffer load,每一个buffer大小为一个cache line。
如今cpu对非cache数据,顺序访问访问作了很好的优化,也能够经过cache,对内存的随机访问下降开销。
 
6.2 Cache Access
6.2.1 优化L1D访问
经过demo的修改,来提升L1D的访问,demo实现如下功能:
代码:
for (i = 0; i < N; ++i)
    for (j = 0; j < N; ++j)
      for (k = 0; k < N; ++k)
        res[i][j] += mul1[i][k] * mul2[k][j];
从代码中看到mul2的访问方式是列的方式,基本上都是随机的,那么咱们能够先转化如下再计算:
double tmp[N][N];
  for (i = 0; i < N; ++i)
    for (j = 0; j < N; ++j)
      tmp[i][j] = mul2[j][i];
  for (i = 0; i < N; ++i)
    for (j = 0; j < N; ++j)
      for (k = 0; k < N; ++k)
        res[i][j] += mul1[i][k] * tmp[j][k];
测试结果:
                        Original          Transposed
Cycles 16,765,297,870 3,922,373,010
Relative 100% 23.4%
转化后,性能高了76.6%,主要是非顺序访问引发。
为了和cache line对齐,能够再对代码修改:
#define SM (CLS / sizeof (double))

  for (i = 0; i < N; i += SM)
      for (j = 0; j < N; j += SM)
          for (k = 0; k < N; k += SM)
              for (i2 = 0, rres = &res[i][j],
                   rmul1 = &mul1[i][k]; i2 < SM;
                   ++i2, rres += N, rmul1 += N)
                  for (k2 = 0, rmul2 = &mul2[k][j];
                       k2 < SM; ++k2, rmul2 += N)
                      for (j2 = 0; j2 < SM; ++j2)
                          rres[j2] += rmul1[k2] * rmul2[j2];
性能以下:
  Original Transposed Sub-Matrix Vectorized
Cycles 16,765,297,870 3,922,373,010 2,895,041,480 1,588,711,750
Relative 100% 23.4% 17.3% 9.47%
和转化比性能有了6.1%的提高,最后一个是向量结果
代码以下:
#include <stdlib.h>
#include <stdio.h>
#include <emmintrin.h>
#define N 1000
double res[N][N] __attribute__ ((aligned (64)));
double mul1[N][N] __attribute__ ((aligned (64)));
double mul2[N][N] __attribute__ ((aligned (64)));
#define SM (CLS / sizeof (double))

int main (void) { // ... Initialize mul1 and mul2
int i, i2, j, j2, k, k2; double *restrict rres; double *restrict rmul1; double *restrict rmul2; for (i = 0; i < N; i += SM) for (j = 0; j < N; j += SM) for (k = 0; k < N; k += SM) for (i2 = 0, rres = &res[i][j], rmul1 = &mul1[i][k]; i2 < SM; ++i2, rres += N, rmul1 += N) { _mm_prefetch (&rmul1[8], _MM_HINT_NTA); for (k2 = 0, rmul2 = &mul2[k][j]; k2 < SM; ++k2, rmul2 += N) { __m128d m1d = _mm_load_sd (&rmul1[k2]); m1d = _mm_unpacklo_pd (m1d, m1d); for (j2 = 0; j2 < SM; j2 += 2) { __m128d m2 = _mm_load_pd (&rmul2[j2]); __m128d r2 = _mm_load_pd (&rres[j2]); _mm_store_pd (&rres[j2], _mm_add_pd (_mm_mul_pd (m2, m1d), r2)); } } }
// ... use res matrix
return 0; }
 
对于写入大量数据,可是同时使用到不多数据的状况是很常见的如图就是这种状况:
 
顺序访问和随机的访问在L2缓存以前都很容易理解,可是超过L2以后,回到了10%不是由于开销变小,是由于内存访问开销太大,已经不成比例了(有点无法理解,为何会不成比例)。
 
软件pahole能够从代码级分析这个结构体可能会用几个cacheline
struct foo {
  int a;
  long fill[7];
  int b;
};
在编译的时候须要带上调试信息才行,我是在linux 下使用。
struct foo {
        int                        a;                    /*     0     4 */

        /* XXX 4 bytes hole, try to pack */

        long int                   fill[7];              /*     8    56 */
        /* --- cacheline 1 boundary (64 bytes) --- */
        int                        b;                    /*    64     4 */
}; /* size: 72, cachelines: 2 */
   /* sum members: 64, holes: 1, sum holes: 4 */
   /* padding: 4 */
   /* last cacheline: 8 bytes */
上面的信息清晰,这个结构体须要2个cache line,大小为72字节,成员大小64字节,空的字节:4个,有1个空洞
元素b也是4个字节,做者这里说由于int 4个字节要和long 对齐因此被浪费了有个4字节是为了和fill对齐。可是我本身在测试的时候没有发现这种状况,在虚拟机上测试的。
使用pahole能够轻易的对元素重排,而且能够确认,那些元素在一个cache line上。结构体中的元素顺序很重要,因此开发人员须要遵照如下2个规则:
1.把多是关键字的放到结构体的头部
2.若没有特定的顺序,就以结构体元素的顺序访问元素
 
对于小的结构体能够随意的排列结构体顺序,可是对于大的结构体须要有一些规则
若是自己不是对齐的结构体,那么从新排序没有什么太大的价值,对于结构化的类型,结构体中最大的元素决定告终构体的对齐。即便结构体小于cache line,可是对齐后可能就比cache line 大。有2个方法确保结构体设置的时候对齐。
1.显示的对齐请求,动态的malloc分配,一般要那些和对齐匹配的一般是标准的类型(如long,double)。也能够使用posix_memalign作更高级别的对齐。如果编译器分配的,变量能够使用变量属性struct strtype variable  __attribute((aligned(64)));这样这个变量以64为字节对齐
使用posix_memalign会致使碎片产生,并可能让内存浪费。使用变量属性对齐不能使用在数组中,除非数组元素都是对齐的。
2.对于类型的对齐能够使用类型属性
struct strtype {
    ...members...
  } __attribute((aligned(64)));
这样设置以后全部编译器分配的对象都是对齐的,包括数组,可是用malloc分配的不是对齐的,仍是posix_memalign对齐,能够使用gcc的alignof传入用于第二个参数。
 
x86,x86-64处理器支持对非对齐访问可是比较慢,对于RISC来讲对齐并非什么新鲜事,RISC指令自己都是对齐的,有些结构体系支持非对齐的访问,可是速度老是比对齐的慢。
 
如图是对齐访问和非对齐访问的对比,有意思的地方是当work set超过2mb的时候开始下滑,是由于,内存访问变多,而且内存访问的时间占的比重大。
关于对齐必须认真对待,居然支持非对齐访问,可是性能也不能可能比对齐好。
 
对齐也是有反作用的,如使用了自动变量对齐,那么就须要在全部用到的地方都要对齐,可是编译器没法控制调用和堆栈,因此有2个解决方法。
1.代码和堆栈对齐,有必要的话就填充空白.2.全部的调用都要对齐。
对于调用对齐,若是被调用要求对齐,可是调用者不对其的话就会出错。
对于和堆栈对齐,堆栈帧不必是对齐大小的整数倍,因此要填充空白,编译器是知道堆栈帧大小的,因此能够处理。
 
当要对VLA(经过变量决定数组长度),或者alloca的变量对齐,大小只能在运行的时候知道,因此对他们作对齐可能会照成代码变慢,由于生产了其余代码。
gcc提供了一个选项能够更灵活的设置stack的对齐, -mpreferred-stack-boundary =N,这个N表示对齐为2的N次。
在编译x86程序的时候设置这个参数为2,能够减小stack的使用并提升代码执行速度。
对于86-64,调用ABI,浮点类型,是经过SSE寄存器和SSE指令须要16个字节对齐。
 
对齐只是优化性能的一个方面
对于比较大的workset,从新整理数据结构是颇有必要的。不是把全部的元素都放在一个结构体中,而是更具经常使用程度,进程组织。
若数据集中在同一个cache set中会致使conflict misses。
图中:x表示list中2个元素的距离,y表示list总长度,z表示运行时间
当list的全部元素都在一个cache set的时候,元素个数高于关联度时,访问就要从L2中读取增长了读取时间,这就是图中显示的特色。(能猜想数据时均匀的分布在L1每一个cache set中,能力有限未能重现conflict miss)。非对齐的访问或增长cache line 的conflict miss。
 
总结:对于data cache访问主要的优化方式是1.顺序访问(提升预取性能)2.对齐(和cache line,减小cache读取次数) 3.减小结构体对cache的占用(也是为了减小cache读取) 4.减小conflict miss
 
6.2.2 优化L1指令cache访问
由于经过编译器处理,因此程序员并不能直接的控制代码,线性让处理器能够高效获取指令,可是若是有jump会打破这个线性,由于:1.jump是动态的,2.若是出现miss就会花费很长的时间恢复(由于pipeline)。
主要问题仍是在分支预测上的成功率,分支预测在运行到这个指令以前会先把指令取来,若是预测错误,就会花费比较大的时间。
 
1.减小代码大小,注意loop unrolling 和inlining的代码量。
     内联对代码分支预测有好处,可是若是内联代码过大,或者被对此调用反而会增长code的大小。
2.尽可能线性执行,让pipleline不会出现断的状况
     碰到跳转,要不太执行的块移动到外面,在gcc 中能够使用,
     
      #define unlikely(expr) __builtin_expect(!!(expr), 0)
      #define likely(expr) __builtin_expect(!!(expr), 1)
      而且在编译的时候使用 -freorder-blocks对代码进行重排,还有另外一选项 -freorder-blocks-and-partition 可是有使用限制,其不能和exception handling 一块儿使用
 
     inter core2对于小的顺序引入了一个基数LSD(loop stream detector),若是循环指令不超过18个,这个LSD,须要4个decoder,最多4个跳转指令,执行64次以上,
     那么  就会被锁定在指令队列中,下次运行就会变得很快。
3.若是有意义就使用代码对齐
     对齐的意义在于可以造成顺序的指令流,对齐可让预取更加有效。也就意味着让decode更加有效。若出现miss,那么pipeline就会断开。
     从如下几点对齐代码比较有效:1.函数的开始,2.基本代码块的开始,3.循环的开始。
     在第一点和第二点的优化中,编译器每每经过插入nop指令,来对齐,因此有一些开销。
     对于第三点,有点不一样的是,在循环以前每每是其余的顺序的代码,若是,其余代码和循环之间有空隙,那么必须使用nop或者跳转到循环的开始,若循环有不常执行,
     那么nop和jump就会很浪费,比非对齐的开销还要多。
     -falign-functions = N,对函数的对齐,对齐2的N次
     -falign-jumps = N ,对跳转的对齐
     -falign-loops = N对循环的对齐
 
6.2.3 优化L2的cache访问
     L1的优化和L2差很少,可是L2的特色是:1.若是miss了,代价很高,由于可能要访问内存了。2.L2cache一般是内核共享或者超线程共享的。
     因为l2cache 差别较大,而且,并非都引用于数据的。因此须要一个动态的计算每一个线程或者内核的最低安全限制,
     cache结构在Linux下可在 /sys/devices/system/cpu/cpu*/cache。
 
6.2.4 优化TLB使用
     要有话TLB,1.减小内存使用,2.减小查找代价,减小被分配的页表的个数。
     ASLR会致使页表过多,由于ASLR会让stack,DSO,heap随机在执行的是否分配。为了性能能够把ASLR关掉,可是少了安全性。
     还有一种方法是mmap的MAP_FIXED选项来分配内存地址,可是十分危险,只有在开发指导最后一级页表的边界,并可以选择合适地址的前提下使用。
 
6.3预取
6.3.1 硬件预取
     只有2次以上的miss才会除非预取,单个的miss触发预取会照成性能问题,随机的内存访问预取会对fsb照成不必的浪费。
     L2或更高的预取能够和其余内核或者超线程共享,prefetch不能越过一个页 由于硬件预取不懂语义,可能引发page fault或者fetch一个并不须要的页。
     在不须要的时候引发prefetch 须要调整程序结构才能解决 在指令中插入未定义指令是一种解决方法,体系结构提供所有或者部分关闭prefetch
6.3.2 软件预取
     硬件预取的好处是:不须要软件处理,缺点:预取不能超过1页。而软件预取须要源代码配合。
#include <xmmintrin.h>
enum _mm_hint
{
  _MM_HINT_T0 = 3,
  _MM_HINT_T1 = 2,
  _MM_HINT_T2 = 1,
  _MM_HINT_NTA = 0
};
void _mm_prefetch(void *p, enum _mm_hint h);     
以上的命令会把指针的内容预取到cache中。若是没有可用空间了,那么会牺牲掉其余数据,没不要的预取应该要避免,否则反而会增长带宽的消耗。
_MM_HINT_T0,_MM_HINT_T1,_MM_HINT_T2,_MM_HINT_NTA,分别预取到L1,L2,L3, _MM_HINT_NTA的NTA是non-temporal access的缩写,告诉处理器尽可能避免访问这个数据,由于只是短期使用,因此处理器若是是包含性的就不读取到低级的cache中,若是从L1d中牺牲,不须要推到L2中,而是直接写入内存。要当心使用,若是work set太大,牺牲了不少cache line,这些cache line 由要从内存中加载,有些得不偿失了。
图中使用NPAD=31 每一个元素大小为128,只有8%的提高,很不明显。加入预取代码效果不是很明显,能够使用性能计数器,在须要的位子上面加预取。
-fprefetch-loop-arrays是GCC提供的编译选项 其将为优化数组循环插入prefetch指令,对于小的循环就没有好处,要当心使用
 
6.3.3 特殊类型的预取:推测(speculation)
主要的用处是在其余不相关的代码中映入预取,来hide延迟
 
6.3.4 帮助thread
把预取放在代码中会让逻辑变得很复杂,能够使用其余处理器来作帮助thread,帮忙预取数据。难度是不能太慢,也不能太快,增长L1i的有效性。
1.使用超线程,能够预取到l2,升至l1
2.使用dumber线程,只作简单的读取和预取操做
上图看到性能有提高。
能够经过libNUMA中的函数,来知道cache的共享信息
#include <libNUMA.h>
ssize_t NUMA_cpu_level_mask(size_t destsize,
                            cpu_set_t *dest,
                            size_t srcsize,
                            const cpu_set_t*src,
                            unsigned int level);
 
cpu_set_t self;
  NUMA_cpu_self_current_mask(sizeof(self),
                             &self);
  cpu_set_t hts;
  NUMA_cpu_level_mask(sizeof(hts), &hts,
                      sizeof(self), &self, 1);
  CPU_XOR(&hts, &hts, &self);
self 是当前的内核,hts是能够被用来作help线程的内核。
 
6.3.5直接cache访问
现代的硬件能够直接写入到内存,可是对于立刻要使用的数据,就会出现miss,因此intel有了一个技术,能够直接报包的数据写入到cache中。使用DCA(direct cache access)来标记这个包,写在包头中,如图:
       
左右没有使用DCA标记,右图有DCA标记, DCA标记若是fsb识别那么就直接传入到L1cache中了。这个功能须要全部硬件都有相关功能才能使用。

6.4多线程优化
关于多线程的优化,主要从如下3方面展开
1.并发
2.原子性
3.带宽
 
6.4.1 并发优化
一般cache的优化是那数据尽可能放在一块儿,减小代码footprint,可以最大化的把内存放到cache中,可是多线程每每访问类似的数据,这样会致使,若是一个线程对一个内存进行修改请求,cache line 必须是 独占(E)状态,这就意味着要作RFO。就算全部的线程使用独立的内存,而且是独立的仍是可能会出现这种情况。
上图测试在4个P4处理器上进行,在第9.3中有这个测试的demo,代码主要是作对一个内存作5000W次的自增,蓝色的值表示,线程在分开的cache上自增,红色的表示在同一个cache上自增。
当线程写入一个cache是,全部的其余cache都会被延迟,而且cache是独立的。形成红色部分比蓝色部分高的缘由。
上图是intel core2 QX6700 上的测试,4核有2个独立的L2,并无出现这个问题,缘由就是cache是共享的。
 
简单的解决问题的方法:把变量放到线程各自的cache line中,线程变量。
1.把读写的变量和只读的变量发开存放。也能够用来解决读多写少的变量。
2.会被同时使用的变量放到一个结构体上,用结构体来保证,变量在比较近的。
3.把被不一样线程读写的变量写入到他们本身的cache line中。
     int foo = 1;
  int baz = 3;
  struct {
    struct al1 {
      int bar;
      int xyzzy;
    };
    char pad[CLSIZE - sizeof(struct al1)];
  } rwstruct __attribute__((aligned(CLSIZE))) =
    { { .bar = 2, .xyzzy = 4 } };
4.把被多线程使用,可是使用都是独立的变量放入各自线程中,如:
      int foo = 1;
  __thread int bar = 2;
  int baz = 3; 
  __thread int xyzzy = 4;   
6.4.2 原子优化
做者只介绍了原子操做的相关操做,并说尽可能减小原子操做。
 
6.4.3 带宽冲突
每一个处理器都有与内存链接的带宽,因为体系结构的不一样,多个处理器可能共享一个总线或者北桥。那么就可能会出现带宽的争用问题。
1.买速度快的服务器,若是重写程序比较昂贵的状况下
2.优化程序避免miss,调度器并不清楚workload,只能收集到cache miss,不少状况下并无什么帮助
3.尽可能让cache存储变得更有效,让处理器处理一块数据。
     
     
     cache使用效率低下
     
     cache 使用效率高
     调度器不知道workload,因此须要开发人员本身设置调度。
     
进程调度:     
#define _GNU_SOURCE
#include <sched.h>

int sched_setaffinity(pid_t pid, size_t size, const cpu_set_t *cpuset);
int sched_getaffinity(pid_t pid, size_t size, cpu_set_t *cpuset);
线程的调度:
 
  
#define _GNU_SOURCE
#include <pthread.h>

int pthread_setaffinity_np(pthread_t th, size_t size,
                           const cpu_set_t *cpuset);
int pthread_getaffinity_np(pthread_t th, size_t size, cpu_set_t *cpuset);
int pthread_attr_setaffinity_np(pthread_attr_t *at,
                                size_t size, const cpu_set_t *cpuset);
int pthread_attr_getaffinity_np(pthread_attr_t *at, size_t size, 
                                cpu_set_t *cpuset);
6.5 NUMA编程
NUMA的特色是:cpu内访内存时,节点访问代价高,非跨节点代价低。
2个线程在同一个内核中使用相同的cache协做比使用独立的cache要快。
6.5.1 内存策略
对于numa来讲内存的访问,尤其重要,全部出了个内存策略也就是内存分配策略,具体说就是在哪里分配内存。
linux 内核支持的策略种类:
1.MPOL_BIND:在给定的node中分配,若是不行则失败
2.MPOL_PREFERRED:首选在给定的节点分配,失败考虑其余节点
3.MPOL_INTERLEAVE:经过虚拟地址区域,决定在哪一个节点上分配
4.MPOL_DUFAULT:在default区域上分配
以上的递归的方法定义策略只是对了一半,其实,内存策略的有个一个框图:
若地址落在VMA上那么,使用VMA策略,若在共享上,那么使用ShMem Policy,若是,这个地址没有指定策略,那么使用task policy,若是定义了task policy那么就task policy处理,若是没有就system default policy处理。
 
 
6.5.2 指定策略
#include <numaif.h>

long set_mempolicy(int mode, 
                   unsigned long *nodemask, 
                   unsigned long maxnode);
经过以上方法来设置task policy
6.5.3 交换和策略
交换后,内存中的节点信息就会消失,对于一个多处理器共享的页来讲随着时间的推移,相关性就会被改变,
6.5.4 VMA Policy
经过函数来装载 VMA Policy
#include <numaif.h>

long mbind(void *start, unsigned long len,
           int mode,
           unsigned long *nodemask,
           unsigned long maxnode,
           unsigned flags);
VMA区域是 start,start+len
mode 就是前面提到的4个策略
flag参数:
MPOL_MF_STRICT:若是mbind中的地址不在nodemask中,就报错
MPOL_MF_MOVE:若是有地址不在这个node上,试图移动。
MPOL_MF_MOVEALL:会移动全部,不仅仅是这个进程用到的页表,这个参数会影响其余进程的访问
对于一个已经保留了地址,可是没有分配的地址空间,能够使用mbind可是不带参数。
6.5.5 查询节点信息
获取numa策略
#include <numaif.h>
long get_mempolicy(int *policy,
             const unsigned long *nmask,
             unsigned long maxnode,
             void *addr, int flags);
经过虚拟地址查询节点信息
#include <libNUMA.h>

int NUMA_mem_get_node_idx(void *addr);
int NUMA_mem_get_node_mask(void *addr,
                           size_t size,
                           size_t __destsize,
                           memnode_set_t *dest);
查询cpu对应的节点
#include <libNUMA.h>

int NUMA_cpu_to_memnode(size_t cpusetsize,
                        const cpu_set_t *cpuset,
                        size_t memnodesize,
                        memnode_set_t *
                        memnodeset);
#include <libNUMA.h>

int NUMA_memnode_to_cpu(size_t memnodesize,
                        const memnode_set_t *
                        memnodeset,
                        size_t cpusetsize,
                        cpu_set_t *cpuset);
6.5.6 cpu和节点设置
使用cpuset来限制用户或者程序对cpu和内存的使用。
6.5.7 显示优化NUMA
当内地内存和affinity规则,若是全部的线程要访问同一个地址,那么访问本地内存,和affinity是没有效果的。
对于只读的内存能够直接使用复制,把数据复制到要用的节点。
可是对于可读写的就比较麻烦,若是计算不依赖于上次的结果,能够把各个节点的计算累计到各个node上,而后再写入内存。
若是访问内存是固定的,迁移到本地node中,若是访问很高,可是非本地访问减小,那么就是有用的。可是要注意,页迁移仍是有不小的开销的。
6.5.8  利用全部 带宽
在图5.4中,访问远程和本地并无明显的差距,这个是由于,把写入再也不使用的数据,经过内存附加,写入到了其余节点上去了,由于带宽链接DRAM和cpu之间的内部链接是同样的,因此,看不出来性能的差距。
利用全部的带宽是不少方面的:一个就是肯定cache是无效的,由于须要经过远程来放置,另一个是远程内存是否须要带宽
相关文章
相关标签/搜索