程序局部性(时间局部性与空间局部性)与循环展开原理详解
Posted 17岁boy想当攻城狮
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了程序局部性(时间局部性与空间局部性)与循环展开原理详解相关的知识,希望对你有一定的参考价值。
目录
前言
程序局部性分为两个概念:时间局部性与空间局部性,它们俩的特性与编译器优化有关,编译器会将经常访问的内存地址或访问的内存地址相邻不大的区域放入到CPU的Cache缓存中,这样每次访问CPU直接从Cache中取数据,这样能大幅度提升程序效率。
循环展开就是非常简单的,就是增加代码长度以提升循环速度。
程序局部性
时间局部性
时间局部性的意思是当一块内存区域,每隔一段时间就会被访问,局部性的意思是程序中的某一小段,这句话可以从计算机角度理解是一个变量每隔几十ns里就会被访问,也就是编译器会推断,推断你代码中某个变量是否会被频繁访问,也可以理解为在一个函数里频繁对一个变量进行访问,如:
void test(){
int sum = 0;
for(int i = 0;i < 100;++i){
sum = i;
}
}
test就可以理解为程序的一部分,同时从代码里你可以看到有两个内存会被频繁访问,分别是sum与i,至于是两个都放入CPU的Cache里还是只放一个取决于编译器的优化程度以及编译器的考量算法,至于放一级缓存还是二级缓存这里需要理解一个更底层的概念,RAM,一级和二级都是使用RAM存储器制作的,并且它是紧贴CPU内部的,总线地址最短,是属于内部总线,CPU无需通过任何外接控制芯片就能访问内部的内存,速度会大幅度提升。
它们都有独立的地址,编译器会将符合时间局部性的变量值放入这里,然后对它们的访问不在是内存的地址,而是缓存中的地址,将缓存中的值读出来,然后做运算,然后在写入缓存,最后编译器还会判断,你是否会对这个变量做第二次操作,比如此次循环结束以后在别的地方是否还对这个变量有引用?如果有的情况下则会将缓存中的数据写回到内存,如果不会则会丢掉。
现代的编译器将80%的工作都放在了优化上。
空间局部性
空间局部性是指每次访问的内存地址与上一次的内存地址是相邻的或是内存对齐的。
如:
void test(){
int sum[10][10] = {0};
for(int i = 0;i < 10;++i){
for(int j = 0;j<10;++j)}
sum[i][j] = i;
}
}
}
上述代码符合空间局部性,因为在访问sum时是以顺序访问的,通过行来访问列,这样编译器能更容易推到出下一行的地址,那么编译器会将sum[i],i,j的地址放入缓存,那么CPU每次访问时,只需要sum+(i*j),就可以求出sum[i]的下一个行地址,然后在加上j的偏移,比如sum是int,那么加上j就是加上几个int大小,int为4,当j为2时就是加上2*4,找到列里的其中一个内存值,那么编译器通过以上猜测,猜测出了未来要访问的几个地址,如果缓存中能存储这个数组的全部值,那么就全部放入缓存,若不能,则放入一部分,那么这一部分的地址操作会访问cache里的值,剩余部分则是内存操作,现代化编译器可能会将其中一部分先放入cache,当CPU访问完了之后在写回内存,在将剩余部分在放入cache,然后CPU在继续对其进行操作,写完以后在返回内存。
其实在优化期间CPU也会去你看,你其它地方有没有引用这个数组了,如果没有引用,那么上述代码基本上就会被忽略掉了,甚至不会被执行。
在缓存里最终是否写回内存是取决于你的代码中是否引用了这个变量,所以说目前编译器是非常强大的。
通俗易懂的来说:编译器会推断出你是否会经常性的访问这个数组,并且这个数组是否是按照顺序访问的,如果是那么编译器可以根据数组访问规则以及数组类型来推断未来要访问的数组地址是多少,从而将这里面的值取出放入cache,然后对它们的访问都从cache里访问,最后通过是否引用来决定是否写入内存,如果数组过大,cache放不下,则将能放下的部分写入cache,或者先访问完cache里的数组元素后,根据其它地方是否引用来决定是否写入内存,然后在将剩下的值写入cache在继续访问。
在单片机方面我们一般会加volatile来表明编译器不要将这个变量放入cache,同时要求不能忽略,要按照实际方式进行访问与读写。
说完了符合内存局部性的,接下来给大家看一下不符合内存局部性的:
void test(){
int sum[10][10] = {0};
for(int i = 0;i < 10;++i){
for(int j = 0;j<10;++j)}
sum[j][i] = i;
}
}
}
这段代码仅仅是将sum[i][j]改成了sum[j][i],这里讲一下它的区别:
sum[i][j]:每次访问时偏移4个字节
sum[j][i]:每次访问时偏移(i*j)个字节
要符合空间局部性的唯一标准就是每次访问的地址与未来要访问的地址要是相邻的,即int数组每隔元素相隔为一个int字节大小,那么每次访问时的地址偏移要符合int字节大小,这样编译器能根据数组类型预测出未来要访问的地址是多少,如果不按照这样的偏移的话编译器会认为你有别的想法,同时编译器认为地址相差过大,不符合内存对齐规则所以编译器不会去优化,同时编译器也不会取优化任何可能产生风险的代码。
因为在每次循环访问时sum[i][j],其实每次只递增4字节,而sum[j][i]每次递增了(i*j)个字节,编译器在做推断时推断sum[i][j]只需要通过每次递增4个字节就可以知道未来要访问的地址,而sum[j][i]则要去计算这个数组的列是多大,然后做(i*j)大小偏移,虽然这样能推断出但是还是存在一定风险,如内存不对齐,其次是偏移过大不可把控,你无法知道下一次偏移是否正确,但如果偏移过小的情况下风险会越低,因为一次跨越过大的地址空间编译会认为这不是一个可靠的优化方案。
循环展开
循环展开会更容易理解,如:
void test(){
int sum[10][10] = {0};
for(int i = 0;i < 10;++i){
for(int j = 0;j<10;++j)}
sum[i][j] = i;
}
}
}
展开后:
void test(){
int sum[10][10] = {0};
for(int i = 0;i < 10;++i){
for(int j = 0;j<10;j=j+5)}
sum[i][j+1] = i+1;
sum[i][j+2] = i+2;
sum[i][j+3] = i+3;
sum[i][j+4] = i+4;
sum[i][j+5] = i+5;
}
}
}
就是去牺牲代码长度来换取时间上的效率。
这样做的好处是在一次循环时做更多的事情,因为循环结束时会进行逻辑判断然后在进行代码跳转这样一个周期性的工作,如果有局部变量还要做堆栈维护工作。
以上是关于程序局部性(时间局部性与空间局部性)与循环展开原理详解的主要内容,如果未能解决你的问题,请参考以下文章