来来,咱也说说缓存与优化
最近更新晚了,给大家说声抱歉,诸君久等了,直接上干货,不过由于本人知识有限,难免有错,还望大家指正,拜谢。
以后不想学习的时候,俺也有理由啦,要陪老婆啊,哈哈。看了非诚勿扰,一大叔端了一锅羊肉来求婚,没成,咱们下了场吃羊肉去。
下午看了一部分斯坦福大学的神经网络公开课,名牌大学关注的都是习以为常的问题,比如为什么老朋友会感觉亲切;看了将近一周隐式马尔科夫模型,还是不甚明了,太理论化了,而且大部分资料都是英文的,看来必须学专业英语了。
再宣传下群CodeForFuture:163354117,期待您的加入……
一.问题引入
以前看过一篇关于经典矩阵相乘算法的优化,不过当时不怎么理解……
看经典相乘算法:
for(i=0;i<n;++i)
for(j=0;j<n;++j)
{double sum=0;
for(k=0;k<n;++k)
sum+=a[i][k]*b[k][j];c[i][j]+=sum;}再看优化后的程序:
for(i=0;i<n;++i)
for(k=0;k<n;++k)
{r=a[i][k];for(j=0;j<n;++j)
c[i][j]+=r*b[k][j];}说实话,再次看到的时候我想起了Floyd算法,第一个是错误顺序,第二个是正确顺序。现在说下为什么第二个的实际效率高。仔细对比下就会发现这两段代码是等价的(要不然比较就没意义了),假设n是4,前者对矩阵A,有良好的空间局部性,假设一次能缓存四个元素,则每次迭代对于A只有0.25次miss,但是对于B,则不然,因此B是按列访问的,每次访问都会miss,因此每次迭代总的miss数是1.25。后者对于矩阵C和矩阵B都有良好的局部性,每次迭代都只有0.25词miss,因此总的miss数是0.5。后者每次迭代多了一次存储(对C[i][j]写入),但是即便如此,后者的运行效率也比前者高。 总而言之,要想程序跑得快,就要在程序中多利用局部性,让缓存hold住你的数据,减少访存次数。要知道CPU可以在3个时钟周期内访问到L1 cache,10个时钟周期左右的时间访问到L2 cache。访问内存却要上百个时钟周期,孰快孰慢,很清楚了吧?
具体请参见博主这一篇:http://www.cnblogs.com/hxsyl/archive/2012/11/17/2774977.html( ps:当时讲DP的时候还扯上去了,现在想想好惭愧,这和DP有毛线关系啊)。
不太了解的,咱们继续往下看……
二.时空局部性
一个优秀的程序、优美的代码,一般都具有良好的局部性。简洁、高效是每个程序员的追求(看过《短码之美》,说实话这本书真没劲)。了解程序的局部性,能编写出更高效的代码。因为有良好局部性的程序能更好的利用缓存。
现在就简要介绍下程序的局部性原理(请回忆组成原理,讲数据库设计的时候还用上了,嘿嘿,刘静老师、刘晓燕老师(她俩师徒)大大好人)。局部性通常有两种形式:时间局部性(temporal locality)和空间局部性(spatial locality);前者指的是被引用过一次的存储器位置在未来会被多次引用(通常在循环中),后者指的是如果一个存储器的位置被引用,那么将来他附近的位置也会被引用(有什么用呢?别忘了缓存以块为交换单位)。
咱再看一个例子:
int getSum(int[] array) {int sum = 0;
/*
也可以使用增强型for循环 ,不过此处为说明问题*/for(int i=0; i<array.length; i++) {sum += array[i];}return sum;
}上例for 循环中的 sum 有良好的时间局部性,因为在for循环结束之前,每次执行循环体都有对 sum 的访问,而 sum 没有空间局部性(因为sum 是标量,也就是说通过 sum 这个地址(可认为是基址)只能得到一个值)。对于循环体中的数组array则有良好的空间局部性。数组array是按顺序存储的(在实际中多数情况也按顺序存储),每次访问array[i]总是在 array[i-1] 的下一个位置,而 array数组 没有时间局部性,因为每个元素只被访问一次。
上例中按顺序、连续的对 数组的引用,我们称为步长为1的引用模式。同理,在一个连续的向量中,每隔k个元素对向量进行访问,称为步长为k的引用。一般来说,随着步长的增加,空间局部性会下降。
对于多维数组而言,步长对空间局部性的影响显得尤为重要。比如对二维数组求和,双重for循环内,若是先对行遍历再对列遍历,那么具有很好的空间局部性(每次按行访问各列,实际上内存里也是这么存储的,按行存储;我又想起了谭老师的那本书里说的,确定二维数组需要确定列(所以二维数组形参第二维必须传),咱不说什么理论,想一下若是按行存储,则必须确定列)。若是先按列再按行,相对于上面的例子,该例的for 循环中交换了 索引 i j 的位置,也就是说在对array[][]进行遍历的时候,以列序为主序,即先访问第一列,在访问第二列。。。对array[][]的存储仍是行序为主序,这意味着每访问一个元素,就要跳过k(列数)个存储器才能访问下一个。于是得到一个简单的结论:该例中对array[][]的访问是以步长为k 的模式,没有良好的空间局部性。
通过上面的例子我们知道:在对向量的访问中,如果访问顺序和存储顺序一致,并且是连续访问,那么这种访问具有良好的空间局部性。
这部分主要说了为什么有良好局部性的程序有更好的性能,小结下:
- 重复引用同一个变量有良好的时间局部性
对于步长为k 的引用的程序,步长为1 的引用具有良好的空间局部性。k越大,空间局部性越差。 对于取指令来说、循环有较好的时间和空间局部性。 下面咱看看缓存……
三.缓存
我们知道,计算机里的存储器有:硬盘、主存、高速缓存(其中又有一级高速缓存、二级高速缓存等等)、再往上就是寄存器。存储器在计算机内部的组织方式如下图所示:
![]()
相信上图大家并不陌生,我们发现,越往上,存储器的容量越小、成本越高、速度越快(上课老师就没说出这层意思,我想起了高中物理课本上的那些简单例子)。
为什么会出现这样的结构呢?早期的存储器层次结构只有三层:cpu寄存器、DRAM主存以及磁盘存储。由于CPU和主存之间巨大的速度差异,系统设计者被迫在CPU寄存器和主存之间插入了一个小的SRAM高速缓存存储器称为L1缓存,大约可以在2--4个时钟周期内访问。再后来发现L1高速缓存和主存之间还是有较大差距,又在L1高速缓存和主存之间插入了速度较快的L2缓存,大约可以在10个时钟周期内访问。于是,在这样的模式下,在不断的演变中形成了现在的存储体系。现在可以知道整个存储器体系被分为了很多层,那么他们之间是如何协调工作以提高运行效率的呢(ps:分层思想很重要,比如TCP/IP,数据库的三级模式两级映像,对了,其实社会越发展,分工越明确,有些事干与不干无关乎品德问题,这是社会发展使然,自己体会去吧)?
那么何为缓存呢?暂时咱们可以这样理解:速度快的存储器缓存了速度慢存储器的数据。准确的描述:对于每个k ,位于k层的更快更小的存储器设备作为第k+1层的更大更慢存储设备的缓存。就是说,k层存储了k+1层中经常被访问的数据。在缓存之间,数据是以块为单位传输的。当然不同层次的缓存,块的大小会不同。一般来说是越往上,块越小(注意:以块为单位,不是单独元素)。如上图,k是k+1的缓存,他们之间的数据传输是以块大小为单位的。如上图中,k中缓存了k+1中块编号为 4、9、14、3的数据。
当程序需要这些块中的数据时,可直接冲缓存k中得到,这比从k+1层读数据要快(上文指令周期的问题)。
当程序需要第k+1层中的某个数据时d,会首先在它的缓存k层中寻找。如果数据刚好在k层中,就称为缓存命中(cache hit)。当需要的数据对象d不再缓存k中时,称为缓存不命中。当发生缓存不命中时,第k层的缓存会从k+1层取出包含数据对象d的那个块,如果k层的缓存已经放满的话,就会覆盖其中的一个块,至于要覆盖哪一个块,这是有缓存中的替换策略决定的,比如说可以覆盖使用频率最小的块,或者最先进入缓存的块。。这里不再讨论。在k层从k+1层中取出数据对象d后,程序就能在缓存中读取数据对象d了。
上面图片显示了计算机的缓存结构,这一部分主要介绍了计算机存储器内部的组织结构以及他们之间的关系,并简单说了下缓存实现机制,以及缓存和局部性之间的关系。至于缓存内部如何实现,程序如何从缓存内存取数据,莫急,咱继续来看……
四.缓存内部机理
编写高效的程序并不只在于算法的精巧,还应该考虑到计算机内部的组织结构、CPU微指令(我也忘了,去看组成吧)的执行、缓存的组织和工作原理等。好的算法在实际中不见得有高效率,如果完全没有考虑缓存、微指令实现的话,所以算法的理论效率不一定和实际效率一样,我想起了熊神那孩子去某游戏公司面试时面试官问的如何优化算法(大概是这意思)?说什么时空复杂度,面试官说NO,后来他说需要从机器字长等方面去考虑,呵呵,有时候经验胜于一切。
下图清晰的说明了通用缓存的组织结构:
可以看到,缓存内部是以组的形式组织的。图中的每一块代表一组,每组由一到多行组成(当然图中的是每组有多行)。每一行包括1 位标记位(valid bit)标明这行的信息是否有可用t 位的标记,标明它是属于这一组的哪一行,剩下的空间是存储数据的数据的空间,可以看出在下面的图中把数据地址分为了三部分,左边 t 位是标记行号的,中间的 s 位标明组号,最后的 b 位则是数据块在行内的偏移量。通常来说,缓存器可描述为(S; E; B; m)其中S为缓存中的组数,E为每组的行数,B为每行存储的字节数,m为缓存的地址位数。所以缓存的容量为C=S*E*B。(这个过程在上一篇博文中就有简单的介绍)当cpu需要从主存中取出地址为A的数据时,先把地址A发送给缓存,如果缓存中存有地址为A的数据,就从缓存中取出该数据传给CPU。那么,缓存是如何知道自己是否存有地址为A的数据呢?这就和缓存的组织有关系了,上文中缓存把地址组分为了三部分,t 、s 、b。所以,只要简单的检查地址中的数据位,就能判断该地址是否在缓存中,如果在的话,还能确定该数据的位置。参数 s 、b 、m 把m个地址位分为三个字段。如上图:
下面的详细的寻址过程:地址A中的中间S 位标记了该地址在缓存中属于哪一组,先通过s 确定这个地址在缓存中的哪一组。通过上面一步确定了属于的组后,地址A中的左边 t 位标记了该地址在该组的哪一行。最后由右边的 b 指出地址A中的元素在该行的偏移位。也就是确定在这行的哪一个位置。说实话,感觉介绍的有些乱。CPU从主存中读数据的详细过程和上文中说的一样,这里假设计算机的存储结构只有:cpu寄存器,L1缓存,主存。当cpu执行一条读存储器地址为A的指令,它向高速缓存请求该地址,如果缓存命中,缓存很快返回数据。如果缓存不命中,L1缓存向主存请求该数据,在这期间cpu必须等待。当被请求块从主存到达缓存L1时,L1缓存将数据放在他的一个高速缓存行里,然后将数据从行中提取返回给cpu。也就是说,如果缓存不命中,先要把数据存入缓存,再返回给cpu。
概括的说,高速缓存确定一个请求是否命中有三个过程:
- 组选择
- 行匹配
- 字抽取
部分资料来自 《computer systems》,大牛的书通俗易懂……
直接映射高速缓存中取数据(每组一行),下面将以直接映射高速缓存为例,一步步说明cup从高速缓存中取数据的过程。
- 组选择
如上图所示,缓存从地址A中抽取出中间的s 位,这 s 为的数值就标记了该地址所在的组。这里可以把缓存当作是一维数组,其中每个元素是一个组,而地址中的 s 位则是这些组的索引。如图中的组标记为 0001 对应组 set1。这要把地址中间的 s 为提取,就能得到该地址在缓存中对应的组。
- 行选择
选好组 i 之后,就是确定地址A在组 i 的哪一行。因为直接映射缓存的每一组只有一个行。所以只要看A地址中的行标记是否和缓存中的行标记位匹配。匹配则地址A中的数据在缓存中。
- 字抽取
既然已知道了地址A中的数据在缓存中的位置,最后一步只要更具地址A中表示偏移量的位,从缓存中取出数据即可。
当缓存不命中的时候,就要从下一层存储中取出数据,放入缓存的某个位置中,放入的位置就由请求地址A中的组索引确定所在缓存的组,行所以确定应该放置的行。如果组中的行都是有效缓存行了,就必须要驱逐现有的一个行。对于直接映射高速缓存,每组包含一个行,替换策略就变的很简单,用新来的行替换当前行。
假设我们有一个直接映射的高速缓存,描述如下(S; E; B; m)=(4;1;2;4),也就是说:该缓存有4个组(s=4),每组有一行(E=1),每一块有两个字节(B=2)存储器的地址是4位的(m=4)。
该状态有图描述如下:
五.参考文献
博客园: http://www.cnblogs.com/yanlingyin/
维基百科:http://zh.wikipedia.org/wiki/CPU%E7%BC%93%E5%AD%98(逃逸符无处不在)
《Computer System》