现在的位置: 首页 > 综合 > 正文

谈谈处理器缓存

2018年05月13日 ⁄ 综合 ⁄ 共 14782字 ⁄ 字号 评论关闭

前言

在电子计算机发展的早期,处理器,内存,以及计算机的其他设备,是作为协调发展的
整体存在的。随着计算机的普及化,其架构已经稳定下来,开发者们为了满足日益增长的应用,开始关注如何进行计算机作为子系统在各种方向上的优化。接下来的
事情,全世界都看到了:为了满足市场需求,伴随着摩尔定律,处理器的发展简直可以用激进来形容,而处理器速度与内存速度之间日趋扩大的鸿沟,使得内存,包
括其他存储体系(比如硬盘)成为提高计算机系统性能的一种主要障碍。

由于对于硬盘这样的存储系统而言,优化通常集中在高速缓存(cache)以及OS的一些机制(例如page)方面,所以在本文,我们不进行深入讨论。事实
上,如何针对主存,也就是内存进行优化,让其不成为系统瓶颈,通常都是非常难的。通常,内存的优化可以由四个方面来归纳:

  • RAM的硬件设计
  • 内存控制器的设计
  • CPU缓存
  • DMA

在上一篇关于
内存技术发展的文章

中,已经大概介绍了RAM的一些技术。本文主要集中介绍CPU缓存。


CPU缓存及其性质介绍

随着CPU频率的疯狂增长,无论是总线技术,还是内存芯片技术,其发
展速度都已经跟不上CPU的脚步。其实赢要跟上也可以,但是付出的代价会非常大,特别是牵涉到成本的时候,因为存储器的速度快慢,和成本多少,是成指数关
系的。人们倾向于用一堆相对比较快的存储器,也不宁愿用很少的非常快的存储器。于是,高速缓存(cache)就出现了。计算机指令和控制数据通常被存储在
cache中,因为这种类型的数据常常在随后的程序中被访问,而且,cache控制器也能很好地保证,cache在所需数据被使用之前得到及时的更新。





然用到了缓存,就要保证其有一个高效的管理控制,当然,OS可以来做这个工作,比如我们把处理器地址空间的部分区域拿给SRAM用,剩下的给DRAM,然
后操作系统根据情况来优化使用SRAM。但这样的管理机制不是最好的,它要求每个进程都在相应的运行软件中管理内存地址的分配,每一个组成程序的模块都得
申明它对快速存储区的共享,考虑到同步,这本身就跟麻烦,如果是多核,还可能出现处理器和处理器之间SRAM容量不一样,附加消耗就更大了,这样就入不敷
出。因此,cache还是被CPU自己透明管理和使用比较好。



但是CPU的cache总是很小的,就算是发展到现在的处理器,也是就MB为单位,我们的硬盘是多大?所以,在实际使用的
时候,我们可以考虑一下优化措施,比如,我们制定一个比较好的策略,这个策略的作用呢,就是在任何时候都能判断,我需要缓存什么数据。因为并不是所有的数
据集都是被同时使用的,我们就可以只存储当前需要用的,到时候再用某种替换策略替换即可。而且,往更好的方面想,说不定我们能在某些数据真正被调用之前,
就能猜到需要它,然后提前放到
cache里面去,要用的时候直接从cache中取,多爽。这个策略呢,是可以实现的,我们称它为“prefetch”。基本上最简单也是最直观的想法,
就是猜主内存中哪些数据接下来会被用到,然后在cache中进行临时的copy。这里谈到


“猜”,说来容易做起难,这个“猜”,该怎么进行呢?我们又不是科幻小说中的魔术师,能预见未来。这里,“猜”的依据,就是一个叫locality,也就是局部性的东西。


其实这个概念的介绍互联网上早就铺天盖地了,不过为了保持文章的连贯性,
我还是大概说一说。对于嵌入式程序员来说,局部性是一个区分你写的程序好还是不好的标准之一,简单解释,就是一般程序都倾向于在未来使用临近于当前引用的
数据项附近的数据项。一般局部性有两种,一种时间局部性,一种空间局部性。直观点说,比如我们程序中经常出现的loop循环,然后相同的程序在loop中
一次一次执行,要一个程序啥都不干,就在loop里面跑圈呢,这就是完美的空间局部性例子。当然现实情况不会是这样的,但通常的数据访问都是发生在相对较
小的一块局部区域。好,假如我们遇到的情况是,举个例子,这次考察组在成都腐败,明天他们就飞到丹佛公费旅游了,这个空间跨度太大了,空间局部性不好使
了,我们还可以考虑时间局部性,那就是很可能在不久以后,他们又会回到成都进行相同的腐败------被引用过的数据可能在不远的将来再次被引用(存储器
位置相同)。还是直观点说,加入我们有一个loop,里面有一个function
call,这个call需要调用某个函数,这个函数可能位于距离很远的存储器地址,空间局部性失效了,但不怕,因为这是loop,每次循环都会访问相同的
位置,这时,时间局部性就能派上用场。

