赞
踩
关键字: 并行程序设计,Cache优化,超标量优化,CPU Profilling
实验平台: x86平台,Windows 10 64位操作系统,CPU型号AMD
R7-5800h,8核16线程,主频3.2GHz,单核拥有32KB一级数据、32KB一级指令缓存、512KB二级缓存、共享16MB三级缓存;DDR416G内存,TDM-GCC 编译器+Code::Blocks集成开发环境,CPU Profilling工具:AMD uProf
编译选项: Have g++ follow the C++17 GNU C++ language standard
,Target x86_64(64bit),Optimize even more(for speed)
代码链接:样例代码
按照矩阵的乘法运算法则进行算法设计,对于一个1*n的向量 B 1 ∗ n B_{1*n} B1∗n和一个n*n的方阵 A n n A_{nn} Ann,采用逐列访问矩阵元素的方法,依次将 A n ∗ n A_{n*n} An∗n的每一列与 B 1 n B_{1n} B1n相乘并累加,每次求出内积的一个结果。
由于按列访问方阵的元素容易造成更多的Cache缺失,使得运行效率降低,于是考虑从Cache的角度对算法进行优化。采用逐行访问矩阵元素的方法,依次将方阵A的第i行与B的第i个值相乘,每次得到最终向量结果的一个累加因子,在最后累加得到内积结果。
逐行访问矩阵元素时, A i j A_{ij} Aij与 A i ( j + 1 ) A_{i(j+1)} Ai(j+1)常常同时在Cache当中,当对 A i j A_{ij} Aij执行操作后能快速方便的从Cache中取出 A i ( j + 1 ) A_{i(j+1)} Ai(j+1)执行下一步操作,省去了从内存中读取数据的步骤,更加快速。
本次实验采取的数据规模是一个1*5000的行向量B,一个5000*5000方阵A,二者的向量内积是一个1*5000的向量sum,具体定义与赋值可见代码
逐列访问的平凡算法
int i,j;
for(i=0;i<MAX;i++)
for(j=0;j<MAX;j++)
sum[i]+=B[j]*A[j][i]; //平凡算法,逐列访问
Cache优化算法
int i,j;
for(i=0;i<MAX;i++)
for(j=0;j<MAX;j++)
sum[j]+=A[i][j]*B[i]; //Cache优化,逐行访问
用计算程序执行时间来衡量程序的性能,经过多次运行程序测试后得到程序的执行时间结果如下表:
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 平均执行时间 | |
---|---|---|---|---|---|---|---|---|---|---|---|
平凡算法 | 0.242 | 0.258 | 0.268 | 0.267 | 0.248 | 0.281 | 0.253 | 0.267 | 0.279 | 0.278 | 0.2641 |
Cache优化 | 0.146 | 0.143 | 0.141 | 0.146 | 0.153 | 0.123 | 0.148 | 0.145 | 0.149 | 0.129 | 0.1423 |
可见,Cache优化后的算法的程序执行速度是平凡算法的1.86倍,平均执行时间是平凡算法的的53.88%,性能提升近乎一倍。
更改程序数据规模后得到以下结果:
数据规模 | Cache优化平均执行时间 | 平凡算法平均执行时间 | 加速比 |
---|---|---|---|
500 | 0.025 | 0.026 | 1.04 |
1000 | 0.030 | 0.032 | 1.06 |
2000 | 0.038 | 0.051 | 1.34 |
综上可知,进行Cache优化后的程序性能优于平凡算法,而且随着问题规模的增大这样的优势愈发明显,在5000的规模时优化倍率达到1.86,性能几乎翻倍,这与Cache优化算法的访存模式具有很好空间局部性,令cache 的作用得以发挥,减少Cache缺失的次数,提高工作效率。
本次实验平台为AMD R7-5800h处理器,采用AMD Zen3架构,可以使用AMD官方CPU
Profill工具AMD
uProf来对执行程序时CPU的活动进行分析,分析当输入规模为5000时平凡算法与Cache优化后各自的L1与L2级的Cache
Miss等,得到结果如下:
算法 | CPI | %L1_DC_MISSES | L1_DC_MISS_RATE |
---|---|---|---|
平凡算法 | 1.09 | 33.77 | 0.11 |
Cache优化 | 0.60 | 4.36 | 0.01 |
由表3可知,优化后CPI大大减少,程序性能获得较大提升;同时,L1级缓存的缺失率(L1_DC_MISS_RATE)也由原先0.11降至0.01,提升明显;L1级的Cache Miss的比例(%L1_DC_MISS)也由原先33.77%降至4.36%,提升也十分明显。
表4中可以看见每千次指令中由L1 Cache Miss引起的L2级缓存的访问(L2_ACCESS_FROM_L1_MISS(PTI))从优化前的126.85次降至13.94,也代表着L1级缓存缺失情况的减少;
同时L2级缓存缺失(L2_MISS_FROM_L1_MISS(PTI))也大大降低,从每千次47.22降至0,L2级的缓存命中率( L 2 _ H I T _ F R O M _ L 1 _ M I S S L 2 _ A C C E S S _ F R O M _ L 1 _ M I S S \frac{L2\_HIT\_FROM\_L1\_MISS}{L2\_ACCESS\_FROM\_L1\_MISS} L2_ACCESS_FROM_L1_MISSL2_HIT_FROM_L1_MISS)也由原先的60.37%上升至98.42%,可见从Cache角度优化算法后性能得到了很大的提升。
从程序的角度分析,当采用平凡算法时逐列访问矩阵元素, A i j A_{ij} Aij与 A ( i + 1 ) j A_{(i+1)j} A(i+1)j的内存位置并不相邻,并且随着数据规模的变大相隔更远,不会同时在L1级Cache中,导致访问下一个元素时无法快速从Cache中取出下一个操作对象,发生Cache缺失,又要花费更多时间从内存中去读数,增加了时间开销,效率降低。
而当优化算法后采取逐行访问矩阵元素的方式, A i j A_{ij} Aij与 A i ( j + 1 ) A_{i(j+1)} Ai(j+1)绝大部分情况下都同时在Cache当中,此时对 A i j A_{ij} Aij执行操作后能快速方便的从Cache中取出 A i ( j + 1 ) A_{i(j+1)} Ai(j+1)执行下一步操作,不会发生Cache缺失,省去了从内存中读取数据的步骤,更加快速。
从实验结果来讲,无论从运行时间还是L1、L2级缓存的表现均能判断出Cache优化后的算法更好,且随着数据规模变大,这样的优势愈发明显,程序运行的CPI和Cache
Miss次数等等指标都得到明显改善,说明此次实验采用的算法是合理且有效的。
即简单的顺序相加,用一个遍历待累加数组A[MAX]的的循环依次累加A[i],最终得到结果。
两路链式算法
尝试将循环展开,设置两个中间结果sum1与sum2,分别为数组的奇数项和与偶数项和,两个中间变量的累加可以在同一个循环中执行,因此只用执行 M A X 2 \frac{MAX}{2} 2MAX次循环,最后将sum1与sum2相加即可得到结果。通过同时执行两步运算,这样的算法节省了程序执行时间,体现了超标量的思想。
为了让实验现象明显,本次实验的待累加数组长度设置为1e8,并且循环50次求和操作。
int t=50; //循环次数
while(t--){
for(int i=0;i<MAXN;i++)
sum+=a[i];
}
两路链式算法
while(t--){
for(int i=0;i<MAXN;i+=2)
{
sum1+=a[i];
sum2+=a[i+1];
}
}
sum=sum1+sum2;
用计算程序执行时间来衡量程序的性能,经过多次运行程序测试后得到程序的执行时间结果如下表:
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 平均时间 | |
---|---|---|---|---|---|---|---|---|---|---|---|
平凡算法 | 1.547 | 1.554 | 1.565 | 1.586 | 1.559 | 1.563 | 1.564 | 1.565 | 1.558 | 1.567 | 1.5628 |
两路链式算法 | 1.230 | 1.214 | 1.213 | 1.217 | 1.216 | 1.232 | 1.209 | 1.211 | 1.234 | 1.215 | 1.2191 |
可见进行了超标量优化后,程序性能变为原先的1.28倍,而且可以预计随着数据规模的提升这样的提升会更加明显,更改数据规模后得到结果如下:
数据规模 | 平凡算法 | 两路链式算法 | 优化倍率 |
---|---|---|---|
1e5 | 0.014 | 0.013 | 1.07 |
1e6 | 0.026 | 0.022 | 1.18 |
1e7 | 0.169 | 0.135 | 1.25 |
运用AMD的CPU Profilling工具AMD uProf分析优化前后程序后得到结果如下:
CYCLES_NOT_IN_HALT | RETIRED_INST | CPI | |
---|---|---|---|
平凡算法 | 5247750000 | 5848750000 | 0.50 |
两路链式算法 | 2158750000 | 5629750000 | 0.38 |
CYCLES_NOT_IN_HALT | RETIRED_INST | CPI | |
---|---|---|---|
平凡算法 | 233500000 | 476750000 | 0.42 |
两路链式算法 | 178000000 | 419000000 | 0.37 |
CYCLES_NOT_IN_HALT | RETIRED_INST | CPI | |
---|---|---|---|
平凡算法 | 37500000 | 80250000 | 0.53 |
两路链式算法 | 32750000 | 61500000 | 0.47 |
CYCLES_NOT_IN_HALT | RETIRED_INST | CPI | |
---|---|---|---|
平凡算法 | 850000 | 13750000 | 0.55 |
两路链式算法 | 675000 | 11500000 | 0.49 |
可见在不同规模下的优化前后程序的总指令数(RETIRED_INST)几乎相等,由于AMD的Profilling工具会将一些系统内核程序的指令数等一同统计,因此会有微小差别;而在CPU的循环数(CYCLES_NOT_IN_HALT)上,可以发现随着数据输入规模增大二者差距愈发变大,当数据规模为1e8 * 50时,优化后的两路链式算法在循环数上仅有原先的41%,大大减少了循环的次数;从CPI方面来看,优化后的算法CPI表现优于平凡算法,在1e8 * 50数据规模下优化后0.38的CPI显著优于优化前的0.50,可见程序性能得以提升,超标量优化切实有效。
从程序算法的角度来看,平凡算法依次累加数组元素,要进行MAX次循环才能得到结果,这样的串行算法仅仅利用了CPU的一条工作线,效率并不高,尤其是当数据规模很大时,执行时间也会更加漫长,没能较好地利用CPU资源;而优化后的两路链接算法一步执行两次累加操作,只用
M
A
X
2
\frac{MAX}{2}
2MAX次循环就能得到结果,对比普通的链式算法,两路链式算法能更好地利用
CPU超标量架构,两条求和的链可令两条流水线充分地并发运行指令。因此两路链接算法表现会更加优秀,以此类推,还可以增加至三路、四路链接算法,增加流水线数从而进一步提升性能。
而从实验结果来讲,当输入数据规模不大时二者无论是执行时间还是CPU循环数指令数等都差别不大,这是由于CPU本身具有强大的运算能力,因此面对不大的数据输入时体现出来的差别也不大;而当数据规模增大后,无论从运行时间还是Profilling后得到的CPU循环数、CPI等指标都明显表现出优化后的算法更高效,说明此次实验采用的优化算法是合理有效的。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。