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模块允许内存控制指定本次传输多少数据。可以是2、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没有用并行总线,而用了串行总线。串行总线可以达到更高的频率,串行化的负面影响,甚至可以增加带宽。使用串行总线后
- 每个通道可以使用更多的模块。
- 每个北桥/内存控制器可以使用更多的通道。
- 串行总线是全双工的(两条线)。
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]):
T exe = N[(1-F mem )T proc + F mem (G hit T 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 |
每个内核都有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是无效的,因为需要通过远程来放置,另外一个是远程内存是否需要带宽