根据时间局部性和空间局部性,我们就可以大概猜一下,在接下来的几个周期里,我们究竟可能需要什么数据。所以,局部性好的程序,运行也就更快。另外,指令
有指令的局部性,数据有数据的局部性,比如一个for循环:for( i=0; i<n; i+= ){ x += y[i]
},这么一个小循环,从数据的局部性性质讲,空间局部性好,因为引用的步长为1,但时间局部性差,因为每个向量元素都只被访问一次。而从指令的局部性性质
讲,空间和时间的局部性都好,因为指令是按照连续的存储器顺序执行的,又会被循环执行多次。

对于实际的程序编写而言,如何让程序尽量对于高速缓存友好?一般来说要注意两点:

  1. 对于局部变量的反复引用是好的,因为编译器能够将它们缓存在寄存器堆中,这是利用了时间局部性
  2. 步长为1的引用模式是好的,因为存储器层次结构中所有层次上的缓存都是将数据存储为连续的块,这是利用的空间局部性

 


缓存的层次和结构

从现在开始的大部分内容,主要是针对数据操作而言,也就是说,主要针对L1d(如果一级缓存区分了指令和数据),L2(通常都是混存)和主存分析。对于L1I指令缓存的分析,后面有一节专门描述。

有了局部性的理论做保证,我们才能提高“猜”的准确率。当然这里的“准确性”的提法本身就是不够准确的,我们通常都是以hit
rate来衡量,比如,当一个cpu需要内存里面的数据d,它首先在缓存中找,找到了就是命中(hit),没找到就是不命中,我们说了这么多局部性,就是
为了提高命中的次数,命中率越高,性能越好。因为如果不命中的话,就要在当前缓存的下一级缓存或者主存中寻找相应的需要数据块,再替换掉当前缓存的某个数
据块。比如吧,我需要找一个包含4的数据块,但我当前一级cache里面只有1,2,3,5,7,8,9,0(假设一级cache可以存8个数据块),那
怎么办?往二级cache里面找呗,找到了,就得把4这个数据块(甚至包括临近数据块)替换到一级cache里面去,因为根据空间局部性,临近数据是很可
能在之后用到的,而根据时间局部性,包含4的这个数据块也是很可能在不久之后就会被访问的。这就叫替换或者驱逐。当然我们得找一个比较好的替换策略,保证
我替换出去的是最不可能在之后被用到的。策略很多,根据不用的用途有不同的机制,比如随机替换,最近最少被使用替换等。这些都是望文了就能生意的,我就不
多废话了。

替换过程在一般人的想法里,应该是这样的,就是从n+1层的存储器中(比如三级缓存)找到的数据块啊,可以被替换到n层存储器(比如二级缓存)的任何位
置。但因为对于缓存来说,越靠近cpu啊,就越贵,容量也越小,这样做的代价有点大,所以一般的实际替换策略都要严格一些,就是第n+1层存储器中的某个
数据块,你只能被替换到上一层(也就是第n层)存储器中的某个位置的子集中去,形象点,比如第n+1层中编号为i的数据块吧,只能被替换到第n层编号为
i%4的数据块中,比如我找到的数据块是4,那我只能替换到第n层存储器的数据块
0中,8也是,12也是。但这样搞,可能引起一种冲突不命中,比如我首先请求访问0,然后是4,然后又是0,4,这样循环。表面上看,数据访问有规律,时
间局部性啊。但实际上,这些数据块不可能同时存在于最近的cache中,因为他们如果要存在于最近的cache,位置都是一样的,0%4 = 0,4%4
= 0。然后你每次引用,都会冲突,都会不命中,因为每一次请求访问都会引起替换。


命中和替换是对于数据处理的策略,而我们还需要关注的,是数据流入流出的具体过程,这就要求我们要了解缓存存储器的结构
了。我们知道,由于容量的原因,cache存储器是不可能全部包含上一级存储器的数据的(不然还要上一级存储器干嘛),而且,cache存储的颗粒度不会
很细,比如说单纯以Byte
或者Word为单位,理由很简单,刚才我们已经谈到了空间局部性,为了更好考虑空间局部性,相邻的存储区域可能是一起被load到cache中的,而且,
考虑到内存内部的原理,为了避免过多的行选通和列选通(CAS和RAS,详见

上一篇关于

内存技术发展的文章

)导致数据传输的延时,我们要尽量在每个row上传输尽量多的数据,所以,cache中存储单位是64字节(老一点CPU的标准是32字节)长的,由连续的words组成的line,我们称为缓存行。

那在缓存中,我们具体怎么来访问这个缓存行呢?缓存是一个关于组的数组,结构大致是这样的:一个缓存地址长度是m位,它就有
2^m个不同地址,由三部分构成,Tag,Cache
Set和Offset。一个缓存包含很多个缓存组(可以理解为子区域),地址由中间的s位地址确定,总共S =
2^s个。每个组里面有E个高速缓存行,E数量不同,会导致缓存性能的差异,这个后面会介绍。地址中的Tag标记,就是用于确定需要的数据是属于组中哪个
行的。每个行包含O = 2^o
byte的数据块,数据块在行中的位置由Offset的值决定,除此之外行还包括一个有效bit,用于表示这个行是否包含有效数据。很显然,m = t +
o + s,而缓存容量是 C = S x E x B,地址位的划分结构如下(以32位地址位为例):


这几个参数是怎么配合的呢?我们来模拟一个过程。当有一条加载指令指示CPU从地址A中读一个数据时,它会将A发送给高速缓存,高速缓存此时开始检查地址
位,它先看cache set,cache
set告诉我们,如果缓存有这个数据,它只可能是在哪个组。好,现在找到找到了这个组,但组里面有很多行啊,数据在哪一行呢?这就是tag标记位的作用。
当且仅当设置了有效位且该行的标记位和A中的标记位匹配,就说明这一行包含了这个数据。但每一行又有很多数据块,于是我们就用上了最后一个参数
offset,这个offset给出了数据在行中的偏移,根据这个偏移,我们就能找到这个数据了。这样,我们无需劳烦更底层cache甚至主存,直接就得
到了这个数据。


看到这里,我们看到了行,组,块这三个和数据描述相关的单位,为了不让人混淆,大概解释一下。

前面说的替换单位都是数据块,是为了方便理解,块是一个固定大小的(现在通常是64bits),包含信息的包,在高速缓存和主存之间来回传送,也就是说,块是Cache和Memory之间数据传输的最小单位。


而具体过程中,传输都是以行为单元的,比如当第n级cache没找到这个数据,需要从第n+1级cache中加载,就会把
包含这个数据的整个cache line加载过来。cache
line的大小通常是64或者128bytes,根据处理器不同而不同,每传输一次cache
line,就要对这么多的块进行8次或者16的的突发(burst)传输。这个过程只需要一条指令,由控制器自动完成,换句话说,Cache
line是Cache和Memory之间数据传输的最小单元


至于组和行的关系,前面已经解释很清楚了。

需要补充一点的是,在传输cache line的时候,里面包含的word并不是完全一定按顺序传输的,我可能只是需要这个cache
line中的排在后面的块中的数据,而如果完全按顺序,我传一个块就要至少4个cycle,那得耽误多少时间啊。有没有变通的方法呢?有,事实上,内存控
制器是可以自由规定对cache
line中word数据请求的顺序,也就是说可以不按套路出牌。它可以和处理器交流信息,得知哪个word是此时处理器真正急需的,并对其首先提出访问请
求。这样的word,我们称其为critical word。这样,在critical
word到达后,急需它的程序就已经可以开始工作了,而此时cache line的其他部分还在传输,这样就大大节约了时间。

这个技术能不能用于所有的情况呢?不能,比如在数据预取的时候。你是不可能在prefetching的时候知道哪个word是关键word,哪个不是的。


缓存的映射

刚才我们介绍了缓存的一些东西,比如prefetch机制,比如命中,冲突和替换

比如缓存具体的组成结构,特别提到了E这个参数的不同,会影响缓存的性能。其实缓存性能是受以上多个因素共同制约的,很明显,谁都希望prefetch的
数据总是对的,访问缓存总是命中的,替换总是不要发生的,但这种缓存是不存在的。我们总是会遇到缓存不够大,冲突到处有的情况,如何在最高的空间利用效率
下达到最高的命中率,成了缓存设计长期以来的挑战性话题。下面,我们就以E参数的分析为基础,展开谈谈对当代处理器中,缓存的映射机制和结构,是如何影响
性能。

根据E的数值,高速缓存可以被分为不用的类,包括直接映射缓存,组相联缓存和全相联缓存。

先来看看直接映射缓存,这种缓存中,每个组只有一行,E = 1,结构很简单,整个缓存就相当于关于组的一维数组。不命中时的行替换也很简单,就一个行嘛,哪不命中替换哪。

但直接映射缓存遇到的冲突情况会特别多,这是为什么呢?刚才我们已经谈到,为了适应容量小的情况,第n+1层存储器中的某个数据块,你只能被替换到上一层
(也就是第n层)存储器中的某个位置的子集中。现在我们来假设一个直接映射的高速缓存,(S,E,B,m) = ( 4,1,2,4
),也就是说,地址是4位(16个),有四个组,每个组一行,每个块两个字节。由于有16个地址,表征16个字节,所以总共有8个块,但只有4个组,也就
是4行。怎么办呢?我们只能把多个块映射到相同的缓存组,比如0和4都映射到组1,1和5都映射到组2,等等。这下问题就来了,比如我先读块0,此时块0
的数据被cache到组0。然后我再读块4,因为块4也是被映射到组0的,组0又只有一行,那就只有把以前块0的数据覆盖了,要是之后我又读块0,就
miss了,只能到下级的存储器去找。实际的循环程序中,很容易引起这种情况,我们称其为抖动。这种情况的存在,自然大大影响了性能。所以,我们需要更好
的映射方案。

另一种缓存是组相联缓存。组相联缓存里,E大于1,就是说一个组里面有多个cache line。E等于多少,就叫有多少路,所以叫E路组相联。

组相联的行匹配就要复杂一些了,因为要检查多个行的标记位和有效位。如果最终找到了,还好,hit了。如果miss了呢?当然,会从下一级存储器中取出包
含所需求数据的行来替换,但一个组里面这么多行,替换哪个行?如果有一个空行,自然就是替换空行,如果没有空行,那就引发了一些其他的替换策略了。除了刚
才介绍过的随机策略,还有最不常使用策略,最近最少使用策略。这些策略本身是需要一定开销的,但要知道,不命中的开销是很大的,所以为了保证命中率,采取
一些相对复杂的策略是值得的。

最后介绍一下全相联缓存。所谓全相联,就是由一个包含所有缓存行的组组成的缓存。由于只有一个组,所以组选择特别简单,此时地址就没有组索引了,只有标记和偏移,也就是t部分和b部分。其他的步骤,行匹配和数据选择,和组相联原理是一样的,只是规模大得多了。

如果说上面关于这三种映射方法的描述非常抽象,为了能让大家理解得更加透彻,我们把存储器比作一家大超市,超市里面的东西就是一个个字节或者数据。为了让
好吃好玩受欢迎的东西能够容易被看到,超市可以将这些东西集中在一块放在一个专门的推荐柜台中,这个柜台就是缓存。如果我们仅仅是把这些货物放在柜台中即
完事,那么这种就是完全关联的方式。

可是如果我想寻找自己想要的东西,还得在这些推荐货物中寻找,而且由于位置不定,我们甚至可能把整个推荐柜台寻找个遍,这样的效率无疑还是不高的。于是超
市老总决定采用另一种方式,即将所有推荐货物分为许多类别,如“果酱饼干”,“巧克力饼干”,“核桃牛奶”等,柜台的每一层存放一种货物。这就是直接关联
的访问原理。这样的好处是容易让顾客有的放矢,寻找更快捷,更有效。

但这种方法还是有其缺点,那就是如果需要果酱饼干的顾客很多,需要巧克力饼干的顾客相对较少,显然对果酱饼干的需求量会远多于对巧克力饼干的需求量,可是
放置两种饼干的空间是一样大的,于是可能出现这种情况:存放的果酱饼干的空间远不能满足市场需求的数量,而巧克力饼干的存放空间却被闲置。为了克服这个弊
病,老板决定改进存货方法:还是将货物分类存放,不过分类方法有所变化,按“饼干”,“牛奶”,“果汁”等类别存货,也就是说,无论是什么饼干都能存入“
饼干”所用空间中,这种方法显然提高了空间利用的充分性,让存储以及查找方法更有弹性。

如图(该图是从我03年发在远望上发的一篇文章上截取的,所以有pcshow的印记):

6.jpg

相联度高有好处,也有坏处。好处刚才已经提到了,坏处就是,相联度越高,成本越高,实现
越复杂,每一缓存组的选通行数增多,说通俗一点,我们面临的选择也就增多了――会有多个选通线等待我们选取。如果我们需要在一个周期中得到一次命中,我们
需要N个比较器来对N路关联缓存进行比较,这意味着路数的增多导致延迟的增多。为了判断一个地址是否存在于当前缓存中,一个比较电路在任何可能的区域搜寻
这个地址:如果是4路关联,就需要进行4次比较。而如果这个过程在一个周期内完成,则需要四个独立的比较器同时工作。而且每一行需要更多的标记位,状态位
和控制逻辑,增加命中时间(从高速缓存传送一个字到
cpu的时间),增加了不命中所需要的额外时间。所以,对于当前越来越大的缓存,全相联的可实现度就非常低,比如我们由于一个4MB的cache,而
cache line只是64B而已,这意味着什么?我们有总共65536个cache
line的入口。你得花多少努力,才能保证逻辑电路能在很短时间内比较完他们的tag,选出合适的line和数据块?当然你可以搞65536个比较器,这
样就能很快,一个line一个比较器,但晶体管数量的增加就是巨大的。也因为如此,全相联的缓存做起来又贵又难,以前一般也就应用在很小的高速缓存中,比
如TLB。 不过呢,现阶段的CPU中的TLB,也已经开始使用组相联模式了。

直接映射缓存又是另外一个极端,它的逻辑太简单了,可能只需要一个比较器和一个多路选择器就够了,而且对于N个cache
line,晶体管的数量也只以O(logN)增长。总的来说,就算缓存容量增长很大,晶体管数量增长也很缓慢。当然,它的缺点呢,刚才介绍的时候就已经说
过了。

相比之下,组相联算是复杂度和效率的折中,它的命中率肯定比直接映射高,又肯定比全相联实现简单。我们可以看一个图,

这个图表示随着缓存的大小,缓存行的长度,以及组相联路数的变化,二级缓存miss的数量。再看一个图:

这个图表示在cache
line长度固定(32B)的情况下,不同路数的缓存在容量增长的情况下miss数量变化的情况。这个图就很直观了,基本上相同容量下,路数越大,命中率
就越高。某些情况下,增加路数所起到的效果甚至等于增大一倍缓存容量。另外还有一个比较直观的,就是命中率的增长,并不是按比例的,这和你具体测试程序最
大使用内存的容量大小有关,比如你的程序整个数据集也就4MB,那你的缓存从8MB换到16MB,也不会有什么大的性能增长。

总的来说,那么对于单线程的工作负荷来说,如果关联路数大于8,得到的好处就不是那么明显了。但对于共享一级缓存的超线程处理器和共享二级缓存的多核来
说,情况就有所变化。因为这时候,你可能会遇到两个或多个程序同时访问同一个缓存的情况,分配到某个程序的关联度就变成原来的一半了。因此,理论上讲,随
着核心(无论是物理核还是逻辑核)的增长,关联路数也应该增长(当然容量也得随之增长)。而当路数到达32的时候(这已经是得益于45nm的工艺了),实
现的难度就比较大了。这时候,处理器设计者不得不使用共享三级缓存,同时让二级缓存被多核的子集所共享,缓解这个矛盾。

这就是一个trade-off的问题,不同公司可能根据不同的情况采用不同的策略,我们用一些生活中的例子来说明。比如

Intel
前些年推出的很有名的处理器Prescott,相对于前一代Northwood,L1数据缓存大了一倍,并使用八路联合,但Intel还是着重表示“L1
数据缓存的访问延迟大体上和Northwood
的8KB四路联合相差不远,但命中率却得到大幅度的提升。”从前文我们也看到了,一般说来,关联路数越多,缓存延迟越大,发生寻找冲突的几率却越小,也就
是命中率会提高。P4的长流水线意味着对数据的延迟更为敏感,比如prescott,它可是有31级流水啊,于是,它的大容量缓存只有较少的缓存关联路
数。而从Conroe开始,流水线长度大大缩短了,只有14级,于是后来的Merom/Conroe的关联路数都是十六路,而最新推出的拥有6MB L2
cache的Penryn/Wolfdale则是二十四路。

再看看Intel的老对头AMD,以Athlon
32的架构为例,它当然非常看重缓存延迟,毕竟集成了内存控制器,导致延迟对其性能的决定性影响,所以虽然拥有极大的一级缓存(无论是数据还是指令),但
关联路数却只是可怜的两路。延迟确实是下来了,可惜寻找数据的效率也降低了,加上大缓存固有的访问延迟,Athlon
32一级缓存实际表现出来性能,至少“性容比”是远远不及Intel的。它所具有的内存缓存性能优势,主要还是集成的内存控制器表现出的超低延迟带来的。

在这里我们看到,关于一级缓存和二级缓存路数,两位死敌的思想是完全相反的。总之,我们不能完全通过相联路数就立即判断效率的高低,毕竟Intel与
AMD在缓存设计上也是有很大不同的,比较时不光要看每个Cache组中的路数,还要看所划分的Cache组数,这两个参数是相互影响的,并且冲突与延迟
几率的减少很难兼得。


hit和miss对性能影响的进一步阐述

前面一直在谈,我们要提高命中率,命中率越高,性能越好。这是一个抽象的描述,那具体点,好多少?好到什么程度?如果不命中,额外的开销还取决于什么?让我们来看一个表:

位置
周期
寄存器

一级缓存

二级缓存

主存

<= 1

~3

~14

~240

表中,就是以cpu执行周期为单位的实际访问时间。有趣的是,片上二级缓存的方位时间开销,很大一部分是浪费在电路延迟上面了,而且二级缓存越大,延迟越大。这是物理特性,只有通过提高生产工艺,比如从60nm制程改进到45nm,才能得到改进。

表面上看,这些周期蛮长的,其实在实际发生的cache加载或者不命中时,并不是一定会浪费掉这么多时间的。这是因为,当代的CPU,都有不同长度的内部
流水线,在这里,指令要先被解码,再准备被执行,大概过程是这样的(该图是从我03年发在远望上一篇文章截取的,所以有pcshow的印记):

6.jpg

准备工作的一部分,
就是将数据从主存或者cache中加载到register,如果存储器加载操作能够尽可能早的在管线中开始执行,那么它就可能和别的操作并行执行,这样延
迟的周期也就相当于被隐藏了。这种情况通常会存在于一级缓存身上,而如果某些CPU的流水管线足够长,二级缓存的操作也可能出现这种情况。另外,前文所介
绍的 prefetch预读取,也会帮忙减少时间代价,

要是能猜对下一个要load的数据,自然就可以提前load,节约时间了。

当然,并不是说早就能早的,我们总是可能遇到很多困难,比如我们需要加载数据的地址,可能正在被管线上的其他指令操作,要很晚才能为这条指令所用。这只是一个例子,事实上,前面的分析,都是理想状况下的。






缓存策略

刚才我们已经谈到过,如果修改了某一级cache的内容,那么上一级甚至主存的内
容都要随之修改,这样才能保证数据的一致性。但怎么修改,什么时候改,针对不同设备的存储cache,是不是该有不同改法,就是一个学问了,由此,导致了
不同写缓存策略的产生。特别是考虑到多线程以及多核时代的来临,在共享内存多处理器中,存在一种可能:一个信息在主内存中,并且同时在每一个单处理器的缓
存中。其中一个操作数信息发生变化,其他信息也要一起改变。缓存的
coherency(一致性)一下子成了相当重要的一个性能标准,因为它是确保操作数在整个系统中及时改变的机制。它具有三个标准:1、每一个写操作都是
瞬间的。2、所有的进程了解每一个操作数数值改变的次序。3、不同的进程可能看到不同的操作数值改变顺序。这里我不会对cache的一致性做过多描述,这
已经是学术界研究的一个领域了,但我们至少要明白,正因为如此,怎么样进行缓存的写操作就值得细细考量了。一般说来,写缓存有4种不同的策略:

  1. 直写策略。这也许是最简单的实现缓存一致性的方法了。方式就是,如果某
    个cache line被写入,处理器立马把这个新的cache
    line复制到上一级缓存以及主存中。也就是说,主存和缓存的内容,无时无刻都不相同,而替换之前cache
    line的内容了,就直接被丢弃了。这个实现是非常简单的,但速度不是很快。这个方法最大的弊处,可以通过举例来说明。比如我有一个程序,不断修改某个本
    地变量,就会在FSB上产生很大的数据流量,直写策略会认为这些修改的数据都是有用的,而实际上,它们大部分的存在周期非常短,也不会在其他地方被调用。
    比如就想像a = 3; a = 4; a = 5,实际上3和4都是无用数据,但它们都会在总线和各级缓存和主存中潇洒走一回。


  2. 写回策略。这个策略要比直写复杂一些了,现在,你修改了一个缓存行后,
    处理器并不是立马把修改应用到上一级缓存或者主存中,相反,这个缓存行只是被标记为“dirty”,表示做过修改。在未来的某个时间点上,cache中被
    写过的line即将要被进入的信息块取代时,好,表征“dirty”的位会告诉处理器,你得把数据写回到主存去,而不是将其丢弃。很明显,这种策略有可能
    大大提高性能,因为很多的中间临时修改过程被省略了,CPU多次刷新同一数据块时,只需把最后的结果写回主内存即可,从而避免了重复写入,大大减少了前端
    总线(cache和memory之间)上需要传输的数据量,提高了效率。当然,写回策略不是没有缺点,其最大的困扰就是一旦处于多核环境下,无论是物理还
    是逻辑,然后大家又同时访问相同位置的内存,如何保证大家同时看到的内容都是一样的?一个最基本的思路,就是当CPU1要读cache时,其它CPU就会
    将自己cache中对应的cache line中标记为dirty的部分写回到memory,并且会将CPU1的相关cache
    line刷新。当CPU1要写cache时,其它CPU就会检查自己cache中对应的cache
    line,如果是dirty的,就写回到Memory,并且会将CPU1的相关cache
    line刷新。这样的话,在程序和二进制对象的内存分配中保持cache line aligned就十分重要,如果不保证cache
    line对齐,出现多个CPU中并行运行的进程或者线程同时读写同一个cache
    line的情况的概率就会很大,这时CPU的Cache和Memory之间会反复出现写回和刷新情况,形成缓存抖动(刚才我们介绍过另一种形式但本质相同
    的抖动)。实际上,光关于这个过程,就差不多可以写一本书了,所以这里就不深入下去了。


  3. 合并写。合并写是一种对存储性能有有限提升的策略,应用的方面也很特
    定,通常用于设备上的RAM,而且是和CPU共用的设备,例如显卡之类的,为了避免数据被cache起来而没有实际写进和其他设备(例如显示卡)共享的内
    存中。它的中心思想,就是尽量减少整个cache
    line的数据转移。怎么理解呢?想像一个场景,存储一个屏幕上的居于同一个水平线上的相邻像素的存储器位置通常也是相邻的,因此,把多个写入动作合并起
    来,比如我们写cache line的时候是一个字一个字写入,但当最后的字修改完成,整个cache
    line才被写入设备。由于一次数据transfer的消耗,远大于一次本地RAM访问的消耗,所以如果仅仅是因为一个cache
    line中的某个字被修改,就进行一次cache line
    transfer,是非常不明智的,特别是当下一个操作可能就是修改相邻的字的时候。而合并写策略,正好就回避了这个问题,由于内存一次存取的数据越多,
    效率就越高,因此采用Write-Combining后,可以显著提升不需要频繁修改操作的non-cacheable内存操作效率。可以看出,它主要适
    用于写入次序无关紧要的设备RAM,更具体的资料,有兴趣的朋友可以看看

    这里


  4. Non-cacheable。是的,不写缓存,也是写缓存策略之一,当
    然,这也是一个专为特定应用准备的策略。所谓
    Non-cacheable区域,就是在主内存中开辟一块专门的区域,所有对内存直接进行写入操作的设备都只能把数据写入该区域,同时,该区域中的内容不
    会被写入Cache中,因此Cache中的数据与Non-Cacheable Block
    中的数据互不干扰,也就不会发生不一致的情况。但是如果每次写入的数据只有32bit大小,存储的效率将会非常糟糕,因为内存控制器的通道在
    Pentium的时候已经是64位宽了。为此,人们使用Write-Combining(合并写)的方式,在CPU上建立一小块cache,这块小
    cache是和一致性cache(例如 L1 cache/L2
    cache等)完全独立的高速缓存,大小基本上就是一个或者若干个cache-line的大小,需要写入到non-cacheable内存的数据会被先存
    入到Write-Combining
    Buffer,待这些数据达到一定大小的时候(例如64-byte),就执行存入到non-cacheable内存中的动作。原理完全就是刚才我们介绍的
    合并写的原理。


这玩意对于嵌入式领域倒是常见,比如一个开发板,用于开关LED灯的内存地址就是直接映射到这个区域的,因为你永远不会cache这个区域。另外对于一些总线上的设备的地址映射,也是映射到内存的Non-cacheable区域,比如PCI设备之类。

很多现代CPU都采用了片上L1和L2Cache。对于L1
Cache,大多是write
though的,这就导致了写性能会受到上一级缓存的速度的限制(写得太多了),通常这一级缓存特点就是读比写快得多,甚至达到了读 +
写操作(比如copy and paste)所用时间约等于写所用时间,所以在后来的core 2处理器中,Intel直接就把策略改成了写回了;L2
Cache则一直是写回的,由于不会立即写memory,写操作带来的延时相对小了点,但这就会导致Cache和Memory的内容的不一致。这里顺便插
一句,通常缓存的操作中,写操作浪费的时间延时比读操作要大得多得多,这是优化任何一个程序时都应该注意的。另外,对于MP(MULTI
Processors)的环境,由于Cache是CPU私有的,不同CPU的Cache的内容也存在不一致的问题,因此很多MP的的计算架构都实现了
Cache
Coherence的机制,即不同CPU的Cache一致性机制。我们这里不涉及具体的任何缓存针对多核support的技术细节,比如MESI
cache coherency protocol这种技术。感兴趣的朋友可以自己找paper看。


指令缓存和指令追踪缓存

之前我们已经讲过,前面的分析,主要都是针对缓存的数据存取,为什么呢?当然,缓存存储的内容不止是数据,也包括指令,但是由于指令具备的以下性质,导致指令缓存的机制远没有数据缓存那么复杂:

  • 需要执行的代码量通常都是比较固定的,和问题的复杂度有关
  • 程序指令通常由编译器产生,产生过程中就能得到一定的优化
  • 相比数据存取机制,程序流程的可预测度要强得多,更有利于prefeching
  • 代码一般都具有很好的空间和时间局部性

随着技术的发展,现在CPU的执行过程都流水化了,指令的执行都是分成阶段的,一开始安
心等在指令缓存中,等待被处理单元执行时才取出,然后解码,准备相关参数,最后执行。这种过程有两个缺点,首先某些 x86
指令非常复杂,经由解码器时需耗费太多的时间来解码,尤其是以X86为代表的CISA家族,最糟的情况下所有解码单元忙于解码复杂指令,以至于阻碍处理器
的执行管道。另一个问题则是如果在小循环的代码中,每当这些代码进入执行路径几次,编码就得进行几次,造成时间的浪费。而且我们知道现在的流水管线是越做
越长,这也就意味着,一旦流水线遭遇不测发生中断,或者出现分枝预测的错误,造成的延时就会很大。可以说,这算是一个一直困扰CPU设计人员的问题。

从Pentium 4开始,Intel采用了一种新的缓存类型,叫trace cache,俗称指令追踪缓存。这玩意不是用来和一般的指令缓存一样存储指令的原始字节序列,而是被用来存储解码单元送出来的微操作。参见示意图:

6.jpg

指令追踪缓存(见图中红色部分)位于指令解码器和内核第一层计算管线之间,指令在解码单
元内获取和解码之后,微操作必须先经过追踪缓存的存储和和输出,才能到达内核第一层计算管线被执行。这样可以有效弥补上述两个缺点,因为此时所取的都是解
码后的指令,还避免了指令的重复调用。更重要的是,追踪缓存确保处理器管道持续处于指令满载的状态,避免执行路径由于解码单元延迟的情况。应该说,指令追
踪缓存确实是一个值得称道的技术。

当然,这些分析都是基于L1I缓存分析的,至于L2的缓存,因为是数据和指令统一存储,自然就不可能作出和追踪缓存类似的架构了。


一点补充:如何诊断你的系统瓶颈?

这是我查资料的时候看到的。

CPU,磁盘还是内存?我的系统瓶颈在哪里?估计每个人都会好奇想知道吧。有趣的是,在查阅资料的时候,我从网上看到了一篇相关文章(未找到出处),正是讲的这方面的内容,在这里发布,供各位参考:

从步骤 1 开始,首先查看 CPU 使用情况,按照诊断 CPU、内存或磁盘瓶颈的指导进行操作。对于下面的每个步骤,查找一段时间内的趋势,从中收集系统运行性能较差时的数据。另外,只有将这些数据与系统正常运行时收集的数据进行比较时才能进行准确的诊断。

步骤 1:

# sar -u [interval] [iterations]??? (示例: sar -u 5 30)

其中%idle是 CPU 未在运行任何进程的时间百分比,看看它是否过低。在一端时间内 %idle 为零可能是 CPU 瓶颈的第一个指示。

不是 -> 系统未发生 CPU 瓶颈。转至步骤 3。
是 -> 系统可能发生了 CPU、内存或 I/O 瓶颈。转至步骤 2。


步骤 2

%usr 是否较高? 很多系统正常情况下花费 80% 的 CPU 时间用于用户, 20% 用于系统。别的系统通常会使用 80% 左右的用户时间。

不是 -> 系统可能遇到 CPU、内存或 I/O 瓶颈。转至步骤 3。
是 -> 系统可能由于用户进程遇到 CPU 瓶颈。转至部分 3,部分 A, 调整系统的 CPU 瓶颈。


步骤 3

%wio 的值是否大于 15?

是 -> 以后记住这个值。它可能表示磁盘或磁带瓶颈。转至步骤 4。
不是 -> 转至步骤 4。


步骤 4

# sar -d [interval] [iterations]

用于任何磁盘的 %busy 是否都大于 50? (请记住,50%
指示一个大概的 指南,它可能远远高于您系统的正常值。在某些系统上,甚至 %busy 值为 20
可能就表示发生了磁盘瓶颈,而其他系统正常情况下可能就为 50% busy。)对于同一个磁盘上,avwait 是否大于 avserv?

不是 -> 很可能不是磁盘瓶颈,转至步骤 6。
是 -> 此设备上好像发生了 IO 瓶颈。转至步骤 5。


步骤 5

系统上存在磁盘瓶颈,发生瓶颈的磁盘上有哪些内容?

原始分区,文件系统 -> 可能是磁盘 IO 瓶颈。
Swap -> 可能是由于内存瓶颈导致的。

转至步骤 6。


步骤 6

# vmstat [interval] [iterations]

在很长的一端时间内,po 是否总是大于 0?对于一个 s800 系统
(free * 4k) 是否小于 2 MB,(对于 s700 系统 free * 4k 是否小于 1 MB)? (值 2 MB 和 1 MB
指示大概的指南,真正的 LOTSFREE 值,即系统开始发生 paging 的值是在系统引导时计算的,它是基于系统内存的大小的。)

不是 -> 如果步骤 1 中的 %idle 较低,系统则很可能发生了 CPU 瓶颈。如果 %idle 不是很低,则可能不是 CPU、磁盘 IO或者内存瓶颈,而是其他瓶颈。

是 -> 系统上存在内存瓶颈。

抱歉!评论已关闭